Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9e475f4
feat(tours): add non-motorized day tour planning
elmocito Oct 4, 2026
562d8c4
merge: update Tours branch to current dev
elmocito Oct 4, 2026
0c2dbdf
test(tours): update planner wiring and stabilize date fixture
elmocito Oct 4, 2026
2ec5ae7
merge: update Tours branch with current Places filters
elmocito Oct 4, 2026
ccaed9f
test(tours): align sidebar fixture with planner-owned filter reset
elmocito Oct 5, 2026
c92478c
feat(tours): align the Tours UI with the planner redesign
mauriceboe Oct 5, 2026
040e6f2
fix(tours): center the empty tour list in the Tours tab like the plan…
mauriceboe Oct 5, 2026
a9f9a23
test(tours): align locale count after copy cleanup
elmocito Oct 5, 2026
af7dbce
fix(tours): prevent duplicate day assignments
elmocito Oct 5, 2026
04c7491
fix(tours): preserve map viewport during route updates
elmocito Oct 5, 2026
8739b4c
fix(tours): restrict permanent deletion to tours workspace
elmocito Oct 6, 2026
411a605
test(planner): stabilize day auto-scroll tests
elmocito Oct 6, 2026
99f3bbf
fix(tours): simplify post-save confirmation
elmocito Oct 6, 2026
a05855e
test(tours): align assignment coverage with row action
elmocito Oct 6, 2026
b1289c6
test(tours): fix assignment test validation
elmocito Oct 6, 2026
9b73ede
fix(tours): preserve viewport when starting a new tour
elmocito Oct 6, 2026
5a249e0
fix(tours): match the delete button to the add-to-day button and use …
mauriceboe Oct 6, 2026
46fcd45
fix(tours): space the tour row actions apart
mauriceboe Oct 6, 2026
c956fe9
Merge dev into feat/tours and port Tours onto MikroORM
mauriceboe Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions client/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Day, Place, Trip } from '../types'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest } from '@trek/shared'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
import { randomId } from '../utils/randomId'
import { postProviderPhotosInBatches } from './providerPhotoBatches'
import {
Expand Down Expand Up @@ -304,7 +304,7 @@
signal?: AbortSignal
}

export function postMultipart<T = any>(url: string, formData: FormData, opts?: UploadOptions): Promise<T> {

Check warning on line 307 in client/src/api/client.ts

View workflow job for this annotation

GitHub Actions / Client Types & Lint

Unexpected any. Specify a different type
return apiClient.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
Expand Down Expand Up @@ -465,7 +465,8 @@
apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
delete: (tripId: number | string, id: number | string): Promise<{ success: boolean; tourPlaceIds?: number[] }> =>
apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
uploadImage: (tripId: number | string, id: number | string, file: File) => {
const fd = new FormData()
Expand Down Expand Up @@ -513,6 +514,24 @@
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
}

export const toursApi = {
list: (tripId: number | string): Promise<{ tours: TourListItem[] }> =>
apiClient.get(`/trips/${tripId}/tours`).then(r => r.data),
detail: (tripId: number | string, placeId: number | string, signal?: AbortSignal): Promise<TourDetailResponse> =>
(signal
? apiClient.get(`/trips/${tripId}/tours/${placeId}`, { signal })
: apiClient.get(`/trips/${tripId}/tours/${placeId}`)).then(r => r.data),
create: (tripId: number | string, body: TourCreateRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data),
update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data),
importGpx: (tripId: number | string, file: File): Promise<TourImportGpxResponse> => {
const fd = new FormData()
fd.append('file', file)
return postMultipart(`/trips/${tripId}/tours/import/gpx`, fd)
},
}

export const assignmentsApi = {
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
Expand Down
3 changes: 3 additions & 0 deletions client/src/api/wsEventPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import type { TrekWsEventName } from '@trek/shared'
* the entry (the event then needs a new home or an IGNORED_WS_EVENTS slot).
*/
export const HANDLED_OUTSIDE_TRIP_STORE = [
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
'tours:changed',
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
'collab:note:updated',
Expand Down
20 changes: 18 additions & 2 deletions client/src/components/Map/MapLayerSwitcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { MapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'

describe('MapLayerSwitcher', () => {
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
Expand Down Expand Up @@ -42,7 +42,23 @@ describe('MapLayerSwitcher', () => {
expect(button.style.background).toBe('transparent')
})

it('FE-COMP-MAPLAYER-006: the shell is as wide as the size its neighbours are placed off', () => {
it('FE-COMP-MAPLAYER-006: offers all three Tours-only base layers', () => {
render(<TourMapLayerSwitcher active="default" onChange={() => {}} />)

expect(screen.getByRole('button', { name: 'Map' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Topo' })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: 'Satellite' })).toHaveAttribute('aria-pressed', 'false')
})

it('FE-COMP-MAPLAYER-007: reports a per-view Topo selection', () => {
const onChange = vi.fn()
render(<TourMapLayerSwitcher active="default" onChange={onChange} />)

fireEvent.click(screen.getByRole('button', { name: 'Topo' }))
expect(onChange).toHaveBeenCalledWith('topo')
})

it('FE-COMP-MAPLAYER-008: the shell is as wide as the size its neighbours are placed off', () => {
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
const button = screen.getByRole('button')
const shell = button.parentElement as HTMLElement
Expand Down
63 changes: 55 additions & 8 deletions client/src/components/Map/MapLayerSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { Map as MapIcon, Mountain, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'

export type BaseLayer = 'default' | 'satellite'
export type TourBaseLayer = BaseLayer | 'topo'

const FROSTED_SHELL = {
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto' as const,
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}

// Where the switcher sits and how much room it takes, so a control placed beside it
// (the phone's compass) is positioned off the same numbers instead of guessing them.
Expand All @@ -28,13 +37,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
const label = isSatellite ? t('map.baseLayer.switchToDefault') : t('map.baseLayer.switchToSatellite')

return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<div style={FROSTED_SHELL}>
{/* Same tooltip as its neighbours on the map, for the same reasons — see
TripRouteOverviewPill. To the right, though: this one sits at the
bottom left of the map, where a tooltip to the left would land on the
Expand All @@ -61,3 +64,47 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
</div>
)
}

const TOUR_LAYERS = [
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
{ id: 'topo', labelKey: null, Icon: Mountain },
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
] as const

/** Per-view Tours control. It never reads or writes the global map setting. */
export function TourMapLayerSwitcher({
active,
onChange,
}: {
active: TourBaseLayer
onChange: (layer: TourBaseLayer) => void
}) {
const { t } = useTranslation()

return (
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
{TOUR_LAYERS.map(({ id, labelKey, Icon }) => {
const selected = active === id
const label = labelKey ? t(labelKey) : 'Topo'
return (
<button
key={id}
type="button"
onClick={() => onChange(id)}
aria-pressed={selected}
className={selected ? 'bg-accent-soft text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
minHeight: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: selected ? undefined : 'transparent', padding: '0 10px',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
}}
>
<Icon size={15} strokeWidth={2} />
<span>{label}</span>
</button>
)
})}
</div>
)
}
54 changes: 54 additions & 0 deletions client/src/components/Map/MapView.leaflet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest
import { render, waitFor, act } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { AMAP_ROAD } from '../../constants/mapDefaults'
import { useSettingsStore } from '../../store/settingsStore'

vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: () => ({ position: null, mode: 'off', error: null, errorCode: null, cycleMode: vi.fn(), setMode: vi.fn() }),
Expand Down Expand Up @@ -112,6 +114,58 @@ async function renderMap() {
}

describe('MapView on a real Leaflet map', () => {
it('uses WGS-84 for Tours-local Topo over global Amap and preserves a Beijing click through layer switches', async () => {
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } })
const beijing = { ...buildPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 }), category_name: null, category_color: null, category_icon: null }
const onMapClick = vi.fn()
const route: [number, number][][] = [[[39.9042, 116.4074], [39.9052, 116.4084]]]
const waypoints = [{ id: 'beijing', lat: 39.9042, lng: 116.4074, role: 'start' as const }]
const renderLayer = (viewBaseLayer: 'default' | 'topo' | 'satellite') => (
<MapView
places={[beijing]}
center={[39.9042, 116.4074]}
zoom={15}
tileUrl={AMAP_ROAD}
viewBaseLayer={viewBaseLayer}
onViewBaseLayerChange={vi.fn()}
route={route}
plannerWaypoints={waypoints}
onMapClick={onMapClick}
/>
)
const view = render(renderLayer('default'))
await waitFor(() => expect(view.container.querySelector('.leaflet-container')).toBeInTheDocument())
let map = maps[maps.length - 1]
expect(map.options.crs).toBeTruthy()
expect(map.options.crs?.code).toBe('TREK:GCJ02')

const retainedCenter = L.latLng(39.91, 116.41)
act(() => map.setView(retainedCenter, 13))
view.rerender(renderLayer('topo'))
await waitFor(() => expect(maps[maps.length - 1]).not.toBe(map))
map = maps[maps.length - 1]
expect(map.options.crs).toBe(L.CRS.EPSG3857)
expect(map.getCenter().lat).toBeCloseTo(retainedCenter.lat, 5)
expect(map.getCenter().lng).toBeCloseTo(retainedCenter.lng, 5)

const expectedClick = L.latLng(39.9042, 116.4074)
const pixel = map.latLngToContainerPoint(expectedClick)
const roundTrip = map.containerPointToLatLng(pixel)
const pixelTolerance = map.distance(expectedClick, map.containerPointToLatLng(pixel.add([1, 1])))
map.fire('click', { latlng: roundTrip })
expect(onMapClick).toHaveBeenCalledOnce()
expect(map.distance(onMapClick.mock.calls[0][0].latlng, expectedClick)).toBeLessThanOrEqual(pixelTolerance)

view.rerender(renderLayer('satellite'))
await waitFor(() => expect(maps[maps.length - 1]).not.toBe(map))
map = maps[maps.length - 1]
expect(map.options.crs?.code).toBe('TREK:GCJ02')
expect(map.getCenter().lat).toBeCloseTo(retainedCenter.lat, 5)
expect(map.getCenter().lng).toBeCloseTo(retainedCenter.lng, 5)
expect(waypoints).toEqual([{ id: 'beijing', lat: 39.9042, lng: 116.4074, role: 'start' }])
expect(route).toEqual([[[39.9042, 116.4074], [39.9052, 116.4084]]])
})

it('FE-MAPCLICK-001: a pin opens its place on a map nobody has dragged yet', async () => {
const { pins, onMarkerClick, onMapClick } = await renderMap()
clickOn(pins[0])
Expand Down
Loading
Loading