diff --git a/charts/trek/templates/NOTES.txt b/charts/trek/templates/NOTES.txt
index 0c11d0dc8..8c70022a4 100644
--- a/charts/trek/templates/NOTES.txt
+++ b/charts/trek/templates/NOTES.txt
@@ -2,8 +2,11 @@
- ENCRYPTION_KEY encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest.
- By default, the chart creates a Kubernetes Secret from `secretEnv.ENCRYPTION_KEY` in values.yaml.
- To generate a random key at install (preserved across upgrades), set `generateEncryptionKey: true`.
- - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must
- contain a key matching `existingSecretKey` (defaults to `ENCRYPTION_KEY`).
+ - To use an existing Kubernetes secret, set `existingSecret` to the secret name. The chart then
+ renders no Secret of its own and reads every secret env var from that secret: `ENCRYPTION_KEY`
+ (renamable via `existingSecretKey`), plus `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `OIDC_CLIENT_SECRET`,
+ `UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET` and
+ `VAPID_PRIVATE_KEY`. Missing keys are simply left unset, so only provide the ones you use.
- If left empty, the server resolves the key automatically: existing installs fall back to
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
auto-generate a key persisted to the data PVC.
diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml
index 1c32275c9..64f74c24d 100644
--- a/charts/trek/values.yaml
+++ b/charts/trek/values.yaml
@@ -171,7 +171,12 @@ secretEnv:
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
-# If set, use an existing Kubernetes secret that contains ENCRYPTION_KEY
+# If set, the chart renders no Secret of its own and instead reads every secret env
+# var from this existing Kubernetes secret. Expected keys: ENCRYPTION_KEY (rename via
+# existingSecretKey), ADMIN_EMAIL, ADMIN_PASSWORD, OIDC_CLIENT_SECRET,
+# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET,
+# VAPID_PRIVATE_KEY. Missing keys are simply left unset (they are optional), so only
+# provide the ones your setup needs.
existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
diff --git a/client/src/components/Map/MapTogglePill.tsx b/client/src/components/Map/MapTogglePill.tsx
index 8d6493480..4a05b653e 100644
--- a/client/src/components/Map/MapTogglePill.tsx
+++ b/client/src/components/Map/MapTogglePill.tsx
@@ -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 (
+ {badge > 0 && (
+
+ {badge}
+
+ )}
)
}
diff --git a/client/src/components/Planner/PlacesSidebarHeader.tsx b/client/src/components/Planner/PlacesSidebarHeader.tsx
index 1c3b3553d..926eb5be8 100644
--- a/client/src/components/Planner/PlacesSidebarHeader.tsx
+++ b/client/src/components/Planner/PlacesSidebarHeader.tsx
@@ -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 (
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) && (
toggleCategoryFilter('uncategorized')}
+ on={categoryFilters.has(UNCATEGORIZED)}
+ onClick={() => toggleCategoryFilter(UNCATEGORIZED)}
tile={}
label={t('places.noCategory')}
/>
diff --git a/client/src/components/Planner/usePlacesSidebar.test.tsx b/client/src/components/Planner/usePlacesSidebar.test.tsx
index 4e22159f4..68078f0a5 100644
--- a/client/src/components/Planner/usePlacesSidebar.test.tsx
+++ b/client/src/components/Planner/usePlacesSidebar.test.tsx
@@ -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();
- 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();
+
+ 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();
+
+ 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();
+ 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 }),
diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts
index 96d196385..4ca56c0fd 100644
--- a/client/src/components/Planner/usePlacesSidebar.ts
+++ b/client/src/components/Planner/usePlacesSidebar.ts
@@ -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('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(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(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>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState(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,
diff --git a/client/src/index.css b/client/src/index.css
index c5ffb6861..c4ee1e1a4 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -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; }
diff --git a/client/src/mobile/components/MChip.tsx b/client/src/mobile/components/MChip.tsx
index 414a61e91..5669ad7cd 100644
--- a/client/src/mobile/components/MChip.tsx
+++ b/client/src/mobile/components/MChip.tsx
@@ -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 (
)}
diff --git a/client/src/mobile/screens/trip/map/MPlacesFilterPill.tsx b/client/src/mobile/screens/trip/map/MPlacesFilterPill.tsx
new file mode 100644
index 000000000..4a57109dd
--- /dev/null
+++ b/client/src/mobile/screens/trip/map/MPlacesFilterPill.tsx
@@ -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 (
+ 0}
+ onToggle={onOpen}
+ label={label}
+ badge={count}
+ testId="places-filter-pill"
+ opensDialog
+ icon={}
+ />
+ )
+}
diff --git a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx
index d69e8f9d6..2b9a5b2f4 100644
--- a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx
+++ b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx
@@ -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 (
+ )
+}
diff --git a/client/src/mobile/screens/trip/places/MPlacesFilterSheet.tsx b/client/src/mobile/screens/trip/places/MPlacesFilterSheet.tsx
new file mode 100644
index 000000000..51a80744b
--- /dev/null
+++ b/client/src/mobile/screens/trip/places/MPlacesFilterSheet.tsx
@@ -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 (
+
{children}
+ )
+}
+
+/**
+ * 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).
+
+
+ {titleExtra}
)}
{detail && (
// Rendered, not raw: a note written with the formatting bar would
// otherwise read as `**asterisks**` on the phone.
- {detail}
+ {detail}
)}