From 845979902ce18292bc4cae98aba380d67d58eefd Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 15 Oct 2026 23:08:46 +0200 Subject: [PATCH] Tours: client tests for the new hooks and views, row chips fade at their end, admin user table aligned; strict and lint ratchets green --- client/scripts/eslint-baseline.json | 2 +- client/scripts/size-baseline.json | 2 +- client/src/components/Map/MapViewGL.tsx | 1 - .../src/components/Map/gpxTrackLayers.test.ts | 66 +++++++++ .../Planner/DayPlanPlaceSubline.tsx | 3 +- .../components/Tours/TourListFilterMenu.tsx | 23 +-- client/src/components/Tours/TourListRow.tsx | 101 ++++++++----- .../components/Tours/ToursSidebar.test.tsx | 37 +++++ .../provenance/TourProvenanceSection.test.tsx | 137 ++++++++++++++++++ .../Tours/provenance/useSourceCheck.test.ts | 103 +++++++++++++ .../Tours/provenance/useSourceCheck.ts | 35 +++-- .../provenance/useTourProvenance.test.ts | 48 ++++++ .../components/Tours/tourPresentation.test.ts | 7 + .../src/components/Tours/tourPresentation.ts | 10 ++ .../Tours/useTourDayAssignment.test.ts | 68 +++++++++ .../Tours/useTourListFilters.test.ts | 76 ++++++++++ .../Tours/useTourMapVisibility.test.ts | 62 ++++++++ .../trip/places/MToursSelectionList.tsx | 3 +- client/src/pages/TripPlannerPage.tsx | 4 +- client/src/pages/admin/AdminUsersTab.tsx | 7 +- .../pages/tripPlanner/useTourDetailActions.ts | 41 ++++++ .../src/pages/tripPlanner/useTourWorkspace.ts | 35 +---- client/src/repo/tourRepo.test.ts | 39 +++++ client/src/styles/fadeEnd.css | 10 ++ server/src/nest/tours/tours.service.ts | 3 +- 25 files changed, 810 insertions(+), 113 deletions(-) create mode 100644 client/src/components/Map/gpxTrackLayers.test.ts create mode 100644 client/src/components/Tours/provenance/TourProvenanceSection.test.tsx create mode 100644 client/src/components/Tours/provenance/useSourceCheck.test.ts create mode 100644 client/src/components/Tours/provenance/useTourProvenance.test.ts create mode 100644 client/src/components/Tours/useTourDayAssignment.test.ts create mode 100644 client/src/components/Tours/useTourListFilters.test.ts create mode 100644 client/src/components/Tours/useTourMapVisibility.test.ts create mode 100644 client/src/pages/tripPlanner/useTourDetailActions.ts create mode 100644 client/src/styles/fadeEnd.css diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index daccac71b..92a3c91aa 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -8,7 +8,7 @@ "trek/no-swallowed-catch": 133, "max-params": 35, "trek/store-without-selector": 69, - "react-hooks/refs": 561, + "react-hooks/refs": 560, "react-hooks/set-state-in-effect": 197, "@typescript-eslint/no-floating-promises": 167, "react-hooks/exhaustive-deps": 121, diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index da77f265e..b039afbe0 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -7,7 +7,7 @@ "src/components/Budget/CostsPanel.tsx": 1816, "src/components/Map/MapView.tsx": 1409, "src/components/Map/MapViewGL.test.tsx": 2920, - "src/components/Map/MapViewGL.tsx": 2372, + "src/components/Map/MapViewGL.tsx": 2371, "src/components/Map/RouteCalculator.ts": 1195, "src/components/PDF/TripPDF.tsx": 1082, "src/components/Planner/DayDetailPanel.test.tsx": 2333, diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index afba8e4a1..7d2f86c0b 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -42,7 +42,6 @@ import { ratingBadgeHtml } from './ratingBadge' import type { Poi } from './poiCategories' import { poiPinParts } from './poiMarker' import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers' -import { parseRenderableRouteGeometry } from '../../utils/routeGeometry' import { buildPoiPopupHtml } from './placePopup' import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client' import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults' diff --git a/client/src/components/Map/gpxTrackLayers.test.ts b/client/src/components/Map/gpxTrackLayers.test.ts new file mode 100644 index 000000000..c941fb037 --- /dev/null +++ b/client/src/components/Map/gpxTrackLayers.test.ts @@ -0,0 +1,66 @@ +import type mapboxgl from 'mapbox-gl'; +import { describe, expect, it, vi } from 'vitest'; +import type { Place } from '../../types'; +import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'; + +function fakeMap() { + const layers = new Map(); + const sources = new Map }>(); + const map = { + addSource: vi.fn((id: string) => sources.set(id, { setData: vi.fn() })), + getSource: (id: string) => sources.get(id), + addLayer: vi.fn((layer: { id: string; filter?: unknown }) => layers.set(layer.id, { filter: layer.filter })), + getLayer: (id: string) => layers.get(id), + setFilter: vi.fn((id: string, filter: unknown) => layers.set(id, { filter })), + }; + return { map: map as unknown as mapboxgl.Map, layers, sources, raw: map }; +} + +describe('gpxTrackLayers (PR1-08)', () => { + it('adds the rim, the line, the picked track and the hit line once, picking nothing yet', () => { + const { map, layers } = fakeMap(); + expect(hasGpxTrackLayers(map)).toBe(false); + addGpxTrackLayers(map); + expect(hasGpxTrackLayers(map)).toBe(true); + expect([...layers.keys()]).toEqual([ + 'trip-gpx-casing', + 'trip-gpx-line', + 'trip-gpx-selected-casing', + 'trip-gpx-selected', + GPX_HIT_LAYER_ID, + ]); + expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]); + }); + + it('puts only places with a drawable track into the source, with their colour and rim', () => { + const { map, sources } = fakeMap(); + addGpxTrackLayers(map); + const track = { id: 5, route_geometry: '[[47,11],[47.1,11.1]]', route_color: '#ff0000' } as Place; + const plain = { id: 6, route_geometry: null } as Place; + setGpxTracks(map, [track, plain]); + const data = sources.get('trip-gpx')?.setData.mock.calls[0]?.[0]; + expect(data.features).toHaveLength(1); + expect(data.features[0].properties).toEqual({ color: '#ff0000', cased: true, place_id: 5 }); + expect(data.features[0].geometry.coordinates).toEqual([ + [11, 47], + [11.1, 47.1], + ]); + }); + + it('points the picked layers at the selected place, and at none again when nothing is picked', () => { + const { map, layers } = fakeMap(); + addGpxTrackLayers(map); + selectGpxTrack(map, 5); + expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], 5]); + expect(layers.get('trip-gpx-selected-casing')?.filter).toEqual(['==', ['get', 'place_id'], 5]); + selectGpxTrack(map, null); + expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]); + }); + + it('does nothing before the style has its layers', () => { + const { map, raw } = fakeMap(); + selectGpxTrack(map, 5); + setGpxTracks(map, []); + expect(raw.setFilter).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Planner/DayPlanPlaceSubline.tsx b/client/src/components/Planner/DayPlanPlaceSubline.tsx index 1cca82cd9..458389ec6 100644 --- a/client/src/components/Planner/DayPlanPlaceSubline.tsx +++ b/client/src/components/Planner/DayPlanPlaceSubline.tsx @@ -2,6 +2,7 @@ import type { TourListItem } from '@trek/shared'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useTranslation } from '../../i18n'; +import '../../styles/fadeEnd.css'; import type { Category, Place } from '../../types'; import { formatTime } from '../../utils/formatters'; import { fs } from '../shared/DialogShell'; @@ -95,7 +96,7 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo return ( // One line like the row's name: when the hover controls come out, the description // gives way and the pills keep their place instead of wrapping onto a second line. -
+
{place.place_time && ( {formatTime(place.place_time, locale, timeFormat)} diff --git a/client/src/components/Tours/TourListFilterMenu.tsx b/client/src/components/Tours/TourListFilterMenu.tsx index 61c1ab45f..ea702b629 100644 --- a/client/src/components/Tours/TourListFilterMenu.tsx +++ b/client/src/components/Tours/TourListFilterMenu.tsx @@ -3,24 +3,9 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from '../../i18n'; import { fs } from '../shared/DialogShell'; import { InfoDot } from './tourParts'; -import type { TourFilter } from './tourPresentation'; +import { ORIGIN_LABEL_KEY, statusLabel } from './tourPresentation'; import type { TourListFilters } from './useTourListFilters'; -type Translate = (key: string, params?: Record) => string; - -/** How each origin is named in a list, the words the source badge uses. */ -export const ORIGIN_LABEL_KEY = { - trek: 'tours.badge.planned', - gpx: 'tours.badge.gpx', - wanderer: 'tours.badge.wanderer', -} as const; - -export function statusLabel(status: TourFilter, t: Translate): string { - if (status === 'unplanned') return t('places.unplanned'); - if (status === 'planned') return t('places.planned'); - return t('places.all'); -} - function MenuRow({ active, label, @@ -132,7 +117,11 @@ export default function TourListFilterMenu({ filters }: { filters: TourListFilte /> {open && ( -
+
{(['all', 'unplanned', 'planned'] as const).map((status) => ( { event.stopPropagation(); toggle.onToggle() }} - className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full shadow-sm ring-1 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${toggle.hidden - ? 'bg-surface-tertiary text-content-muted ring-edge-faint hover:text-content' - : 'bg-surface-card text-content-muted ring-edge-faint hover:text-content'}`} + className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${toggle.hidden ? 'bg-surface-tertiary' : 'bg-surface-card'}`} > {toggle.hidden ? : } @@ -48,6 +47,65 @@ function MapToggleButton({ toggle }: { toggle: NonNullable + dimmed: boolean +}) { + return ( +
+ +
+ {tour.name} + {/* One line like a plan row's: the badges keep their place, the description gives way, + and what does not fit fades out instead of ending at a hard edge. */} +
+ + + + {tour.description && ( + + {tour.description} + + )} +
+
+
+ ) +} + +/** + * The eye and the row's actions on the right. A hidden tour keeps its eye in + * sight; the rest come out on hover, focus or selection, as a plan row's + * controls do, so the name and badges have the room otherwise. + */ +function TourRowActions({ action, mapToggle, selected }: Pick & { selected: boolean }) { + const hoverToggle = mapToggle && !mapToggle.hidden ? mapToggle : null + return ( + <> + {mapToggle?.hidden && } + {(action || hoverToggle) && ( +
event.stopPropagation()} + className={`flex-none items-center gap-1.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}> + {hoverToggle && } + {action} +
+ )} + + ) +} + /** * A tour in a list, laid out like a stop in the day plan: its tile with the * grade, the name, and one line of the plan's white badges (time, distance, @@ -89,41 +147,8 @@ export default function TourListRow({ tour, // The line's colour on the map, as a plan row shows its category.
{children} diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index 42cca4a63..8d795bad9 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TourListItem } from '@trek/shared' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { useSettingsStore } from '../../store/settingsStore' +import { useAuthStore } from '../../store/authStore' +import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore' import { useTripStore } from '../../store/tripStore' import { toursApi } from '../../api/client' import ToursSidebar from './ToursSidebar' @@ -304,4 +306,39 @@ describe('ToursSidebar', () => { expect(badge).toHaveTextContent('Days 1, 3') expect(badge).toHaveAttribute('aria-label', 'Day 1, Day 3: Summit') }) + + it('PR1-13: offers the source and routing limit only where the list varies, and narrows by them', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Show' })) + const source = screen.getByRole('group', { name: 'Source' }) + fireEvent.click(within(source).getByRole('button', { name: /GPX import\s*1/ })) + expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Imported')]) + expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('+1') + // Only one tour type in the list: no type filter. + expect(screen.queryByRole('group', { name: 'Type' })).toBeNull() + fireEvent.click(within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ })) + expect(screen.getByRole('status')).toHaveTextContent('No tour matches these filters.') + fireEvent.click(screen.getByRole('button', { name: 'Reset filters' })) + expect(screen.getAllByRole('option')).toHaveLength(3) + }) + + it('PR1-07/08: a row shows its line colour, and its eye hides the tour from the map until shown again', () => { + seedStore(useAuthStore, { user: { id: 3 } as never }) + seedStore(useTripStore, { places: [{ id: 42, route_color: '#e11d48' }] as never }) + render() + const row = screen.getByRole('option', { name: /Selected ridge walk/ }) + expect(row.querySelector('[aria-hidden="true"][style]')).toHaveStyle({ background: '#e11d48' }) + + fireEvent.click(within(row).getByRole('button', { name: 'Hide on the map' })) + expect(useTourMapVisibilityStore.getState().hidden).toEqual([42]) + expect(screen.getByRole('status')).toHaveTextContent('Hidden on the map') + // A hidden tour keeps its eye in sight, not only on hover. + expect(within(row).getByRole('button', { name: 'Show on the map' })).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(screen.getByRole('button', { name: 'Show all' })) + expect(useTourMapVisibilityStore.getState().hidden).toEqual([]) + }) }) diff --git a/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx b/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx new file mode 100644 index 000000000..5109b49f6 --- /dev/null +++ b/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx @@ -0,0 +1,137 @@ +import type { TourProvenance, TourSourceCheckResponse } from '@trek/shared'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../../tests/helpers/render'; +import TourProvenanceSection from './TourProvenanceSection'; +import type { SourceCheck } from './useSourceCheck'; + +const base: TourProvenance = { + origin: 'trek', + createdAt: '2026-07-01 10:00:00', + source: { kind: 'trek' }, + derivedFrom: null, + plannedOver: false, + editedLocally: null, +}; + +function check(over: Partial = {}): SourceCheck { + return { + checking: false, + result: null, + check: vi.fn(), + canReimport: false, + confirming: false, + askReimport: vi.fn(), + cancelReimport: vi.fn(), + reimporting: false, + reimport: vi.fn(), + ...over, + } as SourceCheck; +} + +describe('TourProvenanceSection (P2C-02, MF-03)', () => { + it('names a planned tour as planned here, with no local state and no check', () => { + render(); + expect(screen.getByText('tours.provenance.label')).toBeInTheDocument(); + expect(screen.getByText('tours.badge.planned')).toBeInTheDocument(); + expect(screen.getByText('tours.provenance.createdAt')).toBeInTheDocument(); + expect(screen.queryByText('tours.provenance.localState')).toBeNull(); + expect(screen.queryByRole('button', { name: /tours.provenance.check/ })).toBeNull(); + }); + + it('shows a GPX import with its file, and that it was planned over keeping its track', () => { + render( + + ); + expect(screen.getByText('tours.badge.gpx')).toBeInTheDocument(); + expect(screen.getByText('ridge.gpx')).toBeInTheDocument(); + expect(screen.getByText('tours.provenance.importedAt')).toBeInTheDocument(); + expect(screen.getByText('tours.provenance.plannedOver')).toBeInTheDocument(); + }); + + it('links a wanderer snapshot to its trail, tells local edits apart and checks the source on purpose', () => { + const onCheck = vi.fn(); + render( + + ); + expect(screen.getByText('trails.example')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'tours.provenance.openSource' })).toHaveAttribute( + 'href', + 'https://trails.example/trail/view/@me/t1' + ); + expect(screen.getByText('tours.provenance.externalUpdated')).toBeInTheDocument(); + expect(screen.getByText('tours.provenance.editedLocally')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /tours.provenance.check$/ })); + expect(onCheck).toHaveBeenCalledTimes(1); + }); + + it('says what the check found, the parts that changed, and asks before the reimport', () => { + const result: TourSourceCheckResponse = { + state: 'changed', + changes: ['name', 'route'], + externalUpdated: '2026-10-14 12:00:00Z', + importedUpdated: null, + editedLocally: false, + reimport: { trailId: 't1', contentHash: 'h' }, + }; + const askReimport = vi.fn(); + const { rerender } = render( + + ); + expect(screen.getByRole('status')).toHaveTextContent('tours.provenance.state.changed'); + expect(screen.getByText('tours.provenance.change.name')).toBeInTheDocument(); + expect(screen.getByText('tours.provenance.change.route')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /tours.provenance.reimport/ })); + expect(askReimport).toHaveBeenCalledTimes(1); + + rerender( + + ); + expect(screen.getByText('tours.provenance.body.unavailable')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /tours.provenance.reimport/ })).toBeNull(); + }); +}); diff --git a/client/src/components/Tours/provenance/useSourceCheck.test.ts b/client/src/components/Tours/provenance/useSourceCheck.test.ts new file mode 100644 index 000000000..4568d32c5 --- /dev/null +++ b/client/src/components/Tours/provenance/useSourceCheck.test.ts @@ -0,0 +1,103 @@ +import { act, renderHook } from '@testing-library/react'; +import type { TourListItem, TourSourceCheckResponse } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSourceCheck } from './useSourceCheck'; + +const repo = vi.hoisted(() => ({ checkSource: vi.fn(), importTrail: vi.fn() })); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })); +vi.mock('../../../repo/wandererRepo', () => ({ + wandererRepo: repo, + wandererErrorCode: (error: unknown) => (error as { code?: string })?.code ?? 'unknown', +})); +vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); + +const changed: TourSourceCheckResponse = { + state: 'changed', + changes: ['route'], + externalUpdated: '2026-10-14', + importedUpdated: '2026-10-01', + editedLocally: false, + reimport: { trailId: 't1', contentHash: 'h' }, +}; +const newTour = { place_id: 99, name: 'Ridge' } as TourListItem; + +beforeEach(() => { + vi.clearAllMocks(); + repo.checkSource.mockResolvedValue(changed); + repo.importTrail.mockResolvedValue({ tour: newTour }); +}); + +describe('useSourceCheck (MF-03)', () => { + it('asks only on purpose, and offers the reimport for a change when someone takes it in', async () => { + const onReimported = vi.fn(); + const { result } = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported })); + expect(repo.checkSource).not.toHaveBeenCalled(); + expect(result.current.result).toBeNull(); + + await act(async () => { + await result.current.check(); + }); + expect(repo.checkSource).toHaveBeenCalledWith(1, 7); + expect(result.current.result).toEqual(changed); + expect(result.current.canReimport).toBe(true); + + act(() => result.current.askReimport()); + expect(result.current.confirming).toBe(true); + await act(async () => { + await result.current.reimport(); + }); + expect(repo.importTrail).toHaveBeenCalledWith(1, { trailId: 't1', contentHash: 'h' }); + expect(onReimported).toHaveBeenCalledWith(newTour); + expect(toast.success).toHaveBeenCalled(); + expect(result.current.result).toBeNull(); + expect(result.current.confirming).toBe(false); + }); + + it('without anyone to take the new snapshot in, or for an unchanged trail, offers no reimport', async () => { + repo.checkSource.mockResolvedValue({ ...changed, state: 'unchanged', changes: [], reimport: null }); + const unchanged = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported: vi.fn() })); + await act(async () => { + await unchanged.result.current.check(); + }); + expect(unchanged.result.current.canReimport).toBe(false); + + repo.checkSource.mockResolvedValue(changed); + const nobody = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7 })); + await act(async () => { + await nobody.result.current.check(); + }); + expect(nobody.result.current.canReimport).toBe(false); + }); + + it('a failed check or reimport says so and changes nothing', async () => { + repo.checkSource.mockRejectedValueOnce({ code: 'unreachable' }); + const onReimported = vi.fn(); + const { result } = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported })); + await act(async () => { + await result.current.check(); + }); + expect(result.current.result).toBeNull(); + expect(toast.error).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.check(); + }); + repo.importTrail.mockRejectedValueOnce({ code: 'preview_stale' }); + await act(async () => { + await result.current.reimport(); + }); + expect(onReimported).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledTimes(2); + }); + + it('another tour in the same dialog starts unchecked', async () => { + const { result, rerender } = renderHook(({ placeId }) => useSourceCheck({ tripId: 1, placeId }), { + initialProps: { placeId: 7 }, + }); + await act(async () => { + await result.current.check(); + }); + rerender({ placeId: 8 }); + expect(result.current.result).toBeNull(); + }); +}); diff --git a/client/src/components/Tours/provenance/useSourceCheck.ts b/client/src/components/Tours/provenance/useSourceCheck.ts index 052752237..0a7c9b02f 100644 --- a/client/src/components/Tours/provenance/useSourceCheck.ts +++ b/client/src/components/Tours/provenance/useSourceCheck.ts @@ -1,5 +1,5 @@ import type { TourListItem, TourSourceCheckResponse } from '@trek/shared'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useTranslation } from '../../../i18n'; import { wandererErrorCode, wandererRepo } from '../../../repo/wandererRepo'; import { useToast } from '../../shared/Toast'; @@ -22,15 +22,24 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt const { t } = useTranslation(); const toast = useToast(); const [checking, setChecking] = useState(false); - const [result, setResult] = useState(null); - const [confirming, setConfirming] = useState(false); const [reimporting, setReimporting] = useState(false); - - // Another tour shown in the same dialog starts unchecked. - useEffect(() => { - setResult(null); - setConfirming(false); - }, [placeId]); + // The answer and the question belong to the tour they were asked for: + // another tour shown in the same dialog starts unchecked. + const [state, setState] = useState<{ placeId: number; result: TourSourceCheckResponse | null; confirming: boolean }>({ + placeId, + result: null, + confirming: false, + }); + const result = state.placeId === placeId ? state.result : null; + const confirming = state.placeId === placeId && state.confirming; + const setResult = useCallback( + (next: TourSourceCheckResponse | null) => setState({ placeId, result: next, confirming: false }), + [placeId] + ); + const setConfirming = useCallback( + (next: boolean) => setState((current) => ({ ...current, placeId, confirming: next })), + [placeId] + ); const check = useCallback(async () => { if (checking) return; @@ -43,7 +52,7 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt } finally { setChecking(false); } - }, [checking, placeId, t, toast, tripId]); + }, [checking, placeId, setResult, t, toast, tripId]); const reimport = useCallback(async () => { const payload = result?.reimport; @@ -61,7 +70,7 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt } finally { setReimporting(false); } - }, [onReimported, reimporting, result, t, toast, tripId]); + }, [onReimported, reimporting, result, setConfirming, setResult, t, toast, tripId]); return { checking, @@ -70,8 +79,8 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt /** A reimport can be offered: the trail was read and differs, and someone takes the new snapshot in. */ canReimport: !!result?.reimport && !!onReimported, confirming, - askReimport: useCallback(() => setConfirming(true), []), - cancelReimport: useCallback(() => setConfirming(false), []), + askReimport: useCallback(() => setConfirming(true), [setConfirming]), + cancelReimport: useCallback(() => setConfirming(false), [setConfirming]), reimporting, reimport, }; diff --git a/client/src/components/Tours/provenance/useTourProvenance.test.ts b/client/src/components/Tours/provenance/useTourProvenance.test.ts new file mode 100644 index 000000000..de91d3248 --- /dev/null +++ b/client/src/components/Tours/provenance/useTourProvenance.test.ts @@ -0,0 +1,48 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import type { TourListItem, TourProvenance } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTourProvenance } from './useTourProvenance'; + +const repo = vi.hoisted(() => ({ detail: vi.fn() })); +vi.mock('../../../repo/tourRepo', () => ({ tourRepo: repo })); + +const provenance: TourProvenance = { + origin: 'trek', + createdAt: null, + source: { kind: 'trek' }, + derivedFrom: null, + plannedOver: false, + editedLocally: null, +}; +const tour = { place_id: 7, name: 'Ridge', distance: 4 } as TourListItem; + +beforeEach(() => { + vi.clearAllMocks(); + repo.detail.mockResolvedValue({ tour, waypoints: [], provenance }); +}); + +describe('useTourProvenance (P2C-02)', () => { + it('reads the provenance with the detail, and asks again when the tour changes', async () => { + const { result, rerender } = renderHook(({ current }) => useTourProvenance(1, current), { + initialProps: { current: tour }, + }); + expect(result.current).toBeNull(); + await waitFor(() => expect(result.current).toEqual(provenance)); + expect(repo.detail).toHaveBeenCalledWith(1, 7, expect.any(AbortSignal)); + + rerender({ current: { ...tour, name: 'Renamed' } }); + await waitFor(() => expect(repo.detail).toHaveBeenCalledTimes(2)); + }); + + it('shows none when the detail cannot be had, or carries none', async () => { + repo.detail.mockRejectedValueOnce(new Error('offline')); + const failed = renderHook(() => useTourProvenance(1, tour)); + await waitFor(() => expect(repo.detail).toHaveBeenCalled()); + expect(failed.result.current).toBeNull(); + + repo.detail.mockResolvedValueOnce({ tour, waypoints: [] }); + const none = renderHook(() => useTourProvenance(1, { ...tour, place_id: 8 })); + await waitFor(() => expect(repo.detail).toHaveBeenCalledTimes(2)); + expect(none.result.current).toBeNull(); + }); +}); diff --git a/client/src/components/Tours/tourPresentation.test.ts b/client/src/components/Tours/tourPresentation.test.ts index 3d38a5976..2611d1367 100644 --- a/client/src/components/Tours/tourPresentation.test.ts +++ b/client/src/components/Tours/tourPresentation.test.ts @@ -29,6 +29,13 @@ describe('tourPresentation', () => { expect(hikeSourceBadgeLabel({ wanderer_ref: 'wanderer:123' }, t)).toBe('tours.badge.wanderer') }) + it('P2C-02: takes the origin the server recorded over the shape of the tour', () => { + // An import planned over in place has waypoints, and is still an import. + expect(tourSource({ wanderer_ref: null, has_waypoints: true, origin: 'gpx' })).toBe('gpx') + expect(tourSource({ wanderer_ref: null, has_waypoints: false, origin: 'trek' })).toBe('trek') + expect(tourSource({ wanderer_ref: 'w#1', origin: 'wanderer' })).toBe('wanderer') + }) + it('filters tours by All, Unplanned and Planned, keeping their order', () => { const tours = [ { id: 1, planned: true }, diff --git a/client/src/components/Tours/tourPresentation.ts b/client/src/components/Tours/tourPresentation.ts index 71d558aab..af12ca665 100644 --- a/client/src/components/Tours/tourPresentation.ts +++ b/client/src/components/Tours/tourPresentation.ts @@ -86,3 +86,13 @@ export function filterTours>(tours: T[], return true }) } + +/** How each origin is named in a list, the words the source badge uses. */ +export const ORIGIN_LABEL_KEY = { trek: 'tours.badge.planned', gpx: 'tours.badge.gpx', wanderer: 'tours.badge.wanderer' } as const + +/** The day status a tour list is filtered by, in words. */ +export function statusLabel(status: TourFilter, t: Translate): string { + if (status === 'unplanned') return t('places.unplanned') + if (status === 'planned') return t('places.planned') + return t('places.all') +} diff --git a/client/src/components/Tours/useTourDayAssignment.test.ts b/client/src/components/Tours/useTourDayAssignment.test.ts new file mode 100644 index 000000000..0b0860404 --- /dev/null +++ b/client/src/components/Tours/useTourDayAssignment.test.ts @@ -0,0 +1,68 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useTripStore } from '../../store/tripStore'; +import type { AssignmentsMap } from '../../types'; +import { isTourOnDay, useTourDayAssignment } from './useTourDayAssignment'; + +const assignments = { '3': [{ id: 31, place_id: 42 }], '4': [] } as unknown as AssignmentsMap; + +beforeEach(() => { + resetAllStores(); + seedStore(useTripStore, { assignments }); +}); + +describe('isTourOnDay (PR1-18)', () => { + it('reads the day lists: a tour is on a day once it has an assignment there', () => { + expect(isTourOnDay(assignments, 42, 3)).toBe(true); + expect(isTourOnDay(assignments, 42, 4)).toBe(false); + expect(isTourOnDay(assignments, 42, 99)).toBe(false); + }); +}); + +describe('useTourDayAssignment (PR1-18)', () => { + it('offers only a day that does not have the tour yet, and nothing without the right', () => { + const allowed = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay: vi.fn() })); + expect(allowed.result.current.isOnDay(42, 3)).toBe(true); + expect(allowed.result.current.isAvailable(42, 3)).toBe(false); + expect(allowed.result.current.isAvailable(42, 4)).toBe(true); + + const denied = renderHook(() => useTourDayAssignment({ canAssign: false, onAssignToDay: vi.fn() })); + expect(denied.result.current.isAvailable(42, 4)).toBe(false); + }); + + it('sends one write per tour and day while it is on its way, and says whether it happened', async () => { + let finish: (value: boolean) => void = () => undefined; + const onAssignToDay = vi.fn(() => new Promise((resolve) => (finish = resolve))); + const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay })); + + let first: Promise = Promise.resolve(false); + let second: Promise = Promise.resolve(false); + act(() => { + first = result.current.assign(42, 4); + second = result.current.assign(42, 4); + }); + expect(onAssignToDay).toHaveBeenCalledTimes(1); + expect(onAssignToDay).toHaveBeenCalledWith(42, 4, undefined); + expect(await second).toBe(false); + expect(result.current.isPending(42, 4)).toBe(true); + expect(result.current.isAvailable(42, 4)).toBe(false); + + await act(async () => { + finish(true); + expect(await first).toBe(true); + }); + expect(result.current.isPending(42, 4)).toBe(false); + }); + + it('reports a refused write as not done, and a day that has the tour is not even asked', async () => { + const onAssignToDay = vi.fn(async () => false); + const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay })); + await act(async () => { + expect(await result.current.assign(42, 4, 2)).toBe(false); + expect(await result.current.assign(42, 3)).toBe(false); + }); + expect(onAssignToDay).toHaveBeenCalledTimes(1); + expect(onAssignToDay).toHaveBeenCalledWith(42, 4, 2); + }); +}); diff --git a/client/src/components/Tours/useTourListFilters.test.ts b/client/src/components/Tours/useTourListFilters.test.ts new file mode 100644 index 000000000..14bc381e9 --- /dev/null +++ b/client/src/components/Tours/useTourListFilters.test.ts @@ -0,0 +1,76 @@ +import { act, renderHook } from '@testing-library/react'; +import type { TourListItem } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; +import { useTourListFilters } from './useTourListFilters'; + +const tour = (over: Partial): TourListItem => + ({ + place_id: 1, + name: 'Ridge', + tour_type: 'hike', + distance: 5, + elevation_gain: null, + elevation_loss: null, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: 2, + planned: false, + caution: false, + origin: 'trek', + ...over, + }) as TourListItem; + +const tours = [ + tour({ place_id: 1, origin: 'trek', max_hiking_difficulty: 2, planned: true }), + tour({ place_id: 2, origin: 'gpx', max_hiking_difficulty: null }), + tour({ place_id: 3, origin: 'wanderer', max_hiking_difficulty: 1 }), + tour({ place_id: 4, origin: 'gpx', max_hiking_difficulty: 3, planned: true }), +]; +const ids = (list: readonly TourListItem[]) => list.map((item) => item.place_id); + +describe('useTourListFilters (PR1-13)', () => { + it('offers only the dimensions the list varies in, each value with its count', () => { + const { result } = renderHook(() => useTourListFilters(tours)); + expect(result.current.facets.origin).toEqual([ + { value: 'trek', count: 1 }, + { value: 'gpx', count: 2 }, + { value: 'wanderer', count: 1 }, + ]); + expect(result.current.facets.grade.map((option) => option.value)).toEqual([1, 2, 3]); + // One tour type only: no type filter. + expect(result.current.facets.type).toEqual([]); + expect(result.current.hasFacets).toBe(true); + }); + + it('a small list that does not vary keeps its single status filter', () => { + const { result } = renderHook(() => useTourListFilters([tour({ place_id: 1 }), tour({ place_id: 2 })])); + expect(result.current.hasFacets).toBe(false); + }); + + it('narrows by status and dimensions together, counts follow, a second choice of a value clears it', () => { + const { result } = renderHook(() => useTourListFilters(tours)); + act(() => result.current.select('origin', 'gpx')); + expect(ids(result.current.filtered)).toEqual([2, 4]); + expect(result.current.activeFacets).toBe(1); + // A GPX import without a grade drops out under a grade, it is not counted as one. + act(() => result.current.select('grade', 3)); + expect(ids(result.current.filtered)).toEqual([4]); + act(() => result.current.setStatus('unplanned')); + expect(result.current.filtered).toEqual([]); + expect(result.current.emptyByFilter).toBe(true); + expect(result.current.statusCounts).toEqual({ all: 4, unplanned: 2, planned: 2 }); + + act(() => result.current.select('grade', 3)); + expect(ids(result.current.filtered)).toEqual([2]); + act(() => result.current.reset()); + expect(ids(result.current.filtered)).toEqual([1, 2, 3, 4]); + expect(result.current.activeFacets).toBe(0); + }); + + it('an empty list is empty, not empty by a filter', () => { + const { result } = renderHook(() => useTourListFilters([])); + expect(result.current.emptyByFilter).toBe(false); + }); +}); diff --git a/client/src/components/Tours/useTourMapVisibility.test.ts b/client/src/components/Tours/useTourMapVisibility.test.ts new file mode 100644 index 000000000..161d722e0 --- /dev/null +++ b/client/src/components/Tours/useTourMapVisibility.test.ts @@ -0,0 +1,62 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useAuthStore } from '../../store/authStore'; +import { tourVisibilityScope, useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'; +import { useTourMapVisibility } from './useTourMapVisibility'; + +const KEY = (userId: number, tripId: number) => `trek:hidden-tours:${tourVisibilityScope(userId, tripId)}`; + +beforeEach(() => { + resetAllStores(); + window.localStorage.clear(); + useTourMapVisibilityStore.setState({ scope: null, hidden: [] }); + seedStore(useAuthStore, { user: { id: 3 } as never }); +}); + +describe('useTourMapVisibility (PR1-07)', () => { + it('hides and shows a tour for this person in this trip, and keeps it over a reload', () => { + const tours = new Set([10, 11]); + const first = renderHook(() => useTourMapVisibility(7, tours)); + act(() => first.result.current.toggle(10)); + expect(first.result.current.isHidden(10)).toBe(true); + expect(first.result.current.hiddenCount).toBe(1); + expect(JSON.parse(window.localStorage.getItem(KEY(3, 7)) ?? '[]')).toEqual([10]); + + // A reload reads it back. + useTourMapVisibilityStore.setState({ scope: null, hidden: [] }); + const reloaded = renderHook(() => useTourMapVisibility(7, tours)); + expect(reloaded.result.current.isHidden(10)).toBe(true); + + act(() => reloaded.result.current.toggle(10)); + expect(reloaded.result.current.isHidden(10)).toBe(false); + expect(window.localStorage.getItem(KEY(3, 7))).toBeNull(); + }); + + it('belongs to one user and one trip only', () => { + window.localStorage.setItem(KEY(3, 7), JSON.stringify([10])); + expect(renderHook(() => useTourMapVisibility(8, new Set([10]))).result.current.hiddenCount).toBe(0); + useTourMapVisibilityStore.setState({ scope: null, hidden: [] }); + seedStore(useAuthStore, { user: { id: 4 } as never }); + expect(renderHook(() => useTourMapVisibility(7, new Set([10]))).result.current.hiddenCount).toBe(0); + }); + + it('a tour that is gone hides nothing, and show all clears the choice', () => { + window.localStorage.setItem(KEY(3, 7), JSON.stringify([10, 99, 'x', -1])); + const { result } = renderHook(() => useTourMapVisibility(7, new Set([10, 11]))); + expect([...result.current.hiddenIds]).toEqual([10]); + act(() => result.current.showAll()); + expect(result.current.hiddenCount).toBe(0); + expect(window.localStorage.getItem(KEY(3, 7))).toBeNull(); + }); + + it('a broken stored entry or no signed-in user shows every tour', () => { + window.localStorage.setItem(KEY(3, 7), '{not json'); + expect(renderHook(() => useTourMapVisibility(7, new Set([10]))).result.current.hiddenCount).toBe(0); + useTourMapVisibilityStore.setState({ scope: null, hidden: [] }); + seedStore(useAuthStore, { user: null }); + const { result } = renderHook(() => useTourMapVisibility(7, new Set([10]))); + act(() => result.current.toggle(10)); + expect(result.current.hiddenCount).toBe(0); + }); +}); diff --git a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx index b728dd438..6db2c0fdd 100644 --- a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx +++ b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx @@ -2,8 +2,7 @@ import { ArrowDown, ArrowUp, Mountain, AlertTriangle, CalendarDays } from 'lucid import type { TourListItem } from '@trek/shared' import { useSettingsStore } from '../../../../store/settingsStore' import { formatDistance, formatElevation } from '../../../../utils/units' -import { hikeSourceBadgeLabel } from '../../../../components/Tours/tourPresentation' -import { ORIGIN_LABEL_KEY } from '../../../../components/Tours/TourListFilterMenu' +import { hikeSourceBadgeLabel, ORIGIN_LABEL_KEY } from '../../../../components/Tours/tourPresentation' import type { TourListFilters } from '../../../../components/Tours/useTourListFilters' import type { TripPlanner, MTripShellApi } from '../MTripShell' import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions' diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index e2f01d9ab..904676b1f 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -31,7 +31,7 @@ import { getHelpContext } from '../help/registry' import { X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react' import ConfirmDialog from '../components/shared/ConfirmDialog' import { Tooltip } from '../components/shared/Tooltip' -import type { Place, Reservation } from '../types' +import type { Reservation } from '../types' import { useTripPlannerPage } from './tripPlanner/useTripPlannerPage' import { useBookingExpenseEditor } from './tripPlanner/useBookingExpenseEditor' import { applyTripCoverUpdate } from './tripPlanner/tripCover' @@ -127,7 +127,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { handleSaveReservation, handleSaveTransport, handleDeleteReservation, selectedPlace, dayOrderMap, dayPlaces, toursEnabled, toursMode, setToursMode, tours, toursLoading, tourDataReady, tourPlaceIds, - reloadTourPlaceIds, invalidateTourPlaceIds, selectedTour, + invalidateTourPlaceIds, selectedTour, mapTileUrl, fontStyle, splashDone, } = pageState.planner const { diff --git a/client/src/pages/admin/AdminUsersTab.tsx b/client/src/pages/admin/AdminUsersTab.tsx index b31b3c4f2..dd66c03b5 100644 --- a/client/src/pages/admin/AdminUsersTab.tsx +++ b/client/src/pages/admin/AdminUsersTab.tsx @@ -17,7 +17,8 @@ interface AdminUsersTabProps { locale: string } -const TH = 'px-3.5 py-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint' +const TH_BASE = 'px-3.5 py-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint' +const TH = `${TH_BASE} text-start` const TD = 'px-3.5 py-3 align-middle' /** An icon action of a row, named by its tooltip and its aria-label. */ @@ -74,7 +75,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): {t('admin.table.role')} {t('admin.table.created')} {t('admin.table.lastActive')} - {t('admin.table.actions')} + {t('admin.table.actions')} @@ -118,7 +119,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): {formatLastActive(u.last_active ?? u.last_login, locale, hour12)} -
+
handleEditUser(u)}> diff --git a/client/src/pages/tripPlanner/useTourDetailActions.ts b/client/src/pages/tripPlanner/useTourDetailActions.ts new file mode 100644 index 000000000..e2481cca0 --- /dev/null +++ b/client/src/pages/tripPlanner/useTourDetailActions.ts @@ -0,0 +1,41 @@ +import type { TourListItem } from '@trek/shared'; +import { tourRepo } from '../../repo/tourRepo'; +import { useTripStore } from '../../store/tripStore'; +import type { Place } from '../../types'; +import type { useTripPlanner } from './useTripPlanner'; + +/** + * What the tour's detail does beyond a place's: a rename reloads the tours, + * a reimport from its source check (MF-03) takes the new snapshot in and opens + * it, and "save as new tour" (P2C-07) makes the copy and opens it under a name + * that says it is one, so renaming it is the next step. + */ +export function useTourDetailActions(planner: ReturnType) { + const { invalidateTourPlaceIds, reloadTourPlaceIds, t, toast, tripActions, tripId, upsertTour } = planner; + + /** A tour that just came in: in the lists and on the map, then open. */ + const takeIn = async (tour: TourListItem) => { + upsertTour(tour); + await useTripStore.getState().refreshPlaces(tripId); + void invalidateTourPlaceIds({ placeIds: [tour.place_id] }); + planner.handlePlaceClick(tour.place_id); + }; + + return { + onUpdatePlace: async (placeId: number, data: Partial) => { + await tripActions.updatePlace(tripId, placeId, data); + await reloadTourPlaceIds(); + }, + onSourceReimported: takeIn, + onSaveAsNew: async (tour: TourListItem) => { + try { + const name = t('tours.copy.name', { name: tour.name }).slice(0, 255); + const { tour: copy } = await tourRepo.copy(tripId, tour.place_id, name); + await takeIn(copy); + toast.success(t('tours.copy.saved')); + } catch { + toast.error(t('tours.copy.failed')); + } + }, + }; +} diff --git a/client/src/pages/tripPlanner/useTourWorkspace.ts b/client/src/pages/tripPlanner/useTourWorkspace.ts index 80ea6ce9f..1c63a3132 100644 --- a/client/src/pages/tripPlanner/useTourWorkspace.ts +++ b/client/src/pages/tripPlanner/useTourWorkspace.ts @@ -5,10 +5,9 @@ import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'; import { useRecordedTrack } from '../../components/Tours/useRecordedTrack'; import { useTourGpxImport } from '../../components/Tours/useTourGpxImport'; import { useWandererBrowser } from '../../components/Tours/wanderer/useWandererBrowser'; -import { tourRepo } from '../../repo/tourRepo'; import { useAddonStore } from '../../store/addonStore'; import { useTripStore } from '../../store/tripStore'; -import type { Place } from '../../types'; +import { useTourDetailActions } from './useTourDetailActions'; import type { useTripPlanner } from './useTripPlanner'; /** @@ -118,37 +117,7 @@ export function useTourWorkspace( }, }); - // What the tour's detail does beyond a place's: a rename reloads the tours, a reimport from its - // source check takes the new snapshot in and opens it. - const tourDetailActions = { - onUpdatePlace: async (placeId: number, data: Partial) => { - await tripActions.updatePlace(tripId, placeId, data); - await reloadTourPlaceIds(); - }, - onSourceReimported: async (tour: TourListItem) => { - upsertTour(tour); - await useTripStore.getState().refreshPlaces(tripId); - void invalidateTourPlaceIds({ placeIds: [tour.place_id] }); - planner.handlePlaceClick(tour.place_id); - }, - // P2C-07: the copy opens at once, under a name that says it is one; renaming it is the next step. - onSaveAsNew: async (tour: TourListItem) => { - try { - const { tour: copy } = await tourRepo.copy( - tripId, - tour.place_id, - t('tours.copy.name', { name: tour.name }).slice(0, 255) - ); - upsertTour(copy); - await useTripStore.getState().refreshPlaces(tripId); - void invalidateTourPlaceIds({ placeIds: [copy.place_id] }); - planner.handlePlaceClick(copy.place_id); - toast.success(t('tours.copy.saved')); - } catch { - toast.error(t('tours.copy.failed')); - } - }, - }; + const tourDetailActions = useTourDetailActions(planner); return { tourPlanner, diff --git a/client/src/repo/tourRepo.test.ts b/client/src/repo/tourRepo.test.ts index eb6e2e65a..04fb891ba 100644 --- a/client/src/repo/tourRepo.test.ts +++ b/client/src/repo/tourRepo.test.ts @@ -243,3 +243,42 @@ describe('replaceTripTours', () => { expect(rows.find(r => r.place_id === 2)!.waypoints).toBeUndefined() }) }) + +describe('tourRepo provenance and copies (P2C-02, P2C-07)', () => { + const provenance = { + origin: 'gpx' as const, + createdAt: '2026-07-01 10:00:00', + source: { kind: 'gpx' as const, fileName: 'ridge.gpx' }, + derivedFrom: null, + plannedOver: false, + editedLocally: null, + } + + it('FE-REPO-TOUR-020: the detail caches where the tour came from, offline gives it back, and a list refresh keeps it', async () => { + server.use(http.get('/api/trips/3/tours/40', () => HttpResponse.json({ tour: tour(), waypoints: body.waypoints, provenance }))) + await tourRepo.detail(3, 40) + expect((await offlineDb.tours.get(40))?.provenance).toEqual(provenance) + + await replaceTripTours(3, [tour({ name: 'Renamed' })]) + expect((await offlineDb.tours.get(40))?.provenance).toEqual(provenance) + + setOnline(false) + const offline = await tourRepo.detail(3, 40) + expect(offline.provenance).toEqual(provenance) + // The list item handed out carries none of the cache's own fields. + expect(offline.tour).not.toHaveProperty('provenance') + }) + + it('FE-REPO-TOUR-021: a copy is asked of the server under its name and cached as a tour of the trip', async () => { + let sent: unknown = null + server.use(http.post('/api/trips/3/tours/40/copy', async ({ request }) => { + sent = await request.json() + return HttpResponse.json({ tour: tour({ place_id: 41, name: 'Ridge walk (copy)', derived_from_place_id: 40 }), waypoints: body.waypoints }, { status: 201 }) + })) + const result = await tourRepo.copy(3, 40, 'Ridge walk (copy)') + expect(sent).toEqual({ name: 'Ridge walk (copy)' }) + expect(result.tour.place_id).toBe(41) + expect(await offlineDb.tours.get(41)).toMatchObject({ trip_id: 3, derived_from_place_id: 40, waypoints: body.waypoints }) + }) +}) + diff --git a/client/src/styles/fadeEnd.css b/client/src/styles/fadeEnd.css new file mode 100644 index 000000000..25ec1c568 --- /dev/null +++ b/client/src/styles/fadeEnd.css @@ -0,0 +1,10 @@ +/* A row that runs out of room fades at its end instead of ending at a hard edge, + e.g. a tour's or a day plan row's chips when the hover actions take their space. */ +.trek-fade-end { + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.75rem), transparent); + mask-image: linear-gradient(to right, #000 calc(100% - 1.75rem), transparent); +} +[dir='rtl'] .trek-fade-end { + -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 1.75rem), transparent); + mask-image: linear-gradient(to left, #000 calc(100% - 1.75rem), transparent); +} diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index 38c84bf92..3184aad53 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -354,7 +354,8 @@ export class ToursService { this.toursRepo.findProvenance(tid, pid), this.placesRepo.findInTrip(pid, tid), ]); - if (!source || !provenance || !place) throw new NotFoundException('Tour not found'); + // A tour without a line has nothing to copy. + if (!source || !provenance || !place?.route_geometry) throw new NotFoundException('Tour not found'); const start = parseRouteGeometry(place.route_geometry)[0]; const id = await this.placesRepo.insertTourPlace({ trip_id: tid,