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 && ( + + )}
) } 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 (
-
+
{toursEnabled && ( )} @@ -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" > - {categoryFilters.size > 0 && ( + {panelFilterCount > 0 && ( - {categoryFilters.size} + {panelFilterCount} )} @@ -324,31 +318,13 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
)} - {/* ── Category filter panel ── */} + {/* ── Rating + category filter panel ── */} {catOpen && (
- {categories.map(c => { - const CatIcon = getCategoryIcon(c.icon) - return ( - toggleCategory(String(c.id))} - label={c.name} - > - - - ) - })} - {hasUncategorized && ( - toggleCategory('uncategorized')} - label={t('places.noCategory')} - > - - - )} +
+ setSelectedIds(new Set())} /> +
+
)} @@ -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' ? (

{t('places.allPlanned')}

@@ -510,37 +486,3 @@ function BulkBtn({ label, onClick, disabled = false, children }: { ) } - -/** 17px (panel) / 19px (row) square checkbox in the demo's act-fill style. */ -function SquareCheck({ checked, big = false }: { checked: boolean; big?: boolean }) { - return ( - - - - ) -} - -function CategoryFilterRow({ checked, onToggle, label, children }: { - checked: boolean - onToggle: () => void - label: string - children: ReactNode -}) { - return ( - - ) -} diff --git a/client/src/mobile/screens/trip/places/MPlacesFilterControls.tsx b/client/src/mobile/screens/trip/places/MPlacesFilterControls.tsx new file mode 100644 index 000000000..320e4dc78 --- /dev/null +++ b/client/src/mobile/screens/trip/places/MPlacesFilterControls.tsx @@ -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 ( + + + + ) +} + +function CategoryFilterRow({ checked, onToggle, label, children }: { + checked: boolean + onToggle: () => void + label: string + children: ReactNode +}) { + return ( + + ) +} + +/** 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 ( + toggle(String(c.id))} + label={c.name} + > + + + ) + })} + {places.some(p => p.category_id == null) && ( + toggle(UNCATEGORIZED)} + label={t('places.noCategory')} + > + + + )} + + ) +} + +/** + * 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 ( +
+ {RATING_FLOORS.map(floor => ( + { setRatingFilter(floor); onPick?.() }}> + {floor === 'all' ? t('common.all') : ( + <> + + {`${floor}+`} + + )} + + ))} +
+ ) +} 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). + +
+
{t('places.filters')}
+ + + +
+ +
+
+ {t('places.filterShow')} +
+ {pools.map(pool => ( + setFilter(pool.id)}> + {pool.label} + + ))} +
+
+ +
+ {t('places.filterByRating')} + +
+ + {hasCategoryChoice && ( +
+ {t('categories.title')} +
+ +
+
+ )} +
+ +
+ +
+
+ ) +} diff --git a/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts b/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts index d26d9a9b6..f6607f302 100644 --- a/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts +++ b/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts @@ -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 => @@ -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) diff --git a/client/src/mobile/screens/trip/places/placesBrowserModel.ts b/client/src/mobile/screens/trip/places/placesBrowserModel.ts index dffb19097..000e334b9 100644 --- a/client/src/mobile/screens/trip/places/placesBrowserModel.ts +++ b/client/src/mobile/screens/trip/places/placesBrowserModel.ts @@ -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): 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 + /** Minimum average stars; omitted means every rating. */ + ratingFilter?: PlacesRatingFloor search: string plannedIds: Set } -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. */ diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx index 0fc365be6..f4731ee19 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx @@ -160,7 +160,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
{planner.toursEnabled && tl.rows.some(row => row.kind === 'place' && row.invalidTour) && ( diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx index 0c4c88061..140a02390 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx @@ -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 (
{ 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 && ( - + )} @@ -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)' }} > -
+
{time && {time}} - {title} + {title}
{titleExtra && ( -
{titleExtra}
+ {titleExtra} )} {detail && ( // Rendered, not raw: a note written with the formatting bar would // otherwise read as `**asterisks**` on the phone. - {detail} + {detail} )}
- {chrome.editing && {reorder}} + {chrome.editing && {reorder}}
) } diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx index d21192bc8..10c0322f6 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx @@ -180,7 +180,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) { {searchBar}
diff --git a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx index bfd90ddc9..bcc7b9375 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx @@ -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) || '?' })}
)} -
{assignment.notes}
+
{assignment.notes}
))}
diff --git a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx index 329edaba2..5b9fcb8e2 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx @@ -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} /> + {/* ── Planner-flag editors (also serve ?create= and the import review) ── */} diff --git a/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx index 1a9d4c911..cb7d77fdb 100644 --- a/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx +++ b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx @@ -360,7 +360,7 @@ function NoteCardRow({ note, color, canEdit, onTap, onTogglePin, onDelete, t }: