merge: update Tours branch with current Places filters

This commit is contained in:
elmocito
2026-10-05 01:50:47 +02:00
63 changed files with 1008 additions and 183 deletions
+20 -2
View File
@@ -6,7 +6,7 @@ import { MAP_CONTROL_SHADOW } from './mapControlShadow'
* One round on/off control in the frosted shell every map control wears, so a new
* one lines up with the compass, the layer switcher and the overview toggle.
*/
export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left' }: {
export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left', badge = 0, opensDialog = false }: {
active: boolean
onToggle: () => void
label: string
@@ -14,9 +14,18 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
testId?: string
/** Away from the edge the control hugs: left on the right edge, right on the left one. */
tooltipPlacement?: 'left' | 'right'
/** A count pinned to the top right corner while above zero (how many filters are on, say). */
badge?: number
/**
* The control opens a dialog instead of switching something on and off (the phone
* map's places filter): it then announces the dialog, not a pressed state it
* does not have. `active` still lights it.
*/
opensDialog?: boolean
}) {
return (
<div style={{
position: 'relative',
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
@@ -30,7 +39,8 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
type="button"
onClick={onToggle}
aria-label={label}
aria-pressed={active}
aria-pressed={opensDialog ? undefined : active}
aria-haspopup={opensDialog ? 'dialog' : undefined}
data-testid={testId}
className={active ? 'text-accent' : 'text-content-muted'}
style={{
@@ -45,6 +55,14 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
{icon}
</button>
</Tooltip>
{badge > 0 && (
<span
aria-hidden="true"
className="pointer-events-none absolute -right-[3px] -top-[3px] box-border flex h-4 min-w-[16px] items-center justify-center rounded-full bg-accent px-1 font-geist text-[0.5625rem] font-bold tabular-nums text-accent-text"
>
{badge}
</span>
)}
</div>
)
}
@@ -9,6 +9,7 @@ import { tintOf } from './planParts'
import { CategoryTile } from './PlacesBulkCategoryModal'
import type { PlacesFilter, SidebarState } from './usePlacesSidebar'
import { PLACES_SORTS, type PlacesSort } from './placesSort'
import { RATING_FLOORS, UNCATEGORIZED } from '../../utils/placesFilter'
/**
* Below this the two labels stop fitting side by side and both buttons fall back
@@ -48,8 +49,6 @@ function BandButton({ label, onClick, on = false, lit = false, ariaPressed, aria
)
}
const RATING_FLOORS = ['all', 5, 4, 3, 2, 1] as const
export function PlacesDropOverlay({ t }: SidebarState) {
return (
<div
@@ -260,7 +259,7 @@ function CategoryFilter(S: SidebarState) {
let label = t('places.allCategories')
if (categoryFilters.size === 1) {
const only = categories.find(c => categoryFilters.has(String(c.id)))
if (categoryFilters.has('uncategorized')) label = t('places.noCategory')
if (categoryFilters.has(UNCATEGORIZED)) label = t('places.noCategory')
else if (only) label = only.name
} else if (categoryFilters.size > 1) {
label = `${categoryFilters.size} ${t('places.categoriesSelected')}`
@@ -279,8 +278,8 @@ function CategoryFilter(S: SidebarState) {
))}
{places.some(p => p.category_id == null) && (
<CategoryChoice
on={categoryFilters.has('uncategorized')}
onClick={() => toggleCategoryFilter('uncategorized')}
on={categoryFilters.has(UNCATEGORIZED)}
onClick={() => toggleCategoryFilter(UNCATEGORIZED)}
tile={<CategoryTile size={22} />}
label={t('places.noCategory')}
/>
@@ -1,4 +1,4 @@
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-059
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-060
import { http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
import { render, screen, fireEvent, act, waitFor } from '../../../tests/helpers/render';
@@ -188,13 +188,46 @@ describe('usePlacesSidebar filtering', () => {
expect(names()).toEqual(['GPX Track']);
});
it('FE-PLANNER-PSHOOK-008: a tracks filter without any track falls back to "all"', async () => {
seedStore(useTripStore, { placesFilter: 'tracks' });
render(<Host {...makeProps({ places: [buildPlace({ name: 'Plain Spot' })] })} />);
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'));
expect(names()).toEqual(['Plain Spot']);
it('Tours addon excludes Tour-backed Places from rows, counts, and localities', () => {
const legacyTrack = buildPlace({ id: 41, name: 'Legacy track', route_geometry: '[[1,2],[3,4]]', rating_avg: 4, address: 'Vienna, Austria' });
const ordinary = buildPlace({ id: 42, name: 'Ordinary place', address: 'Linz, Austria' });
const tour = buildPlace({ id: 43, name: 'Tour place', tour_place_id: 43, route_geometry: '[[5,6],[7,8]]', address: 'Denver, United States' });
render(<Host {...makeProps({
places: [legacyTrack, ordinary, tour],
toursEnabled: true,
excludePlaceIds: new Set([tour.id]),
})} />);
expect(S.hasTracks).toBe(false);
expect(names()).toEqual(expect.arrayContaining(['Legacy track', 'Ordinary place']));
expect(names()).not.toContain('Tour place');
expect(S.filterCounts).toEqual({ all: 2, unplanned: 2, planned: 0, tracks: 1 });
expect(S.localities).toEqual([{ country: 'Austria', count: 2, regions: [] }]);
act(() => { S.setRatingFilter(4); });
expect(names()).toEqual(['Legacy track']);
expect(S.filterCounts.all).toBe(2);
});
it('Tours addon OFF preserves legacy track Places and ignores the exclusion set', () => {
const legacyTrack = buildPlace({ id: 51, name: 'Legacy track', route_geometry: '[[1,2],[3,4]]' });
const dormantTour = buildPlace({ id: 52, name: 'Dormant Tour', tour_place_id: 52, route_geometry: '[[5,6],[7,8]]' });
render(<Host {...makeProps({
places: [legacyTrack, dormantTour],
toursEnabled: false,
excludePlaceIds: new Set([dormantTour.id]),
})} />);
expect(S.hasTracks).toBe(true);
expect(S.filterCounts).toEqual({ all: 2, unplanned: 2, planned: 0, tracks: 2 });
act(() => { S.setFilter('tracks'); });
expect(names()).toEqual(expect.arrayContaining(['Legacy track', 'Dormant Tour']));
});
// FE-PLANNER-PSHOOK-008 (a tracks filter without any track falls back to "all")
// moved to useTripPlanner (FE-TP-HOOK-177): the fallback now lives there, so it
// also holds while no places list is mounted.
it('FE-PLANNER-PSHOOK-009: a category filter keeps only that category', () => {
const tagged = buildPlace({ name: 'Tagged', category_id: 4 });
const other = buildPlace({ name: 'Other', category_id: 5 });
@@ -245,6 +278,17 @@ describe('usePlacesSidebar filtering', () => {
expect(names()).toEqual(['Unrated', 'Rated']);
});
it('FE-PLANNER-PSHOOK-060: the star floor lives in the trip store, so the map and the phone list follow it', () => {
const places = [buildPlace({ name: 'Good', rating_avg: 4 }), buildPlace({ name: 'Meh', rating_avg: 2 })];
render(<Host {...makeProps({ places })} />);
act(() => { S.setRatingFilter(3); });
expect(useTripStore.getState().placesRatingFilter).toBe(3);
// And back: a floor picked elsewhere (the phone's filter sheet) narrows this list too.
act(() => { useTripStore.getState().setPlacesRatingFilter('all'); });
expect(S.ratingFilter).toBe('all');
expect(names()).toEqual(['Good', 'Meh']);
});
it('FE-PLANNER-PSHOOK-014: search and category filter combine', () => {
const places = [
buildPlace({ name: 'Cafe Nord', category_id: 4 }),
@@ -18,6 +18,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { placeMatchesSearch } from '../../utils/placeSearch'
import { matchesCategoryFilter, matchesPlacesFilter } from '../../utils/placesFilter'
import { safeHttpUrl } from '../../utils/safeUrl'
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
import type { MenuEntry } from './planParts'
@@ -193,19 +194,20 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const categoryFilters = useTripStore((s) => s.placesCategoryFilter)
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
const [selectMode, setSelectMode] = useState(false)
// Star sort (#1435): list-only toggle, so it stays local (the map keeps its order).
// Minimum average stars, matching the collections filter (#1435): 'all', or a
// floor of 1..5 that unrated places fall through. It replaced a sort toggle,
// which put the best first but still left everything else on the list — no
// help at all when the point is to see only what the group actually rated.
const [ratingFilter, setRatingFilter] = useState<number | 'all'>('all')
// In the trip store with the other filters, so the map markers follow it too.
const ratingFilter = useTripStore((s) => s.placesRatingFilter)
const setRatingFilter = useTripStore((s) => s.setPlacesRatingFilter)
// The list's order (#2093), remembered on this device.
const [placesSort, setPlacesSortState] = useState<PlacesSort>(readPlacesSort)
const setPlacesSort = useCallback((sort: PlacesSort) => { setPlacesSortState(sort); writePlacesSort(sort) }, [])
// Country, or country and region, from each place's resolved position (#2537). List-only,
// like the rating floor.
const [localityFilter, setLocalityFilter] = useState<LocalityFilter | null>(null)
const localityOf = useMemo(() => new Map(places.map(p => [p.id, placeLocality(p, language)])), [places, language])
const localityOf = useMemo(() => new Map(poolPlaces.map(p => [p.id, placeLocality(p, language)])), [poolPlaces, language])
const localities = useMemo(() => localityGroups(poolPlaces.map(p => localityOf.get(p.id)!)), [poolPlaces, localityOf])
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
@@ -272,7 +274,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
() => !props.toursEnabled && poolPlaces.some(p => p.route_geometry),
[poolPlaces, props.toursEnabled],
)
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
const plannedIds = useMemo(
() => plannedPlaceIds({ assignments, accommodations, reservations }),
@@ -301,16 +302,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const filtered = useMemo(() => {
const list = poolPlaces.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
if (filter === 'tracks' && !p.route_geometry) return false
if (categoryFilters.size > 0) {
if (p.category_id == null) {
if (!categoryFilters.has('uncategorized')) return false
} else if (!categoryFilters.has(String(p.category_id))) return false
}
if (!matchesPlacesFilter(p, { filter, categoryFilters, ratingFilter }, { plannedIds, plannedFilterIds })) return false
if (!placeMatchesSearch(p, search)) return false
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
if (localityFilter && !matchesLocality(localityOf.get(p.id), localityFilter)) return false
return true
})
@@ -325,14 +318,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
* different questions.
*/
const filterCounts = useMemo(() => {
const base = poolPlaces.filter(p => {
if (categoryFilters.size > 0) {
if (p.category_id == null) {
if (!categoryFilters.has('uncategorized')) return false
} else if (!categoryFilters.has(String(p.category_id))) return false
}
return placeMatchesSearch(p, search)
})
const base = poolPlaces.filter(p => matchesCategoryFilter(p, categoryFilters) && placeMatchesSearch(p, search))
return {
all: base.length,
unplanned: base.filter(p => !plannedIds.has(p.id)).length,
+3 -1
View File
@@ -1535,16 +1535,18 @@ img[alt="TREK"] {
/* Markdown in Collab Notes */
.collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; }
.collab-note-md, .collab-note-md-full { overflow-wrap: anywhere; }
.collab-note-md em, .collab-note-md-full em { font-style: italic !important; }
.collab-note-md h1, .collab-note-md h2, .collab-note-md h3 { font-weight: 700 !important; margin: 0; }
.collab-note-md-full h1 { font-size: 1.3em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; }
.collab-note-md-full h2 { font-size: 1.15em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; }
.collab-note-md-full h3 { font-size: 1em !important; font-weight: 700 !important; margin: 0.8em 0 0.3em; }
.collab-note-md p, .collab-note-md-full p { margin: 0 0 0.3em; }
.collab-note-md p:last-child, .collab-note-md-full p:last-child { margin-bottom: 0; }
.collab-note-md ul, .collab-note-md-full ul { list-style-type: disc !important; padding-left: 1.4em !important; margin: 0.2em 0; }
.collab-note-md ol, .collab-note-md-full ol { list-style-type: decimal !important; padding-left: 1.4em !important; margin: 0.2em 0; }
.collab-note-md li, .collab-note-md-full li { display: list-item !important; margin: 0.1em 0; }
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); word-break: break-all; }
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
.collab-note-md-full pre code { padding: 0; background: none; }
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; word-break: normal; overflow-wrap: break-word; }
+8 -1
View File
@@ -10,16 +10,23 @@ interface MChipProps {
* back what it can: the chips that start a search are the ones worth the height.
*/
size?: 'sm' | 'tap'
/**
* The chip is one choice of a picker: announce `active` as `aria-pressed`, so a
* screen reader hears which one is on, not just its colour. Off by default, for
* chips that only run an action.
*/
pressable?: boolean
className?: string
children: ReactNode
}
/** Small pill chip: --m-act when active, neutral --m-ic surface otherwise. */
export default function MChip({ active = false, onClick, size = 'sm', className = '', children }: MChipProps) {
export default function MChip({ active = false, onClick, size = 'sm', pressable = false, className = '', children }: MChipProps) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={pressable ? active : undefined}
className={`inline-flex flex-none items-center gap-[6px] rounded-full font-semibold ${
size === 'tap' ? 'px-[13px] py-[10px] text-[0.8125rem]' : 'px-3 py-[7px] text-[0.75rem]'
} ${
@@ -54,7 +54,7 @@ export type MTripCollabTab = 'chat' | 'notes' | 'links' | 'polls'
* { dayId, note? }), 'accommodation' (payload { dayId?, accId?, from? }),
* 'transport' (payload { reservationId }), 'bract'
* (payload { placeId, dayPicker? }), 'import', 'export', 'members',
* 'tripedit', 'bags', 'task'. The place inspector keys off the planner's
* 'tripedit', 'bags', 'task', 'placesFilter' (the map's places filter). The place inspector keys off the planner's
* place selection instead of a sheet id, and planner-backed editors (place
* form, transport, booking, expense) keep using the planner's own modal flags.
*/
@@ -4,6 +4,7 @@ import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapC
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../../../components/Map/MapLayerSwitcher'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../components/Map/TripRouteOverview'
import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill'
import { MPlacesFilterPill } from './MPlacesFilterPill'
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
import { usePoiExplore } from '../../../../components/Map/usePoiExplore'
import { useMergedMapPois } from '../../../../components/Map/useMergedMapPois'
@@ -452,8 +453,11 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
clear of the round-controls band below it and of the base-layer switcher and
the compass, which both sit bottom left. The offset is Tailwind rather than
inline because the compass band is identified by being the one element with
an inline --bottom-nav-h, and a second would make that ambiguous. */}
{mapActive && !onStage && (!planner.roadtripActive || planner.dawarichEnabled) && (
an inline --bottom-nav-h, and a second would make that ambiguous. The places
filter closes the stack, under the toggles, so the overview card stays
right above its own toggle: it decides which pins the plan map shows, so it is
there whatever else is; the stage draws its own pins and has no use for it. */}
{mapActive && !onStage && (
<div className="pointer-events-none absolute left-3 right-3 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+58px)]">
{!planner.roadtripActive && planner.overviewActive && (
<TripRouteOverviewPanel
@@ -478,6 +482,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
onToggle={planner.toggleDawarichTrail}
/>
)}
<MPlacesFilterPill onOpen={() => shell.openSheet('placesFilter')} />
</div>
)}
</div>
@@ -0,0 +1,31 @@
import { SlidersHorizontal } from 'lucide-react'
import { MapTogglePill } from '../../../../components/Map/MapTogglePill'
import { useTripStore } from '../../../../store/tripStore'
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
import { useTranslation } from '../../../../i18n'
/**
* The phone map's way into the places filter: a round control in the right-hand
* stack, lit and badged with how many filters are narrowing the markers. The sheet
* it opens is the shell's 'placesFilter' sheet, so the button announces a dialog
* rather than a pressed state.
*/
export function MPlacesFilterPill({ onOpen }: { onOpen: () => void }) {
const { t } = useTranslation()
const filter = useTripStore(s => s.placesFilter)
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
const ratingFilter = useTripStore(s => s.placesRatingFilter)
const count = countActivePlacesFilters({ filter, categoryFilters, ratingFilter })
const label = count > 0 ? `${t('places.filters')} (${count})` : t('places.filters')
return (
<MapTogglePill
active={count > 0}
onToggle={onOpen}
label={label}
badge={count}
testId="places-filter-pill"
opensDialog
icon={<SlidersHorizontal size={17} strokeWidth={2} />}
/>
)
}
@@ -1,6 +1,6 @@
import { ReactNode, useEffect, useMemo, useState } from 'react'
import {
Bookmark, Check, CheckCheck, CheckCircle2, Download, ListChecks, Loader2, MapPin, Plus,
Bookmark, CheckCheck, CheckCircle2, Download, ListChecks, Loader2, Plus,
SlidersHorizontal, Tag, Trash2, X,
} from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
@@ -25,6 +25,8 @@ import MPlacesToursModeSwitch from './MPlacesToursModeSwitch'
import MToursSelectionList from './MToursSelectionList'
import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel'
import { useTourPlaceIds } from '../../../../hooks/useTourPlaceIds'
import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls'
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
/**
* Fullscreen places pool (mode === 'browse'): All/Unplanned/Tracks filter
@@ -55,7 +57,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
const filter = useTripStore(s => s.placesFilter)
const setFilter = useTripStore(s => s.setPlacesFilter)
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
const setCategoryFilters = useTripStore(s => s.setPlacesCategoryFilter)
const ratingFilter = useTripStore(s => s.placesRatingFilter)
const [search, setSearch] = useState('')
const [catOpen, setCatOpen] = useState(false)
@@ -73,9 +75,6 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
}, [shell.browseFromEdit, setFilter])
const hasTracks = useMemo(() => !toursEnabled && places.some(p => p.route_geometry), [places, toursEnabled])
useEffect(() => {
if (filter === 'tracks' && !hasTracks) setFilter('all')
}, [filter, hasTracks, setFilter])
// A hotel is linked through its stay and a venue through its booking; neither is
// ever dragged onto a day, and the pool used to call both unplanned (#2072).
@@ -84,10 +83,13 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
[assignments, planner.tripAccommodations, planner.reservations],
)
const dayNumberByPlace = useMemo(() => firstPlannedDayNumbers(assignments, days), [assignments, days])
const poolPlaces = useMemo(
() => toursEnabled ? places.filter(place => !tourPlaceIds.has(place.id)) : places,
[places, toursEnabled, tourPlaceIds],
)
const filtered = useMemo(() => {
const pool = toursEnabled ? places.filter(p => !tourPlaceIds.has(p.id)) : places
return filterPool(pool, { filter, categoryFilters, search, plannedIds })
}, [places, filter, categoryFilters, search, plannedIds, toursEnabled, tourPlaceIds])
return filterPool(poolPlaces, { filter, categoryFilters, ratingFilter, search, plannedIds })
}, [poolPlaces, filter, categoryFilters, ratingFilter, search, plannedIds])
// A bulk delete (or a remote edit) can remove selected places — drop the
// stale ids so the toolbar count stays honest.
@@ -138,13 +140,6 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
else setSelectedIds(new Set(selectablePlaces.map(p => p.id)))
}
const toggleCategory = (catId: string) => {
const next = new Set(categoryFilters)
if (next.has(catId)) next.delete(catId)
else next.add(catId)
setCategoryFilters(next)
}
const openAddPlace = () => {
planner.setEditingPlace(null)
planner.setEditingAssignmentId(null)
@@ -165,12 +160,11 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
}
const selectedTours = [...selectedIds].filter(planner.isTourPlace)
const hasUncategorized = places.some(p => p.category_id == null)
const panelFilterCount = countActivePlacesFilters({ filter: 'all', categoryFilters, ratingFilter })
return (
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
{toursEnabled && (
<MPlacesToursModeSwitch active={toursMode} onChange={setToursMode} />
)}
@@ -235,13 +229,13 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
type="button"
onClick={() => setCatOpen(v => !v)}
aria-expanded={catOpen}
aria-label={t('places.allCategories')}
aria-label={t('places.filters')}
className="relative flex w-[42px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted"
>
<SlidersHorizontal size={15} strokeWidth={2} />
{categoryFilters.size > 0 && (
{panelFilterCount > 0 && (
<span className="absolute -right-[3px] -top-[3px] box-border flex h-4 min-w-[16px] items-center justify-center rounded-full bg-m-act px-1 font-geist text-[0.5625rem] font-bold text-m-actfg">
{categoryFilters.size}
{panelFilterCount}
</span>
)}
</button>
@@ -324,31 +318,13 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
</div>
)}
{/* ── Category filter panel ── */}
{/* ── Rating + category filter panel ── */}
{catOpen && (
<div className="mt-[6px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)]">
{categories.map(c => {
const CatIcon = getCategoryIcon(c.icon)
return (
<CategoryFilterRow
key={c.id}
checked={categoryFilters.has(String(c.id))}
onToggle={() => toggleCategory(String(c.id))}
label={c.name}
>
<CatIcon size={14} strokeWidth={2} className="flex-none" style={{ color: c.color || 'var(--m-muted)' }} />
</CategoryFilterRow>
)
})}
{hasUncategorized && (
<CategoryFilterRow
checked={categoryFilters.has('uncategorized')}
onToggle={() => toggleCategory('uncategorized')}
label={t('places.noCategory')}
>
<MapPin size={14} strokeWidth={2} className="flex-none text-m-faint" />
</CategoryFilterRow>
)}
<div className="border-b border-[color:var(--m-rowbr)] px-[13px] py-[10px]">
<MRatingFloorChips onPick={() => setSelectedIds(new Set())} />
</div>
<MCategoryFilterList categories={categories} places={poolPlaces} />
</div>
)}
@@ -363,7 +339,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
{/* ── Place list ── */}
{filtered.length === 0 ? (
filter === 'unplanned' && !search && categoryFilters.size === 0 ? (
filter === 'unplanned' && !search && categoryFilters.size === 0 && ratingFilter === 'all' ? (
<div className="flex min-h-[60vh] flex-col items-center justify-center px-8 py-10 text-center">
<MDancingTrek scene="idle" mood="happy" className="mb-2" />
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('places.allPlanned')}</p>
@@ -510,37 +486,3 @@ function BulkBtn({ label, onClick, disabled = false, children }: {
</button>
)
}
/** 17px (panel) / 19px (row) square checkbox in the demo's act-fill style. */
function SquareCheck({ checked, big = false }: { checked: boolean; big?: boolean }) {
return (
<span
className={`flex flex-none items-center justify-center border-[1.5px] ${
big ? 'h-[19px] w-[19px] rounded-[6px]' : 'h-[17px] w-[17px] rounded-[5px]'
} ${checked ? 'border-[color:var(--m-act)] bg-m-act text-m-actfg' : 'border-[color:var(--m-trackoff)] text-transparent'}`}
>
<Check size={big ? 12 : 11} strokeWidth={3} />
</span>
)
}
function CategoryFilterRow({ checked, onToggle, label, children }: {
checked: boolean
onToggle: () => void
label: string
children: ReactNode
}) {
return (
<button
type="button"
onClick={onToggle}
role="checkbox"
aria-checked={checked}
className="flex w-full items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left last:border-b-0"
>
<SquareCheck checked={checked} />
{children}
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-medium text-m-ink">{label}</span>
</button>
)
}
@@ -0,0 +1,112 @@
import { ReactNode } from 'react'
import { Check, MapPin, Star } from 'lucide-react'
import MChip from '../../../components/MChip'
import { useTripStore } from '../../../../store/tripStore'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import { RATING_FLOORS, UNCATEGORIZED } from '../../../../utils/placesFilter'
import { useTranslation } from '../../../../i18n'
import type { Category, Place } from '../../../../types'
/**
* The phone's places filter controls, shared by the places browser's filter panel
* and the map's filter sheet. Both read and write the trip store, so whichever one
* the user touches, the list and the map markers follow (#1541).
*/
/** 17px (panel) / 19px (row) square checkbox in the demo's act-fill style. */
export function SquareCheck({ checked, big = false }: { checked: boolean; big?: boolean }) {
return (
<span
className={`flex flex-none items-center justify-center border-[1.5px] ${
big ? 'h-[19px] w-[19px] rounded-[6px]' : 'h-[17px] w-[17px] rounded-[5px]'
} ${checked ? 'border-[color:var(--m-act)] bg-m-act text-m-actfg' : 'border-[color:var(--m-trackoff)] text-transparent'}`}
>
<Check size={big ? 12 : 11} strokeWidth={3} />
</span>
)
}
function CategoryFilterRow({ checked, onToggle, label, children }: {
checked: boolean
onToggle: () => void
label: string
children: ReactNode
}) {
return (
<button
type="button"
onClick={onToggle}
role="checkbox"
aria-checked={checked}
className="flex min-h-[44px] w-full items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left last:border-b-0"
>
<SquareCheck checked={checked} />
{children}
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-medium text-m-ink">{label}</span>
</button>
)
}
/** The trip's categories as a multi-select, plus "no category" while a place has none. */
export function MCategoryFilterList({ categories, places }: { categories: Category[]; places: Place[] }) {
const { t } = useTranslation()
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
const setCategoryFilters = useTripStore(s => s.setPlacesCategoryFilter)
const toggle = (catId: string) => {
const next = new Set(categoryFilters)
if (next.has(catId)) next.delete(catId)
else next.add(catId)
setCategoryFilters(next)
}
return (
<>
{categories.map(c => {
const CatIcon = getCategoryIcon(c.icon)
return (
<CategoryFilterRow
key={c.id}
checked={categoryFilters.has(String(c.id))}
onToggle={() => toggle(String(c.id))}
label={c.name}
>
<CatIcon size={14} strokeWidth={2} className="flex-none" style={{ color: c.color || 'var(--m-muted)' }} />
</CategoryFilterRow>
)
})}
{places.some(p => p.category_id == null) && (
<CategoryFilterRow
checked={categoryFilters.has(UNCATEGORIZED)}
onToggle={() => toggle(UNCATEGORIZED)}
label={t('places.noCategory')}
>
<MapPin size={14} strokeWidth={2} className="flex-none text-m-faint" />
</CategoryFilterRow>
)}
</>
)
}
/**
* A minimum of stars, the same floors as the desktop list and the collections bar
* (#1435). `onPick` runs after a floor is chosen: the places browser drops its
* selection then, as it does for a new pool.
*/
export function MRatingFloorChips({ onPick }: { onPick?: () => void } = {}) {
const { t } = useTranslation()
const ratingFilter = useTripStore(s => s.placesRatingFilter)
const setRatingFilter = useTripStore(s => s.setPlacesRatingFilter)
return (
<div role="group" aria-label={t('places.filterByRating')} className="flex flex-wrap gap-[6px]">
{RATING_FLOORS.map(floor => (
<MChip key={String(floor)} size="tap" pressable active={ratingFilter === floor} onClick={() => { setRatingFilter(floor); onPick?.() }}>
{floor === 'all' ? t('common.all') : (
<>
<Star size={12} strokeWidth={2.2} fill="currentColor" className="flex-none" />
{`${floor}+`}
</>
)}
</MChip>
))}
</div>
)
}
@@ -0,0 +1,100 @@
import { RotateCcw, X } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import MIconBtn from '../../../components/MIconBtn'
import MChip from '../../../components/MChip'
import { useTripStore } from '../../../../store/tripStore'
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
import { useTranslation } from '../../../../i18n'
import type { Category, Place } from '../../../../types'
import { MCategoryFilterList, MRatingFloorChips } from './MPlacesFilterControls'
interface MPlacesFilterSheetProps {
open: boolean
onClose: () => void
places: Place[]
categories: Category[]
toursEnabled: boolean
}
/** Uppercase eyebrow over each group of the sheet. */
function SectionTitle({ children }: { children: string }) {
return (
<div className="mb-2 font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{children}</div>
)
}
/**
* The places filter, opened from the phone map: which pool to show, a minimum
* rating and the trip's categories, with one button that lifts them all. Every
* choice lands in the trip store at once, so the markers behind the sheet and the
* places list change with it (#1541). Routed as shell sheet 'placesFilter'.
*/
export default function MPlacesFilterSheet({ open, onClose, places, categories, toursEnabled }: MPlacesFilterSheetProps) {
const { t } = useTranslation()
const filter = useTripStore(s => s.placesFilter)
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
const ratingFilter = useTripStore(s => s.placesRatingFilter)
const setFilter = useTripStore(s => s.setPlacesFilter)
const resetPlacesFilters = useTripStore(s => s.resetPlacesFilters)
const activeCount = countActivePlacesFilters({ filter, categoryFilters, ratingFilter })
const pools = [
{ id: 'all', label: t('places.all') },
{ id: 'unplanned', label: t('places.unplanned') },
{ id: 'planned', label: t('places.planned') },
]
if (!toursEnabled && places.some(p => p.route_geometry)) pools.push({ id: 'tracks', label: t('places.filterTracks') })
const hasCategoryChoice = categories.length > 0 || places.some(p => p.category_id == null)
return (
// Opaque: the sheet opens over the map, and the default bar glass let the map's
// place names show through the chips and category rows (seen at 390px wide).
<MSheet open={open} onClose={onClose} variant="bottom" material="opaque" ariaLabel={t('places.filters')}>
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[11px] pt-4">
<div className="min-w-0 flex-1 text-[1.03125rem] font-bold text-m-ink">{t('places.filters')}</div>
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.close')}>
<X size={15} strokeWidth={2.2} />
</MIconBtn>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-[14px]">
<section>
<SectionTitle>{t('places.filterShow')}</SectionTitle>
<div role="group" aria-label={t('places.filterShow')} className="flex flex-wrap gap-[6px]">
{pools.map(pool => (
<MChip key={pool.id} size="tap" pressable active={filter === pool.id} onClick={() => setFilter(pool.id)}>
{pool.label}
</MChip>
))}
</div>
</section>
<section className="mt-[18px]">
<SectionTitle>{t('places.filterByRating')}</SectionTitle>
<MRatingFloorChips />
</section>
{hasCategoryChoice && (
<section className="mt-[18px]">
<SectionTitle>{t('categories.title')}</SectionTitle>
<div className="overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
<MCategoryFilterList categories={categories} places={places} />
</div>
</section>
)}
</div>
<div className="flex flex-none border-t border-[color:var(--m-rowbr)] px-[18px] py-3">
<button
type="button"
onClick={resetPlacesFilters}
disabled={activeCount === 0}
className="flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-[0.8125rem] font-semibold text-m-ink disabled:opacity-40"
>
<RotateCcw size={14} strokeWidth={2.2} />
{t('common.reset')}
</button>
</div>
</MSheet>
)
}
@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest'
import {
filterPool, firstPlannedDayNumbers, matchesCategoryFilter, matchesSearch,
filterPool, firstPlannedDayNumbers, matchesSearch,
plannedPlaceIds, poolCounts,
} from './placesBrowserModel'
import { matchesCategoryFilter } from '../../../../utils/placesFilter'
import type { AssignmentsMap, Day, Place } from '../../../../types'
const mkPlace = (id: number, over: Partial<Place> = {}): Place =>
@@ -144,6 +145,18 @@ describe('placesBrowserModel — tracks pool', () => {
expect(out.map(p => p.id)).toEqual([1])
})
it('FE-MOB-PMODEL-015: the rating floor from the store narrows the pool like it narrows the map', () => {
const rated = [
mkPlace(1, { name: 'Top', rating_avg: 4.6 }),
mkPlace(2, { name: 'Fine', rating_avg: 3.2 }),
mkPlace(3, { name: 'Unrated' }),
]
const out = filterPool(rated, { filter: 'all', categoryFilters: new Set(), ratingFilter: 3, search: '', plannedIds })
expect(out.map(p => p.id)).toEqual([1, 2])
const all = filterPool(rated, { filter: 'all', categoryFilters: new Set(), search: '', plannedIds })
expect(all.map(p => p.id)).toEqual([1, 2, 3])
})
it('FE-MOB-PMODEL-013: poolCounts counts tracks on the category+search base set', () => {
const c = poolCounts(places, new Set(['4']), '', plannedIds)
expect(c.all).toBe(2)
@@ -1,5 +1,8 @@
import type { AssignmentsMap, Day, Place } from '../../../../types'
import { placeMatchesSearch } from '../../../../utils/placeSearch'
import {
matchesCategoryFilter, matchesPlacesFilter, type PlacesRatingFloor,
} from '../../../../utils/placesFilter'
import {
plannedPlaceIds as sharedPlannedPlaceIds,
type PlannedAccommodation, type PlannedReservation,
@@ -8,8 +11,10 @@ import {
/**
* Pure filter model of the mobile places browser. Mirrors the desktop
* sidebar's semantics exactly (usePlacesSidebar + the map's mapPlaces memo):
* the pool filter and category set come from the trip store, so the list here
* and the map markers always agree (#1541).
* the pool filter, the category set and the rating floor come from the trip
* store and go through the same matcher (utils/placesFilter) as the map markers
* (#1541); the map's own exceptions (open day, collapsed days) are described
* there.
*/
export function plannedPlaceIds(
@@ -38,12 +43,6 @@ export function firstPlannedDayNumbers(assignments: AssignmentsMap, days: Day[])
return result
}
export function matchesCategoryFilter(place: Place, categoryFilters: Set<string>): boolean {
if (categoryFilters.size === 0) return true
if (place.category_id == null) return categoryFilters.has('uncategorized')
return categoryFilters.has(String(place.category_id))
}
export function matchesSearch(place: Place, search: string): boolean {
return placeMatchesSearch(place, search)
}
@@ -51,17 +50,14 @@ export function matchesSearch(place: Place, search: string): boolean {
interface PoolFilterArgs {
filter: string
categoryFilters: Set<string>
/** Minimum average stars; omitted means every rating. */
ratingFilter?: PlacesRatingFloor
search: string
plannedIds: Set<number>
}
export function filterPool(places: Place[], { filter, categoryFilters, search, plannedIds }: PoolFilterArgs): Place[] {
return places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (filter === 'planned' && !plannedIds.has(p.id)) return false
if (filter === 'tracks' && !p.route_geometry) return false
return matchesCategoryFilter(p, categoryFilters) && matchesSearch(p, search)
})
export function filterPool(places: Place[], { filter, categoryFilters, ratingFilter = 'all', search, plannedIds }: PoolFilterArgs): Place[] {
return places.filter(p => matchesPlacesFilter(p, { filter, categoryFilters, ratingFilter }, { plannedIds }) && matchesSearch(p, search))
}
/** Chip counts run on the category+search base set, like the desktop tabs. */
@@ -160,7 +160,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
<div
ref={cardRef}
data-touch-drag={editing ? '' : undefined}
className="absolute left-4 right-4 overflow-y-auto overscroll-contain rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-2 pt-1 backdrop-blur-[24px] backdrop-saturate-[1.6] bottom-[calc(env(safe-area-inset-bottom,0px)+90px)]"
className="absolute left-4 right-4 overflow-x-hidden overflow-y-auto overscroll-contain rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-2 pt-1 backdrop-blur-[24px] backdrop-saturate-[1.6] bottom-[calc(env(safe-area-inset-bottom,0px)+90px)]"
style={{ top: `calc(var(--m-safe-top, 12px) + ${editing ? 140 : tl.upNext ? 216 : 102}px)` }}
>
{planner.toursEnabled && tl.rows.some(row => row.kind === 'place' && row.invalidTour) && (
@@ -551,7 +551,7 @@ export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
const { time: noteTime, detail } = splitNoteTime(note.time)
const time = noteTime ? fmtTime(noteTime, chrome) : ''
const [title, ...rest] = note.text.split('\n')
const titleExtra = rest.join(' ').trim()
const titleExtra = rest.join('\n').trim()
return (
<div
@@ -561,10 +561,10 @@ export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
// A link in the rendered body keeps its own tap; the rest of the row edits.
onClick={chrome.editing ? (e => { if (!(e.target as HTMLElement).closest('a')) onEdit() }) : undefined}
onKeyDown={chrome.editing ? (e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onEdit() } }) : undefined}
className={`my-[2px] flex items-center gap-2.5 ${chrome.editing ? 'cursor-pointer' : ''} ${dragClass(drag)}`}
className={`my-[2px] flex items-start gap-2.5 ${chrome.editing ? 'cursor-pointer' : ''} ${dragClass(drag)}`}
>
{!chrome.editing && (
<AvatarRing style={note.color ? { background: skin.iconBackground, borderColor: skin.border } : undefined}>
<AvatarRing className="mt-[2px]" style={note.color ? { background: skin.iconBackground, borderColor: skin.border } : undefined}>
<Icon size={14} strokeWidth={2} style={{ color: skin.iconColor }} />
</AvatarRing>
)}
@@ -572,20 +572,20 @@ export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
className="min-w-0 flex-1 rounded-[13px] border px-[11px] py-[7px]"
style={{ borderColor: skin.border, background: note.color ? skin.background : 'var(--m-ic)' }}
>
<div className="flex items-center gap-1.5">
<div className="flex min-w-0 items-start gap-1.5">
{time && <span className={TIME_CHIP}>{time}</span>}
<span className="min-w-0 text-[0.875rem] font-semibold">{title}</span>
<span className="min-w-0 text-[0.875rem] [overflow-wrap:anywhere] font-semibold">{title}</span>
</div>
{titleExtra && (
<div className="mt-px font-geist text-[0.71875rem] leading-[1.4] text-m-muted">{titleExtra}</div>
<MarkdownText className="mt-px font-geist text-[0.71875rem] leading-[1.4] text-m-muted">{titleExtra}</MarkdownText>
)}
{detail && (
// Rendered, not raw: a note written with the formatting bar would
// otherwise read as `**asterisks**` on the phone.
<MarkdownText className="mt-px font-geist text-[0.71875rem] leading-[1.45] text-m-muted [overflow-wrap:anywhere]">{detail}</MarkdownText>
<MarkdownText className="mt-px font-geist text-[0.71875rem] leading-[1.45] text-m-muted">{detail}</MarkdownText>
)}
</div>
{chrome.editing && <span className="flex flex-none items-center gap-1.5">{reorder}</span>}
{chrome.editing && <span className="mt-[2px] flex flex-none items-center gap-1.5">{reorder}</span>}
</div>
)
}
@@ -180,7 +180,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
{searchBar}
<div
ref={cardRef}
className={`absolute inset-0 overflow-y-auto overscroll-contain px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] ${
className={`absolute inset-0 overflow-x-hidden overflow-y-auto overscroll-contain px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] ${
searchBar ? 'pt-[calc(var(--m-safe-top,12px)+150px)]' : 'pt-[calc(var(--m-safe-top,12px)+102px)]'
}`}
>
@@ -483,7 +483,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
{day.title || t('planner.dayN', { n: (day.day_number ?? planner.days.indexOf(day) + 1) || '?' })}
</div>
)}
<div className="whitespace-pre-wrap font-geist text-[0.75rem] leading-[1.5] text-m-muted">{assignment.notes}</div>
<div className="whitespace-pre-wrap [overflow-wrap:anywhere] font-geist text-[0.75rem] leading-[1.5] text-m-muted">{assignment.notes}</div>
</div>
))}
</div>
@@ -27,6 +27,7 @@ import MNoteSheet, { type MNoteSheetPayload } from './MNoteSheet'
import MImportSheet from './MImportSheet'
import MExportSheet from './MExportSheet'
import MMehrSheet from './MMehrSheet'
import MPlacesFilterSheet from '../places/MPlacesFilterSheet'
import MRtStopSheet from '../roadtrip/MRtStopSheet'
import MRtStaySheet from '../roadtrip/MRtStaySheet'
import MRtKindSheet from '../roadtrip/MRtKindSheet'
@@ -150,6 +151,13 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
onClose={shell.closeSheet}
/>
<MImportSheet planner={planner} open={sheet?.id === 'import'} onClose={shell.closeSheet} />
<MPlacesFilterSheet
open={sheet?.id === 'placesFilter'}
onClose={shell.closeSheet}
places={planner.places}
categories={planner.categories}
toursEnabled={planner.toursEnabled}
/>
{/* ── Planner-flag editors (also serve ?create= and the import review) ── */}
<MPlaceEditSheet planner={planner} onOpenExpense={openBookingExpense} />
@@ -360,7 +360,7 @@ function NoteCardRow({ note, color, canEdit, onTap, onTogglePin, onDelete, t }:
<button type="button" onClick={onTap} className="block w-full px-3 pb-3 pt-[9px] text-left">
{note.content && (
<div className="line-clamp-3 font-geist text-[0.75rem] leading-[1.5] text-m-muted [&_a]:underline [&_ol]:list-decimal [&_ol]:pl-4 [&_p]:mb-1 [&_strong]:font-bold [&_ul]:list-disc [&_ul]:pl-4">
<div className="line-clamp-3 [overflow-wrap:anywhere] font-geist text-[0.75rem] leading-[1.5] text-m-muted [&_a]:underline [&_ol]:list-decimal [&_ol]:pl-4 [&_p]:mb-1 [&_strong]:font-bold [&_ul]:list-disc [&_ul]:pl-4">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{note.content}</Markdown>
</div>
)}
@@ -640,7 +640,7 @@ function NoteViewSheet({ open, note, onClose, t }: {
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-4 pt-1">
{snapshot?.content && (
<div className="font-geist text-[0.8125rem] leading-[1.6] text-m-ink [&_a]:underline [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-2 [&_strong]:font-bold [&_ul]:list-disc [&_ul]:pl-5">
<div className="font-geist text-[0.8125rem] leading-[1.6] text-m-ink [overflow-wrap:anywhere] [&_a]:underline [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-2 [&_strong]:font-bold [&_ul]:list-disc [&_ul]:pl-5">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{snapshot.content}</Markdown>
</div>
)}
@@ -17,7 +17,7 @@ import { usePluginStore } from '../../../../store/pluginStore'
export function TabScroller({ children }: { children: ReactNode }) {
return (
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
{children}
</div>
</div>
@@ -1,4 +1,4 @@
// FE-TP-HOOK-001 to FE-TP-HOOK-175
// FE-TP-HOOK-001 to FE-TP-HOOK-177
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -646,6 +646,47 @@ describe('useTripPlanner — map derivations', () => {
expect(result.current.mapPlaces.map(p => p.id)).toEqual([2])
})
it('FE-TP-HOOK-177: losing the last track moves the "Tracks" filter back to all', async () => {
// The phone map has no places list mounted, so this hook is what keeps the
// filter from pointing at a pool no control offers any more.
seedTrip({
places: [geo(1), geo(2, { route_geometry: '[[1,2]]' })],
placesFilter: 'tracks',
})
const { result } = await renderPlanner()
expect(useTripStore.getState().placesFilter).toBe('tracks')
act(() => { useTripStore.setState({ places: [geo(1)] }) })
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'))
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
})
it('Tours enabled clears a stale Tracks filter even while track geometry remains', async () => {
const tour: TourListItem = {
place_id: 7, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
}
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
vi.mocked(addonsApi.enabled).mockResolvedValue({ addons: [{ id: 'tours' }] })
vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [tour] })
seedTrip({
places: [
buildPlace({ id: 7, lat: 1, lng: 2, tour_place_id: 7, route_geometry: '[[1,2],[3,4]]' }),
buildPlace({ id: 8, lat: 3, lng: 4, route_geometry: '[[5,6],[7,8]]' }),
],
placesFilter: 'tracks',
})
const { result } = await renderPlanner()
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'))
expect(result.current.enabledAddons.tours).toBe(true)
expect(result.current.mapPlaces.map(place => place.id)).toEqual([7, 8])
})
it('FE-TP-HOOK-027: the category filter honours the uncategorized bucket', async () => {
seedTrip({
places: [geo(1, { category_id: 3 }), geo(2, { category_id: null })],
@@ -660,6 +701,20 @@ describe('useTripPlanner — map derivations', () => {
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
})
it('FE-TP-HOOK-176: the rating floor the lists set also thins the markers', async () => {
seedTrip({
places: [geo(1, { rating_avg: 4.5 }), geo(2, { rating_avg: 3 }), geo(3, { rating_avg: null })],
placesRatingFilter: 4,
})
const { result } = await renderPlanner()
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
act(() => { useTripStore.getState().setPlacesRatingFilter('all') })
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1, 2, 3])
})
it('FE-TP-HOOK-028: the unplanned filter drops places that sit on a day', async () => {
const planned = geo(1)
seedTrip({
+17 -10
View File
@@ -83,6 +83,7 @@ import {
} from '../../utils/connectionsVisibility'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces'
import { matchesPlacesFilter } from '../../utils/placesFilter'
import { pendingStayPlaceIds } from '../../utils/pendingStays'
import { useDayDelete } from './useDayDelete'
import { useDayAdd } from './useDayAdd'
@@ -733,6 +734,7 @@ export function useTripPlanner() {
// switches can't desync the marker set from the filter UI (#1541).
const placesFilter = useTripStore((s) => s.placesFilter)
const placesCategoryFilter = useTripStore((s) => s.placesCategoryFilter)
const placesRatingFilter = useTripStore((s) => s.placesRatingFilter)
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
@@ -771,28 +773,33 @@ export function useTripPlanner() {
const compactIds = compactUnplanned ? plannedPlaceIds({ assignments, accommodations: tripAccommodations, reservations }) : null
const pendingIds = pendingStayPlaceIds(tripAccommodations, reservations)
const filterState = { filter: placesFilter, categoryFilters: placesCategoryFilter, ratingFilter: placesRatingFilter }
return places.filter(p => {
if (!p.lat || !p.lng) return false
if (placesFilter === 'tracks' && !p.route_geometry) return false
if (placesCategoryFilter.size > 0) {
if (p.category_id == null) {
if (!placesCategoryFilter.has('uncategorized')) return false
} else if (!placesCategoryFilter.has(String(p.category_id))) return false
}
// Collapsed-day declutter hides a day's stops on every filter EXCEPT 'planned':
// there the user asked to see the whole plan on the map, so a collapsed day
// must not drop its planned places.
if (placesFilter !== 'planned' && hiddenPlaceIds.has(p.id)) return false
if (placesFilter === 'unplanned' && plannedIds && plannedIds.has(p.id)) return false
if (placesFilter === 'planned' && plannedIds && !plannedIds.has(p.id)) return false
return true
// Pool, categories and rating floor: the very matcher the lists use (#1541).
return matchesPlacesFilter(p, filterState, { plannedIds })
}).map(p => {
// How the map tells a place apart (#2024, #2281); untouched places keep their identity.
const compact = !!compactIds && !compactIds.has(p.id)
const pending = pendingIds.has(p.id)
return compact || pending ? { ...p, _compact: compact, _pending: pending } : p
})
}, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
}, [places, placesCategoryFilter, placesFilter, placesRatingFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
// The "Tracks" pool is only offered while a place carries a track. When the last
// one goes (deleted here or by a collaborator), fall back to "all" — here, because
// this hook is mounted whatever is on screen: the lists that used to do it are not
// mounted while the phone map is in front, and the map would sit empty under a
// "Tracks" filter no control offers any more.
const setPlacesFilter = useTripStore((s) => s.setPlacesFilter)
const hasTracks = useMemo(() => !toursEnabled && places.some(p => p.route_geometry), [places, toursEnabled])
useEffect(() => {
if (placesFilter === 'tracks' && !hasTracks) setPlacesFilter('all')
}, [placesFilter, hasTracks, setPlacesFilter])
const { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
// Road trip mode already draws the whole trip its own way, so the overview stands
+11
View File
@@ -36,6 +36,7 @@ import type { TodoSlice } from './slices/todoSlice'
import type { BudgetSlice } from './slices/budgetSlice'
import type { ReservationsSlice } from './slices/reservationsSlice'
import type { FilesSlice } from './slices/filesSlice'
import type { PlacesRatingFloor } from '../utils/placesFilter'
export interface TripStoreState
extends PlacesSlice,
@@ -65,12 +66,18 @@ export interface TripStoreState
// unmounts and remounts (#1541).
placesFilter: string
placesCategoryFilter: Set<string>
// Minimum average stars. Shared for the same reason: the map markers and the
// phone's list follow the floor the desktop list sets, and back (#1435).
placesRatingFilter: PlacesRatingFloor
isLoading: boolean
error: string | null
setSelectedDay: (dayId: number | null) => void
setPlacesFilter: (filter: string) => void
setPlacesCategoryFilter: (categoryIds: Set<string>) => void
setPlacesRatingFilter: (floor: PlacesRatingFloor) => void
/** Lifts the pool, category and rating filters at once. */
resetPlacesFilters: () => void
handleRemoteEvent: (event: WebSocketEvent) => void
resetTrip: () => void
loadTrip: (tripId: number | string) => Promise<void>
@@ -97,12 +104,15 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
selectedDayId: null,
placesFilter: 'all',
placesCategoryFilter: new Set<string>(),
placesRatingFilter: 'all',
isLoading: false,
error: null,
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
setPlacesFilter: (filter: string) => set({ placesFilter: filter }),
setPlacesCategoryFilter: (categoryIds: Set<string>) => set({ placesCategoryFilter: categoryIds }),
setPlacesRatingFilter: (floor: PlacesRatingFloor) => set({ placesRatingFilter: floor }),
resetPlacesFilters: () => set({ placesFilter: 'all', placesCategoryFilter: new Set<string>(), placesRatingFilter: 'all' }),
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
@@ -123,6 +133,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
selectedDayId: null,
placesFilter: 'all',
placesCategoryFilter: new Set<string>(),
placesRatingFilter: 'all',
error: null,
}),
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest'
import { buildPlace } from '../../tests/helpers/factories'
import {
RATING_FLOORS, countActivePlacesFilters, matchesCategoryFilter, matchesPlacesFilter,
matchesPoolFilter, matchesRatingFloor,
} from './placesFilter'
const NONE = new Set<string>()
describe('placesFilter', () => {
it('FE-UTIL-PLFILTER-001: offers the same floors as the collections bar', () => {
expect(RATING_FLOORS).toEqual(['all', 5, 4, 3, 2, 1])
})
it('FE-UTIL-PLFILTER-002: an empty category set keeps everything; "uncategorized" stands for no category', () => {
const tagged = buildPlace({ category_id: 4 })
const bare = buildPlace({ category_id: null })
expect(matchesCategoryFilter(tagged, NONE)).toBe(true)
expect(matchesCategoryFilter(tagged, new Set(['4']))).toBe(true)
expect(matchesCategoryFilter(tagged, new Set(['5']))).toBe(false)
expect(matchesCategoryFilter(bare, new Set(['4']))).toBe(false)
expect(matchesCategoryFilter(bare, new Set(['uncategorized']))).toBe(true)
})
it('FE-UTIL-PLFILTER-003: the rating floor keeps places at or above it and drops the unrated', () => {
expect(matchesRatingFloor(buildPlace({ rating_avg: null }), 'all')).toBe(true)
expect(matchesRatingFloor(buildPlace({ rating_avg: null }), 1)).toBe(false)
expect(matchesRatingFloor(buildPlace({ rating_avg: 4 }), 4)).toBe(true)
expect(matchesRatingFloor(buildPlace({ rating_avg: 3.9 }), 4)).toBe(false)
})
it('FE-UTIL-PLFILTER-004: the pool filter reads the planned sets, and a null set filters nothing', () => {
const planned = buildPlace({ id: 1 })
const loose = buildPlace({ id: 2, route_geometry: '[[1,2],[3,4]]' })
const ids = new Set([1])
expect(matchesPoolFilter(planned, 'all', { plannedIds: ids })).toBe(true)
expect(matchesPoolFilter(planned, 'unplanned', { plannedIds: ids })).toBe(false)
expect(matchesPoolFilter(loose, 'unplanned', { plannedIds: ids })).toBe(true)
expect(matchesPoolFilter(planned, 'planned', { plannedIds: ids })).toBe(true)
expect(matchesPoolFilter(loose, 'planned', { plannedIds: ids })).toBe(false)
expect(matchesPoolFilter(planned, 'tracks', { plannedIds: ids })).toBe(false)
expect(matchesPoolFilter(loose, 'tracks', { plannedIds: ids })).toBe(true)
expect(matchesPoolFilter(planned, 'unplanned', { plannedIds: null })).toBe(true)
expect(matchesPoolFilter(loose, 'planned', { plannedIds: null })).toBe(true)
})
it('FE-UTIL-PLFILTER-005: "planned" can be narrowed to the open day while "unplanned" stays trip-wide', () => {
const elsewhere = buildPlace({ id: 1 })
const sets = { plannedIds: new Set([1, 2]), plannedFilterIds: new Set([2]) }
expect(matchesPoolFilter(elsewhere, 'planned', sets)).toBe(false)
expect(matchesPoolFilter(elsewhere, 'unplanned', sets)).toBe(false)
})
it('FE-UTIL-PLFILTER-006: the combined matcher needs pool, category and rating to agree', () => {
const place = buildPlace({ id: 1, category_id: 4, rating_avg: 4.5 })
const sets = { plannedIds: new Set<number>() }
const state = { filter: 'unplanned', categoryFilters: new Set(['4']), ratingFilter: 4 as const }
expect(matchesPlacesFilter(place, state, sets)).toBe(true)
expect(matchesPlacesFilter(place, { ...state, ratingFilter: 5 }, sets)).toBe(false)
expect(matchesPlacesFilter(place, { ...state, categoryFilters: new Set(['9']) }, sets)).toBe(false)
expect(matchesPlacesFilter(place, state, { plannedIds: new Set([1]) })).toBe(false)
})
it('FE-UTIL-PLFILTER-007: counts each filter that narrows, the category set once', () => {
expect(countActivePlacesFilters({ filter: 'all', categoryFilters: NONE, ratingFilter: 'all' })).toBe(0)
expect(countActivePlacesFilters({ filter: 'planned', categoryFilters: NONE, ratingFilter: 'all' })).toBe(1)
expect(countActivePlacesFilters({ filter: 'all', categoryFilters: new Set(['1', '2']), ratingFilter: 'all' })).toBe(1)
expect(countActivePlacesFilters({ filter: 'tracks', categoryFilters: new Set(['1']), ratingFilter: 3 })).toBe(3)
})
})
+71
View File
@@ -0,0 +1,71 @@
import type { Place } from '../types'
/**
* The trip's places filter, in one place: the pool (all / unplanned / planned /
* tracks), the category set and the minimum rating. The desktop list, the phone
* list and the map markers of both shells apply this same predicate to the same
* values, held in the trip store (#1541). The map keeps two deliberate
* differences of its own: with a day open, "planned" shows that day's plan only
* (#2024), and the stops of collapsed days are left off the map (except under
* "planned").
*/
/** A minimum of average stars: 'all', or a floor that unrated places fall through. */
export type PlacesRatingFloor = number | 'all'
/** The floors offered by every rating picker, the same as the collections bar (#1435). */
export const RATING_FLOORS = ['all', 5, 4, 3, 2, 1] as const
/** The pseudo category id that stands for "no category". */
export const UNCATEGORIZED = 'uncategorized'
export function matchesCategoryFilter(place: Place, categoryFilters: ReadonlySet<string>): boolean {
if (categoryFilters.size === 0) return true
if (place.category_id == null) return categoryFilters.has(UNCATEGORIZED)
return categoryFilters.has(String(place.category_id))
}
export function matchesRatingFloor(place: Place, floor: PlacesRatingFloor): boolean {
if (floor === 'all') return true
return place.rating_avg != null && place.rating_avg >= floor
}
export interface PoolFilterSets {
/** Every place planned anywhere in the trip: what 'unplanned' leaves out. */
plannedIds: ReadonlySet<number> | null
/**
* What 'planned' keeps. Defaults to `plannedIds`; the planner narrows it to the
* open day (#2024) while 'unplanned' stays trip-wide.
*/
plannedFilterIds?: ReadonlySet<number> | null
}
/** A null set means "not needed for this filter" and filters nothing out. */
export function matchesPoolFilter(place: Place, filter: string, { plannedIds, plannedFilterIds = plannedIds }: PoolFilterSets): boolean {
if (filter === 'unplanned') return !plannedIds?.has(place.id)
if (filter === 'planned') return !plannedFilterIds || plannedFilterIds.has(place.id)
if (filter === 'tracks') return !!place.route_geometry
return true
}
export interface PlacesFilterState {
filter: string
categoryFilters: ReadonlySet<string>
ratingFilter: PlacesRatingFloor
}
/** Pool, categories and rating together — the whole filter the store holds. */
export function matchesPlacesFilter(place: Place, state: PlacesFilterState, sets: PoolFilterSets): boolean {
return matchesPoolFilter(place, state.filter, sets)
&& matchesCategoryFilter(place, state.categoryFilters)
&& matchesRatingFloor(place, state.ratingFilter)
}
/**
* How many of the three filters are narrowing the places (0 to 3): the pool, the
* categories (however many are ticked) and the rating floor. Drives the badge on
* the phone's filter buttons.
*/
export function countActivePlacesFilters({ filter, categoryFilters, ratingFilter }: PlacesFilterState): number {
return (filter !== 'all' ? 1 : 0) + (categoryFilters.size > 0 ? 1 : 0) + (ratingFilter !== 'all' ? 1 : 0)
}