From 4f2f937e17db6fafe6d452dc76ccb51cb0da1c4d Mon Sep 17 00:00:00 2001 From: Oscar Carlsson Date: Fri, 2 Oct 2026 08:15:25 +0200 Subject: [PATCH 01/16] Clarify how the existingSecret really works --- charts/trek/templates/NOTES.txt | 7 +++++-- charts/trek/values.yaml | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/charts/trek/templates/NOTES.txt b/charts/trek/templates/NOTES.txt index 0c11d0dc8..f4cbe3144 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`, `AMAP_API_KEY` and `AMAP_API_SECRET`. 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..80b1152dd 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -171,7 +171,11 @@ 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, AMAP_API_KEY, AMAP_API_SECRET. Missing keys are simply left +# unset (they are optional), so only provide the ones your setup needs. existingSecret: "" existingSecretKey: ENCRYPTION_KEY From a856c68f874614e70706f3bc6654b069cc0d80cb Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 4 Oct 2026 13:36:34 +0200 Subject: [PATCH 02/16] docs(helm): list PLACES_API_KEY and VAPID_PRIVATE_KEY among existingSecret keys --- charts/trek/templates/NOTES.txt | 4 ++-- charts/trek/values.yaml | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/charts/trek/templates/NOTES.txt b/charts/trek/templates/NOTES.txt index f4cbe3144..8c70022a4 100644 --- a/charts/trek/templates/NOTES.txt +++ b/charts/trek/templates/NOTES.txt @@ -5,8 +5,8 @@ - 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`, `AMAP_API_KEY` and `AMAP_API_SECRET`. Missing keys are simply left unset, - so only provide the ones you use. + `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 80b1152dd..64f74c24d 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -174,8 +174,9 @@ generateEncryptionKey: false # 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, AMAP_API_KEY, AMAP_API_SECRET. Missing keys are simply left -# unset (they are optional), so only provide the ones your setup needs. +# 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 From a9c78f680ef0b29ef13941bee768e6dc200c6b89 Mon Sep 17 00:00:00 2001 From: Diogo Mendes Date: Fri, 2 Oct 2026 10:18:21 -0300 Subject: [PATCH 03/16] fix(mobile): prevent layout breaking from long unbroken text in notes --- client/src/components/shared/MarkdownText.tsx | 2 +- client/src/index.css | 4 +++- .../screens/trip/places/MPlacesBrowser.tsx | 2 +- .../mobile/screens/trip/plan/MPlanTimeline.tsx | 2 +- .../screens/trip/plan/MPlanTimelineRows.tsx | 16 ++++++++-------- .../screens/trip/roadtrip/MRoadtripTab.tsx | 2 +- .../mobile/screens/trip/sheets/MPlaceSheet.tsx | 2 +- .../mobile/screens/trip/tabs/MCollabNotes.tsx | 4 ++-- .../src/mobile/screens/trip/tabs/tabChrome.tsx | 2 +- 9 files changed, 19 insertions(+), 17 deletions(-) diff --git a/client/src/components/shared/MarkdownText.tsx b/client/src/components/shared/MarkdownText.tsx index f65be4069..3e622100a 100644 --- a/client/src/components/shared/MarkdownText.tsx +++ b/client/src/components/shared/MarkdownText.tsx @@ -28,7 +28,7 @@ export default function MarkdownText({ children, className = '', clamp = false } }) { return (
-
+
{/* ── Filter chips + Import (Add sits in the search row below to free space) ── */}
{day && ( diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx index 0c4c88061..a3e2bad24 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 b8d29f0dd..8b9d183bc 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx @@ -474,7 +474,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/tabs/MCollabNotes.tsx b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx index 1a9d4c911..2d75f5c48 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 }: @@ -290,31 +286,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')} - > - - - )} +
+ +
+
)} @@ -329,7 +307,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')}

@@ -470,37 +448,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..bb5317d03 --- /dev/null +++ b/client/src/mobile/screens/trip/places/MPlacesFilterControls.tsx @@ -0,0 +1,108 @@ +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). */ +export function MRatingFloorChips() { + const { t } = useTranslation() + const ratingFilter = useTripStore(s => s.placesRatingFilter) + const setRatingFilter = useTripStore(s => s.setPlacesRatingFilter) + return ( +
+ {RATING_FLOORS.map(floor => ( + setRatingFilter(floor)}> + {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..ce558a35f --- /dev/null +++ b/client/src/mobile/screens/trip/places/MPlacesFilterSheet.tsx @@ -0,0 +1,97 @@ +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[] +} + +/** 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 }: 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 (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 ( + +
+
{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/sheets/MTripSheets.tsx b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx index 1ce2053e3..3b3aca377 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx @@ -25,6 +25,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' @@ -91,6 +92,12 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) { onClose={shell.closeSheet} /> + {/* ── Planner-flag editors (also serve ?create= and the import review) ── */} diff --git a/client/tests/unit/mobile/trip/MMapArea.test.tsx b/client/tests/unit/mobile/trip/MMapArea.test.tsx index 3912e62c0..ef683de0a 100644 --- a/client/tests/unit/mobile/trip/MMapArea.test.tsx +++ b/client/tests/unit/mobile/trip/MMapArea.test.tsx @@ -17,7 +17,7 @@ import { openLeg } from '../../../helpers/legAlternatives' import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives' import { corePoiCategories, pluginPoiCategories } from '../../../../src/components/Map/usePoiCategories' -// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-045 +// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-048 // // The stage's pins come out of the trip store rather than the planner's map list, so the // stage fixtures seed the store and leave `mapPlaces` to stand for what the plan tab shows. @@ -183,7 +183,7 @@ beforeEach(() => { moved: false, toggle: vi.fn(), searchArea: vi.fn(), onViewportChange: vi.fn(), } useSettingsStore.setState(s => ({ settings: { ...s.settings, map_poi_pill_enabled: true } })) - seedStore(useTripStore, { places: [], placesFilter: 'all', placesCategoryFilter: new Set() }) + seedStore(useTripStore, { places: [], placesFilter: 'all', placesCategoryFilter: new Set(), placesRatingFilter: 'all' }) }) describe('MMapArea', () => { @@ -952,3 +952,30 @@ describe('MMapArea and a booked night at the edge of the stage', () => { expect(mocks.poi.toggle).toHaveBeenCalledWith('plugin:trail-finder/trailheads') }) }) + +describe('MMapArea — places filter', () => { + it('FE-MOB-MAPAREA-046: the plan map offers the places filter, and a tap opens its sheet', () => { + const { shell } = renderArea({ trTab: 'plan' }) + const pill = screen.getByTestId('places-filter-pill') + expect(pill).toHaveAccessibleName('Filters') + expect(pill).toHaveAttribute('aria-pressed', 'false') + act(() => { pill.click() }) + expect(shell.openSheet).toHaveBeenCalledWith('placesFilter') + }) + + it('FE-MOB-MAPAREA-047: the pill lights up and counts the filters narrowing the pins', () => { + seedStore(useTripStore, { placesFilter: 'unplanned', placesRatingFilter: 4 }) + renderArea({ trTab: 'plan' }) + const pill = screen.getByTestId('places-filter-pill') + expect(pill).toHaveAttribute('aria-pressed', 'true') + expect(pill).toHaveAccessibleName('Filters (2)') + expect(screen.getByText('2')).toHaveAttribute('aria-hidden', 'true') + }) + + it('FE-MOB-MAPAREA-048: the stage draws its own pins, so it has no places filter, and neither has a covered map', () => { + renderArea({ trTab: 'roadtrip', mapFront: true }) + expect(screen.queryByTestId('places-filter-pill')).not.toBeInTheDocument() + renderArea({ view: 'plan', mapFront: false }) + expect(screen.queryByTestId('places-filter-pill')).not.toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx index 49a192689..a2e352d6a 100644 --- a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx +++ b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' +import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import MPlacesBrowser from '../../../../src/mobile/screens/trip/places/MPlacesBrowser' import { collectionsApi } from '../../../../src/api/collections' @@ -10,7 +10,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { AssignmentsMap, Category, Day, Place } from '../../../../src/types' -// FE-MOB-PBROW-001 to FE-MOB-PBROW-031 +// FE-MOB-PBROW-001 to FE-MOB-PBROW-034 const CATEGORIES = [ { id: 1, name: 'Sights', color: '#123456', icon: 'landmark' }, @@ -168,10 +168,37 @@ describe('MPlacesBrowser', () => { expect(useTripStore.getState().placesCategoryFilter.size).toBe(0) }) + it('FE-MOB-PBROW-032: the panel rating floor narrows the list, badges the button and lands in the store', () => { + const planner = makePlanner({ + places: [{ ...LOUVRE, rating_avg: 4.5 }, { ...EIFFEL, rating_avg: 3 }, SEINE], + } as Partial) + renderBrowser(planner) + const panelBtn = screen.getByRole('button', { name: 'places.allCategories' }) + fireEvent.click(panelBtn) + const floors = screen.getByRole('group', { name: 'Filter by rating' }) + fireEvent.click(within(floors).getByRole('button', { name: '4+' })) + expect(useTripStore.getState().placesRatingFilter).toBe(4) + expect(screen.getByText('Louvre')).toBeInTheDocument() + expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument() + expect(screen.queryByText('Seine Track')).not.toBeInTheDocument() + expect(panelBtn).toHaveTextContent('1') + + fireEvent.click(within(floors).getByRole('button', { name: 'All' })) + expect(useTripStore.getState().placesRatingFilter).toBe('all') + expect(screen.getByText('Seine Track')).toBeInTheDocument() + }) + + it('FE-MOB-PBROW-033: a floor set from the map sheet already thins the list on arrival', () => { + seedStore(useTripStore, { placesRatingFilter: 5 }) + renderBrowser(makePlanner({ places: [{ ...LOUVRE, rating_avg: 5 }, EIFFEL] } as Partial)) + expect(screen.getByText('Louvre')).toBeInTheDocument() + expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument() + }) + it('FE-MOB-PBROW-011: the uncategorized row filters the places without a category', () => { renderBrowser() fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' })) - fireEvent.click(screen.getByRole('checkbox', { name: 'places.noCategory' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'No Category' })) expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['uncategorized']) expect(screen.getByText('Eiffel Tower')).toBeInTheDocument() expect(screen.queryByText('Louvre')).not.toBeInTheDocument() @@ -181,7 +208,7 @@ describe('MPlacesBrowser', () => { const planner = makePlanner({ places: [LOUVRE, SEINE] } as Partial) renderBrowser(planner) fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' })) - expect(screen.queryByRole('checkbox', { name: 'places.noCategory' })).not.toBeInTheDocument() + expect(screen.queryByRole('checkbox', { name: 'No Category' })).not.toBeInTheDocument() expect(screen.getByRole('checkbox', { name: 'Food' })).toBeInTheDocument() }) @@ -362,6 +389,13 @@ describe('MPlacesBrowser', () => { expect(screen.queryByText('places.noneFound')).not.toBeInTheDocument() }) + it('FE-MOB-PBROW-034: an unplanned pool emptied by the rating floor reports no matches, not "all planned"', () => { + seedStore(useTripStore, { placesRatingFilter: 5 }) + renderBrowser(makePlanner({ places: [EIFFEL] } as Partial), buildShell({ browseFromEdit: true })) + expect(screen.getByText('places.noneFound')).toBeInTheDocument() + expect(screen.queryByText('places.allPlanned')).not.toBeInTheDocument() + }) + it('FE-MOB-PBROW-028: a search without hits reports no matches', () => { renderBrowser() fireEvent.change(screen.getByPlaceholderText('places.search'), { target: { value: 'zzz' } }) diff --git a/client/tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx b/client/tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx new file mode 100644 index 000000000..4e878684b --- /dev/null +++ b/client/tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, within } from '../../../helpers/render' +import MPlacesFilterSheet from '../../../../src/mobile/screens/trip/places/MPlacesFilterSheet' +import { useTripStore } from '../../../../src/store/tripStore' +import { resetAllStores, seedStore } from '../../../helpers/store' +import { buildPlace } from '../../../helpers/factories' +import type { Category, Place } from '../../../../src/types' + +// FE-MOB-PFSHEET-001 to FE-MOB-PFSHEET-009 + +const CATEGORIES = [ + { id: 1, name: 'Sights', color: '#123456', icon: 'landmark' }, + { id: 2, name: 'Food', color: '', icon: 'utensils' }, +] as unknown as Category[] + +const TAGGED = buildPlace({ id: 1, category_id: 1 }) +const BARE = buildPlace({ id: 2, category_id: null }) +const TRACK = buildPlace({ id: 3, category_id: 2, route_geometry: '[[1,2],[3,4]]' }) + +function renderSheet({ places = [TAGGED, BARE], categories = CATEGORIES, open = true }: { + places?: Place[] + categories?: Category[] + open?: boolean +} = {}) { + const onClose = vi.fn() + render() + return { onClose } +} + +const group = (name: string) => screen.getByRole('group', { name }) + +describe('MPlacesFilterSheet', () => { + beforeEach(() => { + resetAllStores() + }) + + it('FE-MOB-PFSHEET-001: shows the pool, the rating floors and the categories under one title', async () => { + renderSheet() + const sheet = await screen.findByRole('dialog', { name: 'Filters' }) + expect(within(sheet).getByText('Show')).toBeInTheDocument() + expect(within(group('Show')).getAllByRole('button').map(b => b.textContent)).toEqual(['All', 'Unplanned', 'Planned']) + expect(within(group('Filter by rating')).getAllByRole('button').map(b => b.textContent)) + .toEqual(['All', '5+', '4+', '3+', '2+', '1+']) + expect(screen.getByRole('checkbox', { name: 'Sights' })).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Food' })).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'No Category' })).toBeInTheDocument() + }) + + it('FE-MOB-PFSHEET-002: the tracks pool only shows once a place carries a track', async () => { + renderSheet({ places: [TAGGED, TRACK] }) + await screen.findByRole('dialog', { name: 'Filters' }) + expect(within(group('Show')).getByRole('button', { name: 'Tracks' })).toBeInTheDocument() + expect(screen.queryByRole('checkbox', { name: 'No Category' })).not.toBeInTheDocument() + }) + + it('FE-MOB-PFSHEET-003: picking a pool writes it to the trip store', async () => { + renderSheet() + await screen.findByRole('dialog', { name: 'Filters' }) + fireEvent.click(within(group('Show')).getByRole('button', { name: 'Unplanned' })) + expect(useTripStore.getState().placesFilter).toBe('unplanned') + }) + + it('FE-MOB-PFSHEET-004: picking a floor writes the rating filter to the trip store', async () => { + renderSheet() + await screen.findByRole('dialog', { name: 'Filters' }) + fireEvent.click(within(group('Filter by rating')).getByRole('button', { name: '3+' })) + expect(useTripStore.getState().placesRatingFilter).toBe(3) + }) + + it('FE-MOB-PFSHEET-005: categories toggle in and out of the shared set, "no category" included', async () => { + renderSheet() + await screen.findByRole('dialog', { name: 'Filters' }) + fireEvent.click(screen.getByRole('checkbox', { name: 'Food' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'No Category' })) + expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['2', 'uncategorized']) + expect(screen.getByRole('checkbox', { name: 'Food' })).toHaveAttribute('aria-checked', 'true') + fireEvent.click(screen.getByRole('checkbox', { name: 'Food' })) + expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['uncategorized']) + }) + + it('FE-MOB-PFSHEET-006: reset lifts every filter, and stays off while nothing filters', async () => { + seedStore(useTripStore, { placesFilter: 'planned', placesCategoryFilter: new Set(['1']), placesRatingFilter: 5 }) + renderSheet() + await screen.findByRole('dialog', { name: 'Filters' }) + const reset = screen.getByRole('button', { name: 'Reset' }) + expect(reset).toBeEnabled() + fireEvent.click(reset) + const s = useTripStore.getState() + expect(s.placesFilter).toBe('all') + expect(s.placesCategoryFilter.size).toBe(0) + expect(s.placesRatingFilter).toBe('all') + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled() + }) + + it('FE-MOB-PFSHEET-007: without any category to choose, the categories section is left out', async () => { + renderSheet({ places: [TAGGED], categories: [] }) + const sheet = await screen.findByRole('dialog', { name: 'Filters' }) + expect(within(sheet).queryByText('Categories')).not.toBeInTheDocument() + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument() + }) + + it('FE-MOB-PFSHEET-008: the close button hands back to the caller', async () => { + const { onClose } = renderSheet() + fireEvent.click(within(await screen.findByRole('dialog', { name: 'Filters' })).getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-PFSHEET-009: nothing is rendered while closed', () => { + renderSheet({ open: false }) + expect(screen.queryByRole('dialog', { name: 'Filters' })).not.toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/trip/MTripSheets.test.tsx b/client/tests/unit/mobile/trip/MTripSheets.test.tsx index 4d303407d..2c189a1c8 100644 --- a/client/tests/unit/mobile/trip/MTripSheets.test.tsx +++ b/client/tests/unit/mobile/trip/MTripSheets.test.tsx @@ -10,9 +10,9 @@ import { useSettingsStore } from '../../../../src/store/settingsStore' import { useTripStore } from '../../../../src/store/tripStore' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { resetAllStores, seedStore } from '../../../helpers/store' -import { fireEvent, render, screen, waitFor } from '../../../helpers/render' +import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' -// FE-MOB-SHOST-001 to FE-MOB-SHOST-029 and FE-MOB-SHOST-032 (030 and 031 live in MTripSheets.members.test.tsx) +// FE-MOB-SHOST-001 to FE-MOB-SHOST-029, FE-MOB-SHOST-032 and FE-MOB-SHOST-033 (030 and 031 live in MTripSheets.members.test.tsx) // // Every child sheet is stubbed: this file is about the host — which sheet is // mounted for which shell.sheet id, and how the host's own callbacks wire the @@ -237,6 +237,18 @@ describe('MTripSheets', () => { } }) + it('FE-MOB-SHOST-033: the map\'s places filter opens for its own id and closes through the shell', async () => { + const { shell } = renderHost({}, { sheet: { id: 'placesFilter' } }) + const sheet = await screen.findByRole('dialog', { name: 'Filters' }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Close' })) + expect(shell.closeSheet).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-SHOST-033b: no places filter while another sheet is open', () => { + renderHost({}, { sheet: { id: 'day' } }) + expect(screen.queryByRole('dialog', { name: 'Filters' })).not.toBeInTheDocument() + }) + it('FE-MOB-SHOST-004: hands the note payload to the note sheet and only for that id', () => { renderHost({}, { sheet: { id: 'note', payload: { dayId: 4 } } }) expect(screen.getByTestId('stub-note')).toHaveAttribute('data-day', '4') diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index f02ee24d3..1ab17583e 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'تصفية حسب البلد أو المنطقة', 'places.allLocations': 'كل الدول', 'places.filterShow': 'عرض', + 'places.filters': 'عوامل التصفية', 'places.clearSearch': 'مسح البحث', 'places.yourRating': 'تقييمك', 'places.notRated': 'لم يُقيَّم بعد', diff --git a/shared/src/i18n/az/places.ts b/shared/src/i18n/az/places.ts index 0aaef1e94..1fdfe4332 100644 --- a/shared/src/i18n/az/places.ts +++ b/shared/src/i18n/az/places.ts @@ -70,6 +70,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Ölkə və ya regiona görə filtrlə', 'places.allLocations': 'Bütün ölkələr', 'places.filterShow': 'Göstər', + 'places.filters': 'Filtrlər', 'places.clearSearch': 'Axtarışı təmizlə', 'places.yourRating': 'Sizin reytinqiniz', 'places.notRated': 'Hələ qiymətləndirilməyib', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index 264628d38..fa5fb5e4e 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filtrar por país ou região', 'places.allLocations': 'Todos os países', 'places.filterShow': 'Mostrar', + 'places.filters': 'Siloù', 'places.clearSearch': 'Limpar busca', 'places.yourRating': 'Sua avaliação', 'places.notRated': 'Ainda sem avaliações', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index 078ec5c50..f68f3bca8 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -60,6 +60,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filtra per país o regió', 'places.allLocations': 'Tots els països', 'places.filterShow': 'Mostrar', + 'places.filters': 'Filtres', 'places.clearSearch': 'Esborra la cerca', 'places.yourRating': 'La teva valoració', 'places.notRated': 'Encara sense valoració', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 032c413a0..abc4656ad 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filtrovat podle země nebo regionu', 'places.allLocations': 'Všechny země', 'places.filterShow': 'Zobrazit', + 'places.filters': 'Filtry', 'places.clearSearch': 'Vymazat hledání', 'places.yourRating': 'Tvé hodnocení', 'places.notRated': 'Zatím bez hodnocení', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 5f1e62caf..d16b51d76 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Nach Land oder Region filtern', 'places.allLocations': 'Alle Länder', 'places.filterShow': 'Anzeigen', + 'places.filters': 'Filter', 'places.clearSearch': 'Suche leeren', 'places.yourRating': 'Deine Bewertung', 'places.notRated': 'Noch nicht bewertet', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index bd483125a..33241eb1d 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -64,6 +64,8 @@ const places: TranslationStrings = { /** The button that opens the filter panel, and the panel's name. */ /** Label over the all/unplanned/planned/tracks choice in the filter panel. */ 'places.filterShow': 'Show', + /** The phone map's button that opens the places filter sheet, and the sheet's title. */ + 'places.filters': 'Filters', 'places.clearSearch': 'Clear search', 'places.yourRating': 'Your rating', 'places.notRated': 'Not rated yet', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index 750b76092..49dbf1a39 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -62,6 +62,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filtrar por país o región', 'places.allLocations': 'Todos los países', 'places.filterShow': 'Mostrar', + 'places.filters': 'Filtros', 'places.clearSearch': 'Borrar búsqueda', 'places.yourRating': 'Tu valoración', 'places.notRated': 'Sin valorar todavía', diff --git a/shared/src/i18n/et/places.ts b/shared/src/i18n/et/places.ts index 1d24bd472..19d52bb72 100644 --- a/shared/src/i18n/et/places.ts +++ b/shared/src/i18n/et/places.ts @@ -62,6 +62,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filtreeri riigi või piirkonna järgi', 'places.allLocations': 'Kõik riigid', 'places.filterShow': 'Näita', + 'places.filters': 'Filtrid', 'places.clearSearch': 'Tühjenda otsing', 'places.yourRating': 'Sinu hinnang', 'places.notRated': 'Veel hindamata', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 80730f335..97238e2d7 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -62,6 +62,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filtrer par pays ou région', 'places.allLocations': 'Tous les pays', 'places.filterShow': 'Afficher', + 'places.filters': 'Filtres', 'places.clearSearch': 'Effacer la recherche', 'places.yourRating': 'Ta note', 'places.notRated': 'Pas encore noté', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index 10ca96197..498af63f5 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -62,6 +62,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Φιλτράρισμα ανά χώρα ή περιοχή', 'places.allLocations': 'Όλες οι χώρες', 'places.filterShow': 'Εμφάνιση', + 'places.filters': 'Φίλτρα', 'places.clearSearch': 'Καθαρισμός αναζήτησης', 'places.yourRating': 'Η βαθμολογία σου', 'places.notRated': 'Χωρίς βαθμολογία ακόμη', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index 42f3017cc..7f44a986a 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Szűrés ország vagy régió szerint', 'places.allLocations': 'Minden ország', 'places.filterShow': 'Megjelenítés', + 'places.filters': 'Szűrők', 'places.clearSearch': 'Keresés törlése', 'places.yourRating': 'Az értékelésed', 'places.notRated': 'Még nincs értékelve', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index db53829ab..1a804f524 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -60,6 +60,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Saring menurut negara atau wilayah', 'places.allLocations': 'Semua negara', 'places.filterShow': 'Tampilkan', + 'places.filters': 'Filter', 'places.clearSearch': 'Hapus pencarian', 'places.yourRating': 'Rating kamu', 'places.notRated': 'Belum dinilai', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index e69726f88..b917c03be 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filtra per paese o regione', 'places.allLocations': 'Tutti i paesi', 'places.filterShow': 'Mostra', + 'places.filters': 'Filtri', 'places.clearSearch': 'Cancella ricerca', 'places.yourRating': 'La tua valutazione', 'places.notRated': 'Non ancora valutato', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index 711a9f85e..7f35bc3bf 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': '国または地域で絞り込む', 'places.allLocations': 'すべての国', 'places.filterShow': '表示', + 'places.filters': 'フィルター', 'places.clearSearch': '検索をクリア', 'places.yourRating': 'あなたの評価', 'places.notRated': '未評価', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 1373a7524..348332064 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -60,6 +60,7 @@ const places: TranslationStrings = { 'places.filterByLocation': '국가 또는 지역으로 필터링', 'places.allLocations': '모든 국가', 'places.filterShow': '표시', + 'places.filters': '필터', 'places.clearSearch': '검색 지우기', 'places.yourRating': '내 평점', 'places.notRated': '아직 평점 없음', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index 7ba46c47f..86849da59 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filteren op land of regio', 'places.allLocations': 'Alle landen', 'places.filterShow': 'Tonen', + 'places.filters': 'Filters', 'places.clearSearch': 'Zoekopdracht wissen', 'places.yourRating': 'Jouw beoordeling', 'places.notRated': 'Nog niet beoordeeld', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index eb721e9c0..2b329514f 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -51,6 +51,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filtruj według kraju lub regionu', 'places.allLocations': 'Wszystkie kraje', 'places.filterShow': 'Pokaż', + 'places.filters': 'Filtry', 'places.clearSearch': 'Wyczyść wyszukiwanie', 'places.yourRating': 'Twoja ocena', 'places.notRated': 'Jeszcze nie oceniono', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index fae8ba678..b96d20cd2 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Фильтр по стране или региону', 'places.allLocations': 'Все страны', 'places.filterShow': 'Показать', + 'places.filters': 'Фильтры', 'places.clearSearch': 'Очистить поиск', 'places.yourRating': 'Твоя оценка', 'places.notRated': 'Ещё нет оценок', diff --git a/shared/src/i18n/sk/places.ts b/shared/src/i18n/sk/places.ts index 21f7e7d81..08418207a 100644 --- a/shared/src/i18n/sk/places.ts +++ b/shared/src/i18n/sk/places.ts @@ -62,6 +62,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filtrovať podľa krajiny alebo regiónu', 'places.allLocations': 'Všetky krajiny', 'places.filterShow': 'Zobraziť', + 'places.filters': 'Filtre', 'places.clearSearch': 'Vymazať hľadanie', 'places.yourRating': 'Vaše hodnotenie', 'places.notRated': 'Zatiaľ bez hodnotenia', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 86675fae1..748cd9b8c 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Filtrera efter land eller region', 'places.allLocations': 'Alla länder', 'places.filterShow': 'Visa', + 'places.filters': 'Filter', 'places.clearSearch': 'Rensa sökning', 'places.yourRating': 'Ditt betyg', 'places.notRated': 'Inte betygsatt ännu', diff --git a/shared/src/i18n/th/places.ts b/shared/src/i18n/th/places.ts index 46beee441..11a45cdf4 100644 --- a/shared/src/i18n/th/places.ts +++ b/shared/src/i18n/th/places.ts @@ -55,6 +55,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'กรองตามประเทศหรือภูมิภาค', 'places.allLocations': 'ทุกประเทศ', 'places.filterShow': 'แสดง', + 'places.filters': 'ตัวกรอง', 'places.clearSearch': 'ล้างการค้นหา', 'places.yourRating': 'คะแนนของคุณ', 'places.notRated': 'ยังไม่ได้รับการจัดอันดับ', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index a8c0ea971..ae125af18 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -63,6 +63,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Ülke veya bölgeye göre filtrele', 'places.allLocations': 'Tüm ülkeler', 'places.filterShow': 'Göster', + 'places.filters': 'Filtreler', 'places.clearSearch': 'Aramayı temizle', 'places.yourRating': 'Senin puanın', 'places.notRated': 'Henüz puanlanmadı', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index df379f2d9..d6ab8b737 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Фільтр за країною або регіоном', 'places.allLocations': 'Усі країни', 'places.filterShow': 'Показати', + 'places.filters': 'Фільтри', 'places.clearSearch': 'Очистити пошук', 'places.yourRating': 'Твоя оцінка', 'places.notRated': 'Ще не оцінено', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index b3e5e6293..411e6d7d4 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -61,6 +61,7 @@ const places: TranslationStrings = { 'places.filterByLocation': 'Lọc theo quốc gia hoặc vùng', 'places.allLocations': 'Tất cả quốc gia', 'places.filterShow': 'Hiển thị', + 'places.filters': 'Bộ lọc', 'places.clearSearch': 'Xóa tìm kiếm', 'places.yourRating': 'Đánh giá của bạn', 'places.notRated': 'Chưa có đánh giá', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index fd25d62b2..646a8cf3b 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -60,6 +60,7 @@ const places: TranslationStrings = { 'places.filterByLocation': '依國家或地區篩選', 'places.allLocations': '所有國家', 'places.filterShow': '顯示', + 'places.filters': '篩選', 'places.clearSearch': '清除搜尋', 'places.yourRating': '你的評分', 'places.notRated': '尚未評分', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index a5a78d658..c1f4dbde1 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -60,6 +60,7 @@ const places: TranslationStrings = { 'places.filterByLocation': '按国家或地区筛选', 'places.allLocations': '所有国家', 'places.filterShow': '显示', + 'places.filters': '筛选', 'places.clearSearch': '清除搜索', 'places.yourRating': '你的评分', 'places.notRated': '暂无评分', From fe5a8a8854599e2076585e1d7ef109202f5482b0 Mon Sep 17 00:00:00 2001 From: EvKoh <1309463+EvKoh@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:14:23 +0200 Subject: [PATCH 08/16] fix(map): make the places filter sheet opaque The sheet opens over the phone map, and with the default bar glass the map's place names showed through the pool chips, the rating floors and the category rows, which made them hard to read at 390px wide. It now uses the opaque sheet material, like the other menus that open over busy content. --- client/src/mobile/screens/trip/places/MPlacesFilterSheet.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/mobile/screens/trip/places/MPlacesFilterSheet.tsx b/client/src/mobile/screens/trip/places/MPlacesFilterSheet.tsx index ce558a35f..7f9af9adb 100644 --- a/client/src/mobile/screens/trip/places/MPlacesFilterSheet.tsx +++ b/client/src/mobile/screens/trip/places/MPlacesFilterSheet.tsx @@ -46,7 +46,9 @@ export default function MPlacesFilterSheet({ open, onClose, places, categories } 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')}
From b5514024fa5e8bb7b57690e244ff3421783e91e8 Mon Sep 17 00:00:00 2001 From: EvKoh <1309463+EvKoh@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:35:14 +0200 Subject: [PATCH 09/16] docs(wiki): describe the map's places filters Map-Features gets a "Filtering the places on the map" section: the markers follow the places list's Show, category and rating filters, and on a phone the Filters button of the plan map opens the same choices in a sheet. Places-and-Search says the rating floor narrows the map markers too. --- wiki/Map-Features.md | 6 ++++++ wiki/Places-and-Search.md | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/wiki/Map-Features.md b/wiki/Map-Features.md index abea3eaf3..276de8c8f 100644 --- a/wiki/Map-Features.md +++ b/wiki/Map-Features.md @@ -121,6 +121,12 @@ On a phone the plan map shows one day at a time, so a booking you have switched > **Tip:** Whether endpoint text labels appear on the endpoint markers is your own choice: the **Booking route labels** setting in Settings → General → Travel & map (`map_booking_labels`). It is off by default; with it off, the endpoint markers show only the transport icon. +## Filtering the places on the map + +The trip's place markers follow the filters of the places list: **Show** (**All**, **Unplanned**, **Planned**, **Tracks**), the categories and **Filter by rating**. Whatever you pick in the list narrows the markers at once, and the other way round. + +On a phone you do not have to leave the map for it. The round **Filters** button at the foot of the plan map's right-hand stack opens a sheet with **Show**, **Filter by rating** (**All**, or a floor from **5+** down to **1+**), **Categories** (with **No Category** while a place has none) and **Reset**, which lifts them all. While a filter is on, the button lights up and a badge counts the filters in force. The places list's filter panel offers the same choices, and both write the same filters. The button is hidden on the road trip stage, which draws its own markers. + ## Category buttons With **Explore places on the map** switched on (Settings > General > Travel & map), the trip map carries a row of category buttons such as **Restaurants**, **Sights** or **Nature & parks**, and [plugins](Plugins) can add buttons of their own, such as trailheads, EV chargers or drinking water. Click one to show that kind of place around the part of the map you are looking at, and click a marker to add it as a place. See [Exploring the map by category](Places-and-Search#exploring-the-map-by-category). diff --git a/wiki/Places-and-Search.md b/wiki/Places-and-Search.md index 3dae7c5d1..775f58eb1 100644 --- a/wiki/Places-and-Search.md +++ b/wiki/Places-and-Search.md @@ -13,7 +13,7 @@ The right column of the Plan tab lists every place of the trip. Its head band ha 3. The filters and the order: - **Show**: **All**, **Unplanned**, **Planned**, and **Tracks** once the trip has one, each with its count. - **Categories** (the tag icon): tick one or several categories, or **No Category**. A badge on the icon counts the categories in force, and **Clear filter** lifts them. - - **Filter by rating** (the star): **All**, or a floor from **5+** down to **1+**. + - **Filter by rating** (the star): **All**, or a floor from **5+** down to **1+**. The map's markers follow the floor too. - **Filter by country or region** (the globe): every country the trip's places lie in, with its regions underneath, each with the number of places in it. Pick a country or one region; **All countries** is the default and **Clear filter** goes back to it. The button only appears once the trip spans more than one country or region. - **Sort by**: newest first (**Recently added**, the default), **Oldest first**, by name, **Highest rated** or **Recently changed**. The order applies to the list only, not to the map, and your browser remembers it. @@ -266,7 +266,7 @@ The footer holds **Add to Day** or **Remove from Day** for the selected day, **N Every trip member can rate a place from 1 to 5 stars, even when place editing is restricted to certain members. Open the place inspector: the **Rating** row shows the stars, the average with the number of votes in brackets, and the avatars of who voted; rest the pointer on it to see everyone's stars. Click a star to cast your vote, and click the same star again to clear it. A place nobody has rated reads **Not rated yet**. -The average also sits beside the place's name in the places column and on a marker's hover card on the map; a marker that carries no order badge shows it as a small disc in its corner instead. To narrow the list, **Filter by rating** (the star in the places column) picks a floor from 1 to 5 stars and keeps only the places whose average reaches it. +The average also sits beside the place's name in the places column and on a marker's hover card on the map; a marker that carries no order badge shows it as a small disc in its corner instead. To narrow the list, **Filter by rating** (the star in the places column) picks a floor from 1 to 5 stars and keeps only the places whose average reaches it. The map's markers follow the same floor, and on a phone it is set from the places list's filter panel or from **Filters** on the map, see [Filtering the places on the map](Map-Features#filtering-the-places-on-the-map). Saved places in [Collections](Collections) are rated the same way. Saving a trip place to a list or copying a list place into a trip carries the votes along, but only those of people who are members on both sides. From a9f7adf99b630eae649a3b106c4cc1a41b95abae Mon Sep 17 00:00:00 2001 From: EvKoh <1309463+EvKoh@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:49:48 +0200 Subject: [PATCH 10/16] fix(map): move the tracks filter fallback to the always-mounted planner hook The fallback from "Tracks" to "All" once the last track disappears lived in the desktop sidebar and the phone places browser, neither of which is mounted while the phone map is in front: the map could sit empty under a filter no control offers any more. useTripPlanner now owns it, once, for every screen. --- .../Planner/usePlacesSidebar.test.tsx | 9 +++------ .../components/Planner/usePlacesSidebar.ts | 3 ++- .../screens/trip/places/MPlacesBrowser.tsx | 5 ++--- .../pages/tripPlanner/useTripPlanner.test.tsx | 19 ++++++++++++++++++- .../src/pages/tripPlanner/useTripPlanner.ts | 11 +++++++++++ .../unit/mobile/trip/MPlacesBrowser.test.tsx | 6 +++--- 6 files changed, 39 insertions(+), 14 deletions(-) diff --git a/client/src/components/Planner/usePlacesSidebar.test.tsx b/client/src/components/Planner/usePlacesSidebar.test.tsx index 3ec5b6ca2..07aaf764b 100644 --- a/client/src/components/Planner/usePlacesSidebar.test.tsx +++ b/client/src/components/Planner/usePlacesSidebar.test.tsx @@ -188,12 +188,9 @@ 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']); - }); + // 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 }); diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts index cc5843c07..6a6c9abf5 100644 --- a/client/src/components/Planner/usePlacesSidebar.ts +++ b/client/src/components/Planner/usePlacesSidebar.ts @@ -259,8 +259,9 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { const pickFilter = (next: PlacesFilter) => { setFilter(next); setSelectedIds(new Set()) } // Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen) + // Whether to offer the "Tracks" tab. useTripPlanner moves the filter back to + // "all" once the last track is gone, whichever screen is in front. const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places]) - useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter]) const plannedIds = useMemo( () => plannedPlaceIds({ assignments, accommodations, reservations }), diff --git a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx index db8d6a21b..fb9fe9252 100644 --- a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx +++ b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx @@ -63,10 +63,9 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) if (shell.browseFromEdit) setFilter('unplanned') }, [shell.browseFromEdit, setFilter]) + // Whether to offer the "Tracks" chip; useTripPlanner moves the filter back to + // "all" once the last track is gone. const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places]) - 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). diff --git a/client/src/pages/tripPlanner/useTripPlanner.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.test.tsx index c4ea889cc..33cf5c255 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.test.tsx @@ -1,4 +1,4 @@ -// FE-TP-HOOK-001 to FE-TP-HOOK-176 +// 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' @@ -644,6 +644,23 @@ 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('FE-TP-HOOK-027: the category filter honours the uncategorized bucket', async () => { seedTrip({ places: [geo(1, { category_id: 3 }), geo(2, { category_id: null })], diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index bc65e3ac5..25b85e4c4 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -768,6 +768,17 @@ export function useTripPlanner() { }) }, [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(() => places.some(p => p.route_geometry), [places]) + 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 // down there rather than drawing a second set of lines over it. diff --git a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx index a2e352d6a..a2eebd252 100644 --- a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx +++ b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx @@ -124,11 +124,11 @@ describe('MPlacesBrowser', () => { expect(screen.queryByText('Louvre')).not.toBeInTheDocument() }) - it('FE-MOB-PBROW-007: hides the tracks chip and falls back to all when the last track is gone', () => { - seedStore(useTripStore, { placesFilter: 'tracks' }) + // The fall back to "all" once the last track is gone lives in useTripPlanner + // (FE-TP-HOOK-177), which stays mounted while this browser is not. + it('FE-MOB-PBROW-007: hides the tracks chip when no place carries a track', () => { renderBrowser(makePlanner({ places: [LOUVRE, EIFFEL] } as Partial)) expect(screen.queryByRole('button', { name: 'places.filterTracks' })).not.toBeInTheDocument() - expect(useTripStore.getState().placesFilter).toBe('all') expect(screen.getByText('places.count:2')).toBeInTheDocument() }) From 3951e8cc9a1ee963265bbfc7d11bfb06cc01bb27 Mon Sep 17 00:00:00 2001 From: EvKoh <1309463+EvKoh@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:50:30 +0200 Subject: [PATCH 11/16] fix(map): announce the places filter control as opening a dialog MapTogglePill always set aria-pressed, so the phone map's Filters control read as a toggle although it opens a sheet. An opensDialog option drops aria-pressed for aria-haspopup="dialog"; the other map toggles are unchanged. --- client/src/components/Map/MapTogglePill.tsx | 11 +++++++++-- .../src/mobile/screens/trip/map/MPlacesFilterPill.tsx | 4 +++- client/tests/unit/mobile/trip/MMapArea.test.tsx | 8 ++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/client/src/components/Map/MapTogglePill.tsx b/client/src/components/Map/MapTogglePill.tsx index f78c125b2..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', badge = 0 }: { +export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left', badge = 0, opensDialog = false }: { active: boolean onToggle: () => void label: string @@ -16,6 +16,12 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl 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 (
void }) { const { t } = useTranslation() @@ -23,6 +24,7 @@ export function MPlacesFilterPill({ onOpen }: { onOpen: () => void }) { label={label} badge={count} testId="places-filter-pill" + opensDialog icon={} /> ) diff --git a/client/tests/unit/mobile/trip/MMapArea.test.tsx b/client/tests/unit/mobile/trip/MMapArea.test.tsx index ef683de0a..c2afec565 100644 --- a/client/tests/unit/mobile/trip/MMapArea.test.tsx +++ b/client/tests/unit/mobile/trip/MMapArea.test.tsx @@ -958,7 +958,9 @@ describe('MMapArea — places filter', () => { const { shell } = renderArea({ trTab: 'plan' }) const pill = screen.getByTestId('places-filter-pill') expect(pill).toHaveAccessibleName('Filters') - expect(pill).toHaveAttribute('aria-pressed', 'false') + // It opens a sheet: a dialog to announce, not an on/off state. + expect(pill).toHaveAttribute('aria-haspopup', 'dialog') + expect(pill).not.toHaveAttribute('aria-pressed') act(() => { pill.click() }) expect(shell.openSheet).toHaveBeenCalledWith('placesFilter') }) @@ -967,7 +969,9 @@ describe('MMapArea — places filter', () => { seedStore(useTripStore, { placesFilter: 'unplanned', placesRatingFilter: 4 }) renderArea({ trTab: 'plan' }) const pill = screen.getByTestId('places-filter-pill') - expect(pill).toHaveAttribute('aria-pressed', 'true') + expect(pill).toHaveClass('text-accent') + expect(pill).not.toHaveAttribute('aria-pressed') + expect(pill).toHaveAttribute('aria-haspopup', 'dialog') expect(pill).toHaveAccessibleName('Filters (2)') expect(screen.getByText('2')).toHaveAttribute('aria-hidden', 'true') }) From db3c9d660e19d016b599742ecdee14aa93f7bfbc Mon Sep 17 00:00:00 2001 From: EvKoh <1309463+EvKoh@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:51:34 +0200 Subject: [PATCH 12/16] fix(mobile): announce which Show and rating chip is chosen MChip only showed its state through colour. A pressable option renders aria-pressed from active; the places filter's Show and rating floor chips use it, and the tests now check the chosen chip by its pressed state. --- client/src/mobile/components/MChip.tsx | 9 ++++++++- .../screens/trip/places/MPlacesFilterControls.tsx | 2 +- .../screens/trip/places/MPlacesFilterSheet.tsx | 2 +- .../tests/unit/mobile/trip/MPlacesBrowser.test.tsx | 1 + .../unit/mobile/trip/MPlacesFilterSheet.test.tsx | 12 ++++++++++-- 5 files changed, 21 insertions(+), 5 deletions(-) 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 (