mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
merge: update Tours branch with current Places filters
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
* One round on/off control in the frosted shell every map control wears, so a new
|
||||
* one lines up with the compass, the layer switcher and the overview toggle.
|
||||
*/
|
||||
export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left' }: {
|
||||
export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left', badge = 0, opensDialog = false }: {
|
||||
active: boolean
|
||||
onToggle: () => void
|
||||
label: string
|
||||
@@ -14,9 +14,18 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
|
||||
testId?: string
|
||||
/** Away from the edge the control hugs: left on the right edge, right on the left one. */
|
||||
tooltipPlacement?: 'left' | 'right'
|
||||
/** A count pinned to the top right corner while above zero (how many filters are on, say). */
|
||||
badge?: number
|
||||
/**
|
||||
* The control opens a dialog instead of switching something on and off (the phone
|
||||
* map's places filter): it then announces the dialog, not a pressed state it
|
||||
* does not have. `active` still lights it.
|
||||
*/
|
||||
opensDialog?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
@@ -30,7 +39,8 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
aria-pressed={opensDialog ? undefined : active}
|
||||
aria-haspopup={opensDialog ? 'dialog' : undefined}
|
||||
data-testid={testId}
|
||||
className={active ? 'text-accent' : 'text-content-muted'}
|
||||
style={{
|
||||
@@ -45,6 +55,14 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
|
||||
{icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{badge > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -right-[3px] -top-[3px] box-border flex h-4 min-w-[16px] items-center justify-center rounded-full bg-accent px-1 font-geist text-[0.5625rem] font-bold tabular-nums text-accent-text"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { tintOf } from './planParts'
|
||||
import { CategoryTile } from './PlacesBulkCategoryModal'
|
||||
import type { PlacesFilter, SidebarState } from './usePlacesSidebar'
|
||||
import { PLACES_SORTS, type PlacesSort } from './placesSort'
|
||||
import { RATING_FLOORS, UNCATEGORIZED } from '../../utils/placesFilter'
|
||||
|
||||
/**
|
||||
* Below this the two labels stop fitting side by side and both buttons fall back
|
||||
@@ -48,8 +49,6 @@ function BandButton({ label, onClick, on = false, lit = false, ariaPressed, aria
|
||||
)
|
||||
}
|
||||
|
||||
const RATING_FLOORS = ['all', 5, 4, 3, 2, 1] as const
|
||||
|
||||
export function PlacesDropOverlay({ t }: SidebarState) {
|
||||
return (
|
||||
<div
|
||||
@@ -260,7 +259,7 @@ function CategoryFilter(S: SidebarState) {
|
||||
let label = t('places.allCategories')
|
||||
if (categoryFilters.size === 1) {
|
||||
const only = categories.find(c => categoryFilters.has(String(c.id)))
|
||||
if (categoryFilters.has('uncategorized')) label = t('places.noCategory')
|
||||
if (categoryFilters.has(UNCATEGORIZED)) label = t('places.noCategory')
|
||||
else if (only) label = only.name
|
||||
} else if (categoryFilters.size > 1) {
|
||||
label = `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||
@@ -279,8 +278,8 @@ function CategoryFilter(S: SidebarState) {
|
||||
))}
|
||||
{places.some(p => p.category_id == null) && (
|
||||
<CategoryChoice
|
||||
on={categoryFilters.has('uncategorized')}
|
||||
onClick={() => toggleCategoryFilter('uncategorized')}
|
||||
on={categoryFilters.has(UNCATEGORIZED)}
|
||||
onClick={() => toggleCategoryFilter(UNCATEGORIZED)}
|
||||
tile={<CategoryTile size={22} />}
|
||||
label={t('places.noCategory')}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-059
|
||||
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-060
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, fireEvent, act, waitFor } from '../../../tests/helpers/render';
|
||||
@@ -188,13 +188,46 @@ describe('usePlacesSidebar filtering', () => {
|
||||
expect(names()).toEqual(['GPX Track']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-008: a tracks filter without any track falls back to "all"', async () => {
|
||||
seedStore(useTripStore, { placesFilter: 'tracks' });
|
||||
render(<Host {...makeProps({ places: [buildPlace({ name: 'Plain Spot' })] })} />);
|
||||
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'));
|
||||
expect(names()).toEqual(['Plain Spot']);
|
||||
it('Tours addon excludes Tour-backed Places from rows, counts, and localities', () => {
|
||||
const legacyTrack = buildPlace({ id: 41, name: 'Legacy track', route_geometry: '[[1,2],[3,4]]', rating_avg: 4, address: 'Vienna, Austria' });
|
||||
const ordinary = buildPlace({ id: 42, name: 'Ordinary place', address: 'Linz, Austria' });
|
||||
const tour = buildPlace({ id: 43, name: 'Tour place', tour_place_id: 43, route_geometry: '[[5,6],[7,8]]', address: 'Denver, United States' });
|
||||
render(<Host {...makeProps({
|
||||
places: [legacyTrack, ordinary, tour],
|
||||
toursEnabled: true,
|
||||
excludePlaceIds: new Set([tour.id]),
|
||||
})} />);
|
||||
|
||||
expect(S.hasTracks).toBe(false);
|
||||
expect(names()).toEqual(expect.arrayContaining(['Legacy track', 'Ordinary place']));
|
||||
expect(names()).not.toContain('Tour place');
|
||||
expect(S.filterCounts).toEqual({ all: 2, unplanned: 2, planned: 0, tracks: 1 });
|
||||
expect(S.localities).toEqual([{ country: 'Austria', count: 2, regions: [] }]);
|
||||
|
||||
act(() => { S.setRatingFilter(4); });
|
||||
expect(names()).toEqual(['Legacy track']);
|
||||
expect(S.filterCounts.all).toBe(2);
|
||||
});
|
||||
|
||||
it('Tours addon OFF preserves legacy track Places and ignores the exclusion set', () => {
|
||||
const legacyTrack = buildPlace({ id: 51, name: 'Legacy track', route_geometry: '[[1,2],[3,4]]' });
|
||||
const dormantTour = buildPlace({ id: 52, name: 'Dormant Tour', tour_place_id: 52, route_geometry: '[[5,6],[7,8]]' });
|
||||
render(<Host {...makeProps({
|
||||
places: [legacyTrack, dormantTour],
|
||||
toursEnabled: false,
|
||||
excludePlaceIds: new Set([dormantTour.id]),
|
||||
})} />);
|
||||
|
||||
expect(S.hasTracks).toBe(true);
|
||||
expect(S.filterCounts).toEqual({ all: 2, unplanned: 2, planned: 0, tracks: 2 });
|
||||
act(() => { S.setFilter('tracks'); });
|
||||
expect(names()).toEqual(expect.arrayContaining(['Legacy track', 'Dormant Tour']));
|
||||
});
|
||||
|
||||
// FE-PLANNER-PSHOOK-008 (a tracks filter without any track falls back to "all")
|
||||
// moved to useTripPlanner (FE-TP-HOOK-177): the fallback now lives there, so it
|
||||
// also holds while no places list is mounted.
|
||||
|
||||
it('FE-PLANNER-PSHOOK-009: a category filter keeps only that category', () => {
|
||||
const tagged = buildPlace({ name: 'Tagged', category_id: 4 });
|
||||
const other = buildPlace({ name: 'Other', category_id: 5 });
|
||||
@@ -245,6 +278,17 @@ describe('usePlacesSidebar filtering', () => {
|
||||
expect(names()).toEqual(['Unrated', 'Rated']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-060: the star floor lives in the trip store, so the map and the phone list follow it', () => {
|
||||
const places = [buildPlace({ name: 'Good', rating_avg: 4 }), buildPlace({ name: 'Meh', rating_avg: 2 })];
|
||||
render(<Host {...makeProps({ places })} />);
|
||||
act(() => { S.setRatingFilter(3); });
|
||||
expect(useTripStore.getState().placesRatingFilter).toBe(3);
|
||||
// And back: a floor picked elsewhere (the phone's filter sheet) narrows this list too.
|
||||
act(() => { useTripStore.getState().setPlacesRatingFilter('all'); });
|
||||
expect(S.ratingFilter).toBe('all');
|
||||
expect(names()).toEqual(['Good', 'Meh']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-014: search and category filter combine', () => {
|
||||
const places = [
|
||||
buildPlace({ name: 'Cafe Nord', category_id: 4 }),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { placeMatchesSearch } from '../../utils/placeSearch'
|
||||
import { matchesCategoryFilter, matchesPlacesFilter } from '../../utils/placesFilter'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
||||
import type { MenuEntry } from './planParts'
|
||||
@@ -193,19 +194,20 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const categoryFilters = useTripStore((s) => s.placesCategoryFilter)
|
||||
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
// Star sort (#1435): list-only toggle, so it stays local (the map keeps its order).
|
||||
// Minimum average stars, matching the collections filter (#1435): 'all', or a
|
||||
// floor of 1..5 that unrated places fall through. It replaced a sort toggle,
|
||||
// which put the best first but still left everything else on the list — no
|
||||
// help at all when the point is to see only what the group actually rated.
|
||||
const [ratingFilter, setRatingFilter] = useState<number | 'all'>('all')
|
||||
// In the trip store with the other filters, so the map markers follow it too.
|
||||
const ratingFilter = useTripStore((s) => s.placesRatingFilter)
|
||||
const setRatingFilter = useTripStore((s) => s.setPlacesRatingFilter)
|
||||
// The list's order (#2093), remembered on this device.
|
||||
const [placesSort, setPlacesSortState] = useState<PlacesSort>(readPlacesSort)
|
||||
const setPlacesSort = useCallback((sort: PlacesSort) => { setPlacesSortState(sort); writePlacesSort(sort) }, [])
|
||||
// Country, or country and region, from each place's resolved position (#2537). List-only,
|
||||
// like the rating floor.
|
||||
const [localityFilter, setLocalityFilter] = useState<LocalityFilter | null>(null)
|
||||
const localityOf = useMemo(() => new Map(places.map(p => [p.id, placeLocality(p, language)])), [places, language])
|
||||
const localityOf = useMemo(() => new Map(poolPlaces.map(p => [p.id, placeLocality(p, language)])), [poolPlaces, language])
|
||||
const localities = useMemo(() => localityGroups(poolPlaces.map(p => localityOf.get(p.id)!)), [poolPlaces, localityOf])
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
||||
@@ -272,7 +274,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
() => !props.toursEnabled && poolPlaces.some(p => p.route_geometry),
|
||||
[poolPlaces, props.toursEnabled],
|
||||
)
|
||||
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
|
||||
|
||||
const plannedIds = useMemo(
|
||||
() => plannedPlaceIds({ assignments, accommodations, reservations }),
|
||||
@@ -301,16 +302,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = poolPlaces.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||
}
|
||||
if (!matchesPlacesFilter(p, { filter, categoryFilters, ratingFilter }, { plannedIds, plannedFilterIds })) return false
|
||||
if (!placeMatchesSearch(p, search)) return false
|
||||
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
|
||||
if (localityFilter && !matchesLocality(localityOf.get(p.id), localityFilter)) return false
|
||||
return true
|
||||
})
|
||||
@@ -325,14 +318,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
* different questions.
|
||||
*/
|
||||
const filterCounts = useMemo(() => {
|
||||
const base = poolPlaces.filter(p => {
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||
}
|
||||
return placeMatchesSearch(p, search)
|
||||
})
|
||||
const base = poolPlaces.filter(p => matchesCategoryFilter(p, categoryFilters) && placeMatchesSearch(p, search))
|
||||
return {
|
||||
all: base.length,
|
||||
unplanned: base.filter(p => !plannedIds.has(p.id)).length,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -10,16 +10,23 @@ interface MChipProps {
|
||||
* back what it can: the chips that start a search are the ones worth the height.
|
||||
*/
|
||||
size?: 'sm' | 'tap'
|
||||
/**
|
||||
* The chip is one choice of a picker: announce `active` as `aria-pressed`, so a
|
||||
* screen reader hears which one is on, not just its colour. Off by default, for
|
||||
* chips that only run an action.
|
||||
*/
|
||||
pressable?: boolean
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Small pill chip: --m-act when active, neutral --m-ic surface otherwise. */
|
||||
export default function MChip({ active = false, onClick, size = 'sm', className = '', children }: MChipProps) {
|
||||
export default function MChip({ active = false, onClick, size = 'sm', pressable = false, className = '', children }: MChipProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={pressable ? active : undefined}
|
||||
className={`inline-flex flex-none items-center gap-[6px] rounded-full font-semibold ${
|
||||
size === 'tap' ? 'px-[13px] py-[10px] text-[0.8125rem]' : 'px-3 py-[7px] text-[0.75rem]'
|
||||
} ${
|
||||
|
||||
@@ -54,7 +54,7 @@ export type MTripCollabTab = 'chat' | 'notes' | 'links' | 'polls'
|
||||
* { dayId, note? }), 'accommodation' (payload { dayId?, accId?, from? }),
|
||||
* 'transport' (payload { reservationId }), 'bract'
|
||||
* (payload { placeId, dayPicker? }), 'import', 'export', 'members',
|
||||
* 'tripedit', 'bags', 'task'. The place inspector keys off the planner's
|
||||
* 'tripedit', 'bags', 'task', 'placesFilter' (the map's places filter). The place inspector keys off the planner's
|
||||
* place selection instead of a sheet id, and planner-backed editors (place
|
||||
* form, transport, booking, expense) keep using the planner's own modal flags.
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapC
|
||||
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../../../components/Map/MapLayerSwitcher'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../components/Map/TripRouteOverview'
|
||||
import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill'
|
||||
import { MPlacesFilterPill } from './MPlacesFilterPill'
|
||||
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
|
||||
import { usePoiExplore } from '../../../../components/Map/usePoiExplore'
|
||||
import { useMergedMapPois } from '../../../../components/Map/useMergedMapPois'
|
||||
@@ -452,8 +453,11 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
clear of the round-controls band below it and of the base-layer switcher and
|
||||
the compass, which both sit bottom left. The offset is Tailwind rather than
|
||||
inline because the compass band is identified by being the one element with
|
||||
an inline --bottom-nav-h, and a second would make that ambiguous. */}
|
||||
{mapActive && !onStage && (!planner.roadtripActive || planner.dawarichEnabled) && (
|
||||
an inline --bottom-nav-h, and a second would make that ambiguous. The places
|
||||
filter closes the stack, under the toggles, so the overview card stays
|
||||
right above its own toggle: it decides which pins the plan map shows, so it is
|
||||
there whatever else is; the stage draws its own pins and has no use for it. */}
|
||||
{mapActive && !onStage && (
|
||||
<div className="pointer-events-none absolute left-3 right-3 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+58px)]">
|
||||
{!planner.roadtripActive && planner.overviewActive && (
|
||||
<TripRouteOverviewPanel
|
||||
@@ -478,6 +482,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
onToggle={planner.toggleDawarichTrail}
|
||||
/>
|
||||
)}
|
||||
<MPlacesFilterPill onOpen={() => shell.openSheet('placesFilter')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import { MapTogglePill } from '../../../../components/Map/MapTogglePill'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
|
||||
/**
|
||||
* The phone map's way into the places filter: a round control in the right-hand
|
||||
* stack, lit and badged with how many filters are narrowing the markers. The sheet
|
||||
* it opens is the shell's 'placesFilter' sheet, so the button announces a dialog
|
||||
* rather than a pressed state.
|
||||
*/
|
||||
export function MPlacesFilterPill({ onOpen }: { onOpen: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const filter = useTripStore(s => s.placesFilter)
|
||||
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
|
||||
const ratingFilter = useTripStore(s => s.placesRatingFilter)
|
||||
const count = countActivePlacesFilters({ filter, categoryFilters, ratingFilter })
|
||||
const label = count > 0 ? `${t('places.filters')} (${count})` : t('places.filters')
|
||||
return (
|
||||
<MapTogglePill
|
||||
active={count > 0}
|
||||
onToggle={onOpen}
|
||||
label={label}
|
||||
badge={count}
|
||||
testId="places-filter-pill"
|
||||
opensDialog
|
||||
icon={<SlidersHorizontal size={17} strokeWidth={2} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Bookmark, Check, CheckCheck, CheckCircle2, Download, ListChecks, Loader2, MapPin, Plus,
|
||||
Bookmark, CheckCheck, CheckCircle2, Download, ListChecks, Loader2, Plus,
|
||||
SlidersHorizontal, Tag, Trash2, X,
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
@@ -25,6 +25,8 @@ import MPlacesToursModeSwitch from './MPlacesToursModeSwitch'
|
||||
import MToursSelectionList from './MToursSelectionList'
|
||||
import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel'
|
||||
import { useTourPlaceIds } from '../../../../hooks/useTourPlaceIds'
|
||||
import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls'
|
||||
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
|
||||
|
||||
/**
|
||||
* Fullscreen places pool (mode === 'browse'): All/Unplanned/Tracks filter
|
||||
@@ -55,7 +57,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
const filter = useTripStore(s => s.placesFilter)
|
||||
const setFilter = useTripStore(s => s.setPlacesFilter)
|
||||
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
|
||||
const setCategoryFilters = useTripStore(s => s.setPlacesCategoryFilter)
|
||||
const ratingFilter = useTripStore(s => s.placesRatingFilter)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [catOpen, setCatOpen] = useState(false)
|
||||
@@ -73,9 +75,6 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
}, [shell.browseFromEdit, setFilter])
|
||||
|
||||
const hasTracks = useMemo(() => !toursEnabled && places.some(p => p.route_geometry), [places, toursEnabled])
|
||||
useEffect(() => {
|
||||
if (filter === 'tracks' && !hasTracks) setFilter('all')
|
||||
}, [filter, hasTracks, setFilter])
|
||||
|
||||
// A hotel is linked through its stay and a venue through its booking; neither is
|
||||
// ever dragged onto a day, and the pool used to call both unplanned (#2072).
|
||||
@@ -84,10 +83,13 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
[assignments, planner.tripAccommodations, planner.reservations],
|
||||
)
|
||||
const dayNumberByPlace = useMemo(() => firstPlannedDayNumbers(assignments, days), [assignments, days])
|
||||
const poolPlaces = useMemo(
|
||||
() => toursEnabled ? places.filter(place => !tourPlaceIds.has(place.id)) : places,
|
||||
[places, toursEnabled, tourPlaceIds],
|
||||
)
|
||||
const filtered = useMemo(() => {
|
||||
const pool = toursEnabled ? places.filter(p => !tourPlaceIds.has(p.id)) : places
|
||||
return filterPool(pool, { filter, categoryFilters, search, plannedIds })
|
||||
}, [places, filter, categoryFilters, search, plannedIds, toursEnabled, tourPlaceIds])
|
||||
return filterPool(poolPlaces, { filter, categoryFilters, ratingFilter, search, plannedIds })
|
||||
}, [poolPlaces, filter, categoryFilters, ratingFilter, search, plannedIds])
|
||||
|
||||
// A bulk delete (or a remote edit) can remove selected places — drop the
|
||||
// stale ids so the toolbar count stays honest.
|
||||
@@ -138,13 +140,6 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
else setSelectedIds(new Set(selectablePlaces.map(p => p.id)))
|
||||
}
|
||||
|
||||
const toggleCategory = (catId: string) => {
|
||||
const next = new Set(categoryFilters)
|
||||
if (next.has(catId)) next.delete(catId)
|
||||
else next.add(catId)
|
||||
setCategoryFilters(next)
|
||||
}
|
||||
|
||||
const openAddPlace = () => {
|
||||
planner.setEditingPlace(null)
|
||||
planner.setEditingAssignmentId(null)
|
||||
@@ -165,12 +160,11 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
}
|
||||
|
||||
const selectedTours = [...selectedIds].filter(planner.isTourPlace)
|
||||
|
||||
const hasUncategorized = places.some(p => p.category_id == null)
|
||||
const panelFilterCount = countActivePlacesFilters({ filter: 'all', categoryFilters, ratingFilter })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
|
||||
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
|
||||
{toursEnabled && (
|
||||
<MPlacesToursModeSwitch active={toursMode} onChange={setToursMode} />
|
||||
)}
|
||||
@@ -235,13 +229,13 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
type="button"
|
||||
onClick={() => setCatOpen(v => !v)}
|
||||
aria-expanded={catOpen}
|
||||
aria-label={t('places.allCategories')}
|
||||
aria-label={t('places.filters')}
|
||||
className="relative flex w-[42px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted"
|
||||
>
|
||||
<SlidersHorizontal size={15} strokeWidth={2} />
|
||||
{categoryFilters.size > 0 && (
|
||||
{panelFilterCount > 0 && (
|
||||
<span className="absolute -right-[3px] -top-[3px] box-border flex h-4 min-w-[16px] items-center justify-center rounded-full bg-m-act px-1 font-geist text-[0.5625rem] font-bold text-m-actfg">
|
||||
{categoryFilters.size}
|
||||
{panelFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -324,31 +318,13 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Category filter panel ── */}
|
||||
{/* ── Rating + category filter panel ── */}
|
||||
{catOpen && (
|
||||
<div className="mt-[6px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)]">
|
||||
{categories.map(c => {
|
||||
const CatIcon = getCategoryIcon(c.icon)
|
||||
return (
|
||||
<CategoryFilterRow
|
||||
key={c.id}
|
||||
checked={categoryFilters.has(String(c.id))}
|
||||
onToggle={() => toggleCategory(String(c.id))}
|
||||
label={c.name}
|
||||
>
|
||||
<CatIcon size={14} strokeWidth={2} className="flex-none" style={{ color: c.color || 'var(--m-muted)' }} />
|
||||
</CategoryFilterRow>
|
||||
)
|
||||
})}
|
||||
{hasUncategorized && (
|
||||
<CategoryFilterRow
|
||||
checked={categoryFilters.has('uncategorized')}
|
||||
onToggle={() => toggleCategory('uncategorized')}
|
||||
label={t('places.noCategory')}
|
||||
>
|
||||
<MapPin size={14} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
</CategoryFilterRow>
|
||||
)}
|
||||
<div className="border-b border-[color:var(--m-rowbr)] px-[13px] py-[10px]">
|
||||
<MRatingFloorChips onPick={() => setSelectedIds(new Set())} />
|
||||
</div>
|
||||
<MCategoryFilterList categories={categories} places={poolPlaces} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -363,7 +339,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
|
||||
{/* ── Place list ── */}
|
||||
{filtered.length === 0 ? (
|
||||
filter === 'unplanned' && !search && categoryFilters.size === 0 ? (
|
||||
filter === 'unplanned' && !search && categoryFilters.size === 0 && ratingFilter === 'all' ? (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-8 py-10 text-center">
|
||||
<MDancingTrek scene="idle" mood="happy" className="mb-2" />
|
||||
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('places.allPlanned')}</p>
|
||||
@@ -510,37 +486,3 @@ function BulkBtn({ label, onClick, disabled = false, children }: {
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** 17px (panel) / 19px (row) square checkbox in the demo's act-fill style. */
|
||||
function SquareCheck({ checked, big = false }: { checked: boolean; big?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`flex flex-none items-center justify-center border-[1.5px] ${
|
||||
big ? 'h-[19px] w-[19px] rounded-[6px]' : 'h-[17px] w-[17px] rounded-[5px]'
|
||||
} ${checked ? 'border-[color:var(--m-act)] bg-m-act text-m-actfg' : 'border-[color:var(--m-trackoff)] text-transparent'}`}
|
||||
>
|
||||
<Check size={big ? 12 : 11} strokeWidth={3} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryFilterRow({ checked, onToggle, label, children }: {
|
||||
checked: boolean
|
||||
onToggle: () => void
|
||||
label: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
className="flex w-full items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left last:border-b-0"
|
||||
>
|
||||
<SquareCheck checked={checked} />
|
||||
{children}
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-medium text-m-ink">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Check, MapPin, Star } from 'lucide-react'
|
||||
import MChip from '../../../components/MChip'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
|
||||
import { RATING_FLOORS, UNCATEGORIZED } from '../../../../utils/placesFilter'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Category, Place } from '../../../../types'
|
||||
|
||||
/**
|
||||
* The phone's places filter controls, shared by the places browser's filter panel
|
||||
* and the map's filter sheet. Both read and write the trip store, so whichever one
|
||||
* the user touches, the list and the map markers follow (#1541).
|
||||
*/
|
||||
|
||||
/** 17px (panel) / 19px (row) square checkbox in the demo's act-fill style. */
|
||||
export function SquareCheck({ checked, big = false }: { checked: boolean; big?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`flex flex-none items-center justify-center border-[1.5px] ${
|
||||
big ? 'h-[19px] w-[19px] rounded-[6px]' : 'h-[17px] w-[17px] rounded-[5px]'
|
||||
} ${checked ? 'border-[color:var(--m-act)] bg-m-act text-m-actfg' : 'border-[color:var(--m-trackoff)] text-transparent'}`}
|
||||
>
|
||||
<Check size={big ? 12 : 11} strokeWidth={3} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryFilterRow({ checked, onToggle, label, children }: {
|
||||
checked: boolean
|
||||
onToggle: () => void
|
||||
label: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
className="flex min-h-[44px] w-full items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left last:border-b-0"
|
||||
>
|
||||
<SquareCheck checked={checked} />
|
||||
{children}
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-medium text-m-ink">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The trip's categories as a multi-select, plus "no category" while a place has none. */
|
||||
export function MCategoryFilterList({ categories, places }: { categories: Category[]; places: Place[] }) {
|
||||
const { t } = useTranslation()
|
||||
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
|
||||
const setCategoryFilters = useTripStore(s => s.setPlacesCategoryFilter)
|
||||
const toggle = (catId: string) => {
|
||||
const next = new Set(categoryFilters)
|
||||
if (next.has(catId)) next.delete(catId)
|
||||
else next.add(catId)
|
||||
setCategoryFilters(next)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{categories.map(c => {
|
||||
const CatIcon = getCategoryIcon(c.icon)
|
||||
return (
|
||||
<CategoryFilterRow
|
||||
key={c.id}
|
||||
checked={categoryFilters.has(String(c.id))}
|
||||
onToggle={() => toggle(String(c.id))}
|
||||
label={c.name}
|
||||
>
|
||||
<CatIcon size={14} strokeWidth={2} className="flex-none" style={{ color: c.color || 'var(--m-muted)' }} />
|
||||
</CategoryFilterRow>
|
||||
)
|
||||
})}
|
||||
{places.some(p => p.category_id == null) && (
|
||||
<CategoryFilterRow
|
||||
checked={categoryFilters.has(UNCATEGORIZED)}
|
||||
onToggle={() => toggle(UNCATEGORIZED)}
|
||||
label={t('places.noCategory')}
|
||||
>
|
||||
<MapPin size={14} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
</CategoryFilterRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimum of stars, the same floors as the desktop list and the collections bar
|
||||
* (#1435). `onPick` runs after a floor is chosen: the places browser drops its
|
||||
* selection then, as it does for a new pool.
|
||||
*/
|
||||
export function MRatingFloorChips({ onPick }: { onPick?: () => void } = {}) {
|
||||
const { t } = useTranslation()
|
||||
const ratingFilter = useTripStore(s => s.placesRatingFilter)
|
||||
const setRatingFilter = useTripStore(s => s.setPlacesRatingFilter)
|
||||
return (
|
||||
<div role="group" aria-label={t('places.filterByRating')} className="flex flex-wrap gap-[6px]">
|
||||
{RATING_FLOORS.map(floor => (
|
||||
<MChip key={String(floor)} size="tap" pressable active={ratingFilter === floor} onClick={() => { setRatingFilter(floor); onPick?.() }}>
|
||||
{floor === 'all' ? t('common.all') : (
|
||||
<>
|
||||
<Star size={12} strokeWidth={2.2} fill="currentColor" className="flex-none" />
|
||||
{`${floor}+`}
|
||||
</>
|
||||
)}
|
||||
</MChip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { RotateCcw, X } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import MChip from '../../../components/MChip'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Category, Place } from '../../../../types'
|
||||
import { MCategoryFilterList, MRatingFloorChips } from './MPlacesFilterControls'
|
||||
|
||||
interface MPlacesFilterSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
toursEnabled: boolean
|
||||
}
|
||||
|
||||
/** Uppercase eyebrow over each group of the sheet. */
|
||||
function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="mb-2 font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The places filter, opened from the phone map: which pool to show, a minimum
|
||||
* rating and the trip's categories, with one button that lifts them all. Every
|
||||
* choice lands in the trip store at once, so the markers behind the sheet and the
|
||||
* places list change with it (#1541). Routed as shell sheet 'placesFilter'.
|
||||
*/
|
||||
export default function MPlacesFilterSheet({ open, onClose, places, categories, toursEnabled }: MPlacesFilterSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const filter = useTripStore(s => s.placesFilter)
|
||||
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
|
||||
const ratingFilter = useTripStore(s => s.placesRatingFilter)
|
||||
const setFilter = useTripStore(s => s.setPlacesFilter)
|
||||
const resetPlacesFilters = useTripStore(s => s.resetPlacesFilters)
|
||||
const activeCount = countActivePlacesFilters({ filter, categoryFilters, ratingFilter })
|
||||
|
||||
const pools = [
|
||||
{ id: 'all', label: t('places.all') },
|
||||
{ id: 'unplanned', label: t('places.unplanned') },
|
||||
{ id: 'planned', label: t('places.planned') },
|
||||
]
|
||||
if (!toursEnabled && places.some(p => p.route_geometry)) pools.push({ id: 'tracks', label: t('places.filterTracks') })
|
||||
const hasCategoryChoice = categories.length > 0 || places.some(p => p.category_id == null)
|
||||
|
||||
return (
|
||||
// Opaque: the sheet opens over the map, and the default bar glass let the map's
|
||||
// place names show through the chips and category rows (seen at 390px wide).
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" material="opaque" ariaLabel={t('places.filters')}>
|
||||
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[11px] pt-4">
|
||||
<div className="min-w-0 flex-1 text-[1.03125rem] font-bold text-m-ink">{t('places.filters')}</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.close')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-[14px]">
|
||||
<section>
|
||||
<SectionTitle>{t('places.filterShow')}</SectionTitle>
|
||||
<div role="group" aria-label={t('places.filterShow')} className="flex flex-wrap gap-[6px]">
|
||||
{pools.map(pool => (
|
||||
<MChip key={pool.id} size="tap" pressable active={filter === pool.id} onClick={() => setFilter(pool.id)}>
|
||||
{pool.label}
|
||||
</MChip>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-[18px]">
|
||||
<SectionTitle>{t('places.filterByRating')}</SectionTitle>
|
||||
<MRatingFloorChips />
|
||||
</section>
|
||||
|
||||
{hasCategoryChoice && (
|
||||
<section className="mt-[18px]">
|
||||
<SectionTitle>{t('categories.title')}</SectionTitle>
|
||||
<div className="overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
|
||||
<MCategoryFilterList categories={categories} places={places} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none border-t border-[color:var(--m-rowbr)] px-[18px] py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetPlacesFilters}
|
||||
disabled={activeCount === 0}
|
||||
className="flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-[0.8125rem] font-semibold text-m-ink disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw size={14} strokeWidth={2.2} />
|
||||
{t('common.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
filterPool, firstPlannedDayNumbers, matchesCategoryFilter, matchesSearch,
|
||||
filterPool, firstPlannedDayNumbers, matchesSearch,
|
||||
plannedPlaceIds, poolCounts,
|
||||
} from './placesBrowserModel'
|
||||
import { matchesCategoryFilter } from '../../../../utils/placesFilter'
|
||||
import type { AssignmentsMap, Day, Place } from '../../../../types'
|
||||
|
||||
const mkPlace = (id: number, over: Partial<Place> = {}): Place =>
|
||||
@@ -144,6 +145,18 @@ describe('placesBrowserModel — tracks pool', () => {
|
||||
expect(out.map(p => p.id)).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-015: the rating floor from the store narrows the pool like it narrows the map', () => {
|
||||
const rated = [
|
||||
mkPlace(1, { name: 'Top', rating_avg: 4.6 }),
|
||||
mkPlace(2, { name: 'Fine', rating_avg: 3.2 }),
|
||||
mkPlace(3, { name: 'Unrated' }),
|
||||
]
|
||||
const out = filterPool(rated, { filter: 'all', categoryFilters: new Set(), ratingFilter: 3, search: '', plannedIds })
|
||||
expect(out.map(p => p.id)).toEqual([1, 2])
|
||||
const all = filterPool(rated, { filter: 'all', categoryFilters: new Set(), search: '', plannedIds })
|
||||
expect(all.map(p => p.id)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-013: poolCounts counts tracks on the category+search base set', () => {
|
||||
const c = poolCounts(places, new Set(['4']), '', plannedIds)
|
||||
expect(c.all).toBe(2)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { AssignmentsMap, Day, Place } from '../../../../types'
|
||||
import { placeMatchesSearch } from '../../../../utils/placeSearch'
|
||||
import {
|
||||
matchesCategoryFilter, matchesPlacesFilter, type PlacesRatingFloor,
|
||||
} from '../../../../utils/placesFilter'
|
||||
import {
|
||||
plannedPlaceIds as sharedPlannedPlaceIds,
|
||||
type PlannedAccommodation, type PlannedReservation,
|
||||
@@ -8,8 +11,10 @@ import {
|
||||
/**
|
||||
* Pure filter model of the mobile places browser. Mirrors the desktop
|
||||
* sidebar's semantics exactly (usePlacesSidebar + the map's mapPlaces memo):
|
||||
* the pool filter and category set come from the trip store, so the list here
|
||||
* and the map markers always agree (#1541).
|
||||
* the pool filter, the category set and the rating floor come from the trip
|
||||
* store and go through the same matcher (utils/placesFilter) as the map markers
|
||||
* (#1541); the map's own exceptions (open day, collapsed days) are described
|
||||
* there.
|
||||
*/
|
||||
|
||||
export function plannedPlaceIds(
|
||||
@@ -38,12 +43,6 @@ export function firstPlannedDayNumbers(assignments: AssignmentsMap, days: Day[])
|
||||
return result
|
||||
}
|
||||
|
||||
export function matchesCategoryFilter(place: Place, categoryFilters: Set<string>): boolean {
|
||||
if (categoryFilters.size === 0) return true
|
||||
if (place.category_id == null) return categoryFilters.has('uncategorized')
|
||||
return categoryFilters.has(String(place.category_id))
|
||||
}
|
||||
|
||||
export function matchesSearch(place: Place, search: string): boolean {
|
||||
return placeMatchesSearch(place, search)
|
||||
}
|
||||
@@ -51,17 +50,14 @@ export function matchesSearch(place: Place, search: string): boolean {
|
||||
interface PoolFilterArgs {
|
||||
filter: string
|
||||
categoryFilters: Set<string>
|
||||
/** Minimum average stars; omitted means every rating. */
|
||||
ratingFilter?: PlacesRatingFloor
|
||||
search: string
|
||||
plannedIds: Set<number>
|
||||
}
|
||||
|
||||
export function filterPool(places: Place[], { filter, categoryFilters, search, plannedIds }: PoolFilterArgs): Place[] {
|
||||
return places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'planned' && !plannedIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
return matchesCategoryFilter(p, categoryFilters) && matchesSearch(p, search)
|
||||
})
|
||||
export function filterPool(places: Place[], { filter, categoryFilters, ratingFilter = 'all', search, plannedIds }: PoolFilterArgs): Place[] {
|
||||
return places.filter(p => matchesPlacesFilter(p, { filter, categoryFilters, ratingFilter }, { plannedIds }) && matchesSearch(p, search))
|
||||
}
|
||||
|
||||
/** Chip counts run on the category+search base set, like the desktop tabs. */
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-touch-drag={editing ? '' : undefined}
|
||||
className="absolute left-4 right-4 overflow-y-auto overscroll-contain rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-2 pt-1 backdrop-blur-[24px] backdrop-saturate-[1.6] bottom-[calc(env(safe-area-inset-bottom,0px)+90px)]"
|
||||
className="absolute left-4 right-4 overflow-x-hidden overflow-y-auto overscroll-contain rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-2 pt-1 backdrop-blur-[24px] backdrop-saturate-[1.6] bottom-[calc(env(safe-area-inset-bottom,0px)+90px)]"
|
||||
style={{ top: `calc(var(--m-safe-top, 12px) + ${editing ? 140 : tl.upNext ? 216 : 102}px)` }}
|
||||
>
|
||||
{planner.toursEnabled && tl.rows.some(row => row.kind === 'place' && row.invalidTour) && (
|
||||
|
||||
@@ -551,7 +551,7 @@ export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
|
||||
const { time: noteTime, detail } = splitNoteTime(note.time)
|
||||
const time = noteTime ? fmtTime(noteTime, chrome) : ''
|
||||
const [title, ...rest] = note.text.split('\n')
|
||||
const titleExtra = rest.join(' ').trim()
|
||||
const titleExtra = rest.join('\n').trim()
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -561,10 +561,10 @@ export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
|
||||
// A link in the rendered body keeps its own tap; the rest of the row edits.
|
||||
onClick={chrome.editing ? (e => { if (!(e.target as HTMLElement).closest('a')) onEdit() }) : undefined}
|
||||
onKeyDown={chrome.editing ? (e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onEdit() } }) : undefined}
|
||||
className={`my-[2px] flex items-center gap-2.5 ${chrome.editing ? 'cursor-pointer' : ''} ${dragClass(drag)}`}
|
||||
className={`my-[2px] flex items-start gap-2.5 ${chrome.editing ? 'cursor-pointer' : ''} ${dragClass(drag)}`}
|
||||
>
|
||||
{!chrome.editing && (
|
||||
<AvatarRing style={note.color ? { background: skin.iconBackground, borderColor: skin.border } : undefined}>
|
||||
<AvatarRing className="mt-[2px]" style={note.color ? { background: skin.iconBackground, borderColor: skin.border } : undefined}>
|
||||
<Icon size={14} strokeWidth={2} style={{ color: skin.iconColor }} />
|
||||
</AvatarRing>
|
||||
)}
|
||||
@@ -572,20 +572,20 @@ export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
|
||||
className="min-w-0 flex-1 rounded-[13px] border px-[11px] py-[7px]"
|
||||
style={{ borderColor: skin.border, background: note.color ? skin.background : 'var(--m-ic)' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex min-w-0 items-start gap-1.5">
|
||||
{time && <span className={TIME_CHIP}>{time}</span>}
|
||||
<span className="min-w-0 text-[0.875rem] font-semibold">{title}</span>
|
||||
<span className="min-w-0 text-[0.875rem] [overflow-wrap:anywhere] font-semibold">{title}</span>
|
||||
</div>
|
||||
{titleExtra && (
|
||||
<div className="mt-px font-geist text-[0.71875rem] leading-[1.4] text-m-muted">{titleExtra}</div>
|
||||
<MarkdownText className="mt-px font-geist text-[0.71875rem] leading-[1.4] text-m-muted">{titleExtra}</MarkdownText>
|
||||
)}
|
||||
{detail && (
|
||||
// Rendered, not raw: a note written with the formatting bar would
|
||||
// otherwise read as `**asterisks**` on the phone.
|
||||
<MarkdownText className="mt-px font-geist text-[0.71875rem] leading-[1.45] text-m-muted [overflow-wrap:anywhere]">{detail}</MarkdownText>
|
||||
<MarkdownText className="mt-px font-geist text-[0.71875rem] leading-[1.45] text-m-muted">{detail}</MarkdownText>
|
||||
)}
|
||||
</div>
|
||||
{chrome.editing && <span className="flex flex-none items-center gap-1.5">{reorder}</span>}
|
||||
{chrome.editing && <span className="mt-[2px] flex flex-none items-center gap-1.5">{reorder}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
{searchBar}
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`absolute inset-0 overflow-y-auto overscroll-contain px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] ${
|
||||
className={`absolute inset-0 overflow-x-hidden overflow-y-auto overscroll-contain px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] ${
|
||||
searchBar ? 'pt-[calc(var(--m-safe-top,12px)+150px)]' : 'pt-[calc(var(--m-safe-top,12px)+102px)]'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -483,7 +483,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
{day.title || t('planner.dayN', { n: (day.day_number ?? planner.days.indexOf(day) + 1) || '?' })}
|
||||
</div>
|
||||
)}
|
||||
<div className="whitespace-pre-wrap font-geist text-[0.75rem] leading-[1.5] text-m-muted">{assignment.notes}</div>
|
||||
<div className="whitespace-pre-wrap [overflow-wrap:anywhere] font-geist text-[0.75rem] leading-[1.5] text-m-muted">{assignment.notes}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import MNoteSheet, { type MNoteSheetPayload } from './MNoteSheet'
|
||||
import MImportSheet from './MImportSheet'
|
||||
import MExportSheet from './MExportSheet'
|
||||
import MMehrSheet from './MMehrSheet'
|
||||
import MPlacesFilterSheet from '../places/MPlacesFilterSheet'
|
||||
import MRtStopSheet from '../roadtrip/MRtStopSheet'
|
||||
import MRtStaySheet from '../roadtrip/MRtStaySheet'
|
||||
import MRtKindSheet from '../roadtrip/MRtKindSheet'
|
||||
@@ -150,6 +151,13 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
onClose={shell.closeSheet}
|
||||
/>
|
||||
<MImportSheet planner={planner} open={sheet?.id === 'import'} onClose={shell.closeSheet} />
|
||||
<MPlacesFilterSheet
|
||||
open={sheet?.id === 'placesFilter'}
|
||||
onClose={shell.closeSheet}
|
||||
places={planner.places}
|
||||
categories={planner.categories}
|
||||
toursEnabled={planner.toursEnabled}
|
||||
/>
|
||||
|
||||
{/* ── Planner-flag editors (also serve ?create= and the import review) ── */}
|
||||
<MPlaceEditSheet planner={planner} onOpenExpense={openBookingExpense} />
|
||||
|
||||
@@ -360,7 +360,7 @@ function NoteCardRow({ note, color, canEdit, onTap, onTogglePin, onDelete, t }:
|
||||
|
||||
<button type="button" onClick={onTap} className="block w-full px-3 pb-3 pt-[9px] text-left">
|
||||
{note.content && (
|
||||
<div className="line-clamp-3 font-geist text-[0.75rem] leading-[1.5] text-m-muted [&_a]:underline [&_ol]:list-decimal [&_ol]:pl-4 [&_p]:mb-1 [&_strong]:font-bold [&_ul]:list-disc [&_ul]:pl-4">
|
||||
<div className="line-clamp-3 [overflow-wrap:anywhere] font-geist text-[0.75rem] leading-[1.5] text-m-muted [&_a]:underline [&_ol]:list-decimal [&_ol]:pl-4 [&_p]:mb-1 [&_strong]:font-bold [&_ul]:list-disc [&_ul]:pl-4">
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{note.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
@@ -640,7 +640,7 @@ function NoteViewSheet({ open, note, onClose, t }: {
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-4 pt-1">
|
||||
{snapshot?.content && (
|
||||
<div className="font-geist text-[0.8125rem] leading-[1.6] text-m-ink [&_a]:underline [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-2 [&_strong]:font-bold [&_ul]:list-disc [&_ul]:pl-5">
|
||||
<div className="font-geist text-[0.8125rem] leading-[1.6] text-m-ink [overflow-wrap:anywhere] [&_a]:underline [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-2 [&_strong]:font-bold [&_ul]:list-disc [&_ul]:pl-5">
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{snapshot.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { usePluginStore } from '../../../../store/pluginStore'
|
||||
export function TabScroller({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
|
||||
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-TP-HOOK-001 to FE-TP-HOOK-175
|
||||
// FE-TP-HOOK-001 to FE-TP-HOOK-177
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
@@ -646,6 +646,47 @@ describe('useTripPlanner — map derivations', () => {
|
||||
expect(result.current.mapPlaces.map(p => p.id)).toEqual([2])
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-177: losing the last track moves the "Tracks" filter back to all', async () => {
|
||||
// The phone map has no places list mounted, so this hook is what keeps the
|
||||
// filter from pointing at a pool no control offers any more.
|
||||
seedTrip({
|
||||
places: [geo(1), geo(2, { route_geometry: '[[1,2]]' })],
|
||||
placesFilter: 'tracks',
|
||||
})
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
expect(useTripStore.getState().placesFilter).toBe('tracks')
|
||||
|
||||
act(() => { useTripStore.setState({ places: [geo(1)] }) })
|
||||
|
||||
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'))
|
||||
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
|
||||
})
|
||||
|
||||
it('Tours enabled clears a stale Tracks filter even while track geometry remains', async () => {
|
||||
const tour: TourListItem = {
|
||||
place_id: 7, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
|
||||
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
|
||||
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true } as never], loaded: true })
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({ addons: [{ id: 'tours' }] })
|
||||
vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [tour] })
|
||||
seedTrip({
|
||||
places: [
|
||||
buildPlace({ id: 7, lat: 1, lng: 2, tour_place_id: 7, route_geometry: '[[1,2],[3,4]]' }),
|
||||
buildPlace({ id: 8, lat: 3, lng: 4, route_geometry: '[[5,6],[7,8]]' }),
|
||||
],
|
||||
placesFilter: 'tracks',
|
||||
})
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'))
|
||||
|
||||
expect(result.current.enabledAddons.tours).toBe(true)
|
||||
expect(result.current.mapPlaces.map(place => place.id)).toEqual([7, 8])
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-027: the category filter honours the uncategorized bucket', async () => {
|
||||
seedTrip({
|
||||
places: [geo(1, { category_id: 3 }), geo(2, { category_id: null })],
|
||||
@@ -660,6 +701,20 @@ describe('useTripPlanner — map derivations', () => {
|
||||
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-176: the rating floor the lists set also thins the markers', async () => {
|
||||
seedTrip({
|
||||
places: [geo(1, { rating_avg: 4.5 }), geo(2, { rating_avg: 3 }), geo(3, { rating_avg: null })],
|
||||
placesRatingFilter: 4,
|
||||
})
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1])
|
||||
|
||||
act(() => { useTripStore.getState().setPlacesRatingFilter('all') })
|
||||
expect(result.current.mapPlaces.map(p => p.id)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-028: the unplanned filter drops places that sit on a day', async () => {
|
||||
const planned = geo(1)
|
||||
seedTrip({
|
||||
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
} from '../../utils/connectionsVisibility'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces'
|
||||
import { matchesPlacesFilter } from '../../utils/placesFilter'
|
||||
import { pendingStayPlaceIds } from '../../utils/pendingStays'
|
||||
import { useDayDelete } from './useDayDelete'
|
||||
import { useDayAdd } from './useDayAdd'
|
||||
@@ -733,6 +734,7 @@ export function useTripPlanner() {
|
||||
// switches can't desync the marker set from the filter UI (#1541).
|
||||
const placesFilter = useTripStore((s) => s.placesFilter)
|
||||
const placesCategoryFilter = useTripStore((s) => s.placesCategoryFilter)
|
||||
const placesRatingFilter = useTripStore((s) => s.placesRatingFilter)
|
||||
|
||||
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
|
||||
|
||||
@@ -771,28 +773,33 @@ export function useTripPlanner() {
|
||||
|
||||
const compactIds = compactUnplanned ? plannedPlaceIds({ assignments, accommodations: tripAccommodations, reservations }) : null
|
||||
const pendingIds = pendingStayPlaceIds(tripAccommodations, reservations)
|
||||
const filterState = { filter: placesFilter, categoryFilters: placesCategoryFilter, ratingFilter: placesRatingFilter }
|
||||
return places.filter(p => {
|
||||
if (!p.lat || !p.lng) return false
|
||||
if (placesFilter === 'tracks' && !p.route_geometry) return false
|
||||
if (placesCategoryFilter.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!placesCategoryFilter.has('uncategorized')) return false
|
||||
} else if (!placesCategoryFilter.has(String(p.category_id))) return false
|
||||
}
|
||||
// Collapsed-day declutter hides a day's stops on every filter EXCEPT 'planned':
|
||||
// there the user asked to see the whole plan on the map, so a collapsed day
|
||||
// must not drop its planned places.
|
||||
if (placesFilter !== 'planned' && hiddenPlaceIds.has(p.id)) return false
|
||||
if (placesFilter === 'unplanned' && plannedIds && plannedIds.has(p.id)) return false
|
||||
if (placesFilter === 'planned' && plannedIds && !plannedIds.has(p.id)) return false
|
||||
return true
|
||||
// Pool, categories and rating floor: the very matcher the lists use (#1541).
|
||||
return matchesPlacesFilter(p, filterState, { plannedIds })
|
||||
}).map(p => {
|
||||
// How the map tells a place apart (#2024, #2281); untouched places keep their identity.
|
||||
const compact = !!compactIds && !compactIds.has(p.id)
|
||||
const pending = pendingIds.has(p.id)
|
||||
return compact || pending ? { ...p, _compact: compact, _pending: pending } : p
|
||||
})
|
||||
}, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
|
||||
}, [places, placesCategoryFilter, placesFilter, placesRatingFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations, compactUnplanned])
|
||||
|
||||
// The "Tracks" pool is only offered while a place carries a track. When the last
|
||||
// one goes (deleted here or by a collaborator), fall back to "all" — here, because
|
||||
// this hook is mounted whatever is on screen: the lists that used to do it are not
|
||||
// mounted while the phone map is in front, and the map would sit empty under a
|
||||
// "Tracks" filter no control offers any more.
|
||||
const setPlacesFilter = useTripStore((s) => s.setPlacesFilter)
|
||||
const hasTracks = useMemo(() => !toursEnabled && places.some(p => p.route_geometry), [places, toursEnabled])
|
||||
useEffect(() => {
|
||||
if (placesFilter === 'tracks' && !hasTracks) setPlacesFilter('all')
|
||||
}, [placesFilter, hasTracks, setPlacesFilter])
|
||||
|
||||
const { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
|
||||
// Road trip mode already draws the whole trip its own way, so the overview stands
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { TodoSlice } from './slices/todoSlice'
|
||||
import type { BudgetSlice } from './slices/budgetSlice'
|
||||
import type { ReservationsSlice } from './slices/reservationsSlice'
|
||||
import type { FilesSlice } from './slices/filesSlice'
|
||||
import type { PlacesRatingFloor } from '../utils/placesFilter'
|
||||
|
||||
export interface TripStoreState
|
||||
extends PlacesSlice,
|
||||
@@ -65,12 +66,18 @@ export interface TripStoreState
|
||||
// unmounts and remounts (#1541).
|
||||
placesFilter: string
|
||||
placesCategoryFilter: Set<string>
|
||||
// Minimum average stars. Shared for the same reason: the map markers and the
|
||||
// phone's list follow the floor the desktop list sets, and back (#1435).
|
||||
placesRatingFilter: PlacesRatingFloor
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
setSelectedDay: (dayId: number | null) => void
|
||||
setPlacesFilter: (filter: string) => void
|
||||
setPlacesCategoryFilter: (categoryIds: Set<string>) => void
|
||||
setPlacesRatingFilter: (floor: PlacesRatingFloor) => void
|
||||
/** Lifts the pool, category and rating filters at once. */
|
||||
resetPlacesFilters: () => void
|
||||
handleRemoteEvent: (event: WebSocketEvent) => void
|
||||
resetTrip: () => void
|
||||
loadTrip: (tripId: number | string) => Promise<void>
|
||||
@@ -97,12 +104,15 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
selectedDayId: null,
|
||||
placesFilter: 'all',
|
||||
placesCategoryFilter: new Set<string>(),
|
||||
placesRatingFilter: 'all',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
|
||||
setPlacesFilter: (filter: string) => set({ placesFilter: filter }),
|
||||
setPlacesCategoryFilter: (categoryIds: Set<string>) => set({ placesCategoryFilter: categoryIds }),
|
||||
setPlacesRatingFilter: (floor: PlacesRatingFloor) => set({ placesRatingFilter: floor }),
|
||||
resetPlacesFilters: () => set({ placesFilter: 'all', placesCategoryFilter: new Set<string>(), placesRatingFilter: 'all' }),
|
||||
|
||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
|
||||
|
||||
@@ -123,6 +133,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
selectedDayId: null,
|
||||
placesFilter: 'all',
|
||||
placesCategoryFilter: new Set<string>(),
|
||||
placesRatingFilter: 'all',
|
||||
error: null,
|
||||
}),
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildPlace } from '../../tests/helpers/factories'
|
||||
import {
|
||||
RATING_FLOORS, countActivePlacesFilters, matchesCategoryFilter, matchesPlacesFilter,
|
||||
matchesPoolFilter, matchesRatingFloor,
|
||||
} from './placesFilter'
|
||||
|
||||
const NONE = new Set<string>()
|
||||
|
||||
describe('placesFilter', () => {
|
||||
it('FE-UTIL-PLFILTER-001: offers the same floors as the collections bar', () => {
|
||||
expect(RATING_FLOORS).toEqual(['all', 5, 4, 3, 2, 1])
|
||||
})
|
||||
|
||||
it('FE-UTIL-PLFILTER-002: an empty category set keeps everything; "uncategorized" stands for no category', () => {
|
||||
const tagged = buildPlace({ category_id: 4 })
|
||||
const bare = buildPlace({ category_id: null })
|
||||
expect(matchesCategoryFilter(tagged, NONE)).toBe(true)
|
||||
expect(matchesCategoryFilter(tagged, new Set(['4']))).toBe(true)
|
||||
expect(matchesCategoryFilter(tagged, new Set(['5']))).toBe(false)
|
||||
expect(matchesCategoryFilter(bare, new Set(['4']))).toBe(false)
|
||||
expect(matchesCategoryFilter(bare, new Set(['uncategorized']))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-UTIL-PLFILTER-003: the rating floor keeps places at or above it and drops the unrated', () => {
|
||||
expect(matchesRatingFloor(buildPlace({ rating_avg: null }), 'all')).toBe(true)
|
||||
expect(matchesRatingFloor(buildPlace({ rating_avg: null }), 1)).toBe(false)
|
||||
expect(matchesRatingFloor(buildPlace({ rating_avg: 4 }), 4)).toBe(true)
|
||||
expect(matchesRatingFloor(buildPlace({ rating_avg: 3.9 }), 4)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-PLFILTER-004: the pool filter reads the planned sets, and a null set filters nothing', () => {
|
||||
const planned = buildPlace({ id: 1 })
|
||||
const loose = buildPlace({ id: 2, route_geometry: '[[1,2],[3,4]]' })
|
||||
const ids = new Set([1])
|
||||
expect(matchesPoolFilter(planned, 'all', { plannedIds: ids })).toBe(true)
|
||||
expect(matchesPoolFilter(planned, 'unplanned', { plannedIds: ids })).toBe(false)
|
||||
expect(matchesPoolFilter(loose, 'unplanned', { plannedIds: ids })).toBe(true)
|
||||
expect(matchesPoolFilter(planned, 'planned', { plannedIds: ids })).toBe(true)
|
||||
expect(matchesPoolFilter(loose, 'planned', { plannedIds: ids })).toBe(false)
|
||||
expect(matchesPoolFilter(planned, 'tracks', { plannedIds: ids })).toBe(false)
|
||||
expect(matchesPoolFilter(loose, 'tracks', { plannedIds: ids })).toBe(true)
|
||||
expect(matchesPoolFilter(planned, 'unplanned', { plannedIds: null })).toBe(true)
|
||||
expect(matchesPoolFilter(loose, 'planned', { plannedIds: null })).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-UTIL-PLFILTER-005: "planned" can be narrowed to the open day while "unplanned" stays trip-wide', () => {
|
||||
const elsewhere = buildPlace({ id: 1 })
|
||||
const sets = { plannedIds: new Set([1, 2]), plannedFilterIds: new Set([2]) }
|
||||
expect(matchesPoolFilter(elsewhere, 'planned', sets)).toBe(false)
|
||||
expect(matchesPoolFilter(elsewhere, 'unplanned', sets)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-PLFILTER-006: the combined matcher needs pool, category and rating to agree', () => {
|
||||
const place = buildPlace({ id: 1, category_id: 4, rating_avg: 4.5 })
|
||||
const sets = { plannedIds: new Set<number>() }
|
||||
const state = { filter: 'unplanned', categoryFilters: new Set(['4']), ratingFilter: 4 as const }
|
||||
expect(matchesPlacesFilter(place, state, sets)).toBe(true)
|
||||
expect(matchesPlacesFilter(place, { ...state, ratingFilter: 5 }, sets)).toBe(false)
|
||||
expect(matchesPlacesFilter(place, { ...state, categoryFilters: new Set(['9']) }, sets)).toBe(false)
|
||||
expect(matchesPlacesFilter(place, state, { plannedIds: new Set([1]) })).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-PLFILTER-007: counts each filter that narrows, the category set once', () => {
|
||||
expect(countActivePlacesFilters({ filter: 'all', categoryFilters: NONE, ratingFilter: 'all' })).toBe(0)
|
||||
expect(countActivePlacesFilters({ filter: 'planned', categoryFilters: NONE, ratingFilter: 'all' })).toBe(1)
|
||||
expect(countActivePlacesFilters({ filter: 'all', categoryFilters: new Set(['1', '2']), ratingFilter: 'all' })).toBe(1)
|
||||
expect(countActivePlacesFilters({ filter: 'tracks', categoryFilters: new Set(['1']), ratingFilter: 3 })).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Place } from '../types'
|
||||
|
||||
/**
|
||||
* The trip's places filter, in one place: the pool (all / unplanned / planned /
|
||||
* tracks), the category set and the minimum rating. The desktop list, the phone
|
||||
* list and the map markers of both shells apply this same predicate to the same
|
||||
* values, held in the trip store (#1541). The map keeps two deliberate
|
||||
* differences of its own: with a day open, "planned" shows that day's plan only
|
||||
* (#2024), and the stops of collapsed days are left off the map (except under
|
||||
* "planned").
|
||||
*/
|
||||
|
||||
/** A minimum of average stars: 'all', or a floor that unrated places fall through. */
|
||||
export type PlacesRatingFloor = number | 'all'
|
||||
|
||||
/** The floors offered by every rating picker, the same as the collections bar (#1435). */
|
||||
export const RATING_FLOORS = ['all', 5, 4, 3, 2, 1] as const
|
||||
|
||||
/** The pseudo category id that stands for "no category". */
|
||||
export const UNCATEGORIZED = 'uncategorized'
|
||||
|
||||
export function matchesCategoryFilter(place: Place, categoryFilters: ReadonlySet<string>): boolean {
|
||||
if (categoryFilters.size === 0) return true
|
||||
if (place.category_id == null) return categoryFilters.has(UNCATEGORIZED)
|
||||
return categoryFilters.has(String(place.category_id))
|
||||
}
|
||||
|
||||
export function matchesRatingFloor(place: Place, floor: PlacesRatingFloor): boolean {
|
||||
if (floor === 'all') return true
|
||||
return place.rating_avg != null && place.rating_avg >= floor
|
||||
}
|
||||
|
||||
export interface PoolFilterSets {
|
||||
/** Every place planned anywhere in the trip: what 'unplanned' leaves out. */
|
||||
plannedIds: ReadonlySet<number> | null
|
||||
/**
|
||||
* What 'planned' keeps. Defaults to `plannedIds`; the planner narrows it to the
|
||||
* open day (#2024) while 'unplanned' stays trip-wide.
|
||||
*/
|
||||
plannedFilterIds?: ReadonlySet<number> | null
|
||||
}
|
||||
|
||||
/** A null set means "not needed for this filter" and filters nothing out. */
|
||||
export function matchesPoolFilter(place: Place, filter: string, { plannedIds, plannedFilterIds = plannedIds }: PoolFilterSets): boolean {
|
||||
if (filter === 'unplanned') return !plannedIds?.has(place.id)
|
||||
if (filter === 'planned') return !plannedFilterIds || plannedFilterIds.has(place.id)
|
||||
if (filter === 'tracks') return !!place.route_geometry
|
||||
return true
|
||||
}
|
||||
|
||||
export interface PlacesFilterState {
|
||||
filter: string
|
||||
categoryFilters: ReadonlySet<string>
|
||||
ratingFilter: PlacesRatingFloor
|
||||
}
|
||||
|
||||
/** Pool, categories and rating together — the whole filter the store holds. */
|
||||
export function matchesPlacesFilter(place: Place, state: PlacesFilterState, sets: PoolFilterSets): boolean {
|
||||
return matchesPoolFilter(place, state.filter, sets)
|
||||
&& matchesCategoryFilter(place, state.categoryFilters)
|
||||
&& matchesRatingFloor(place, state.ratingFilter)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of the three filters are narrowing the places (0 to 3): the pool, the
|
||||
* categories (however many are ticked) and the rating floor. Drives the badge on
|
||||
* the phone's filter buttons.
|
||||
*/
|
||||
export function countActivePlacesFilters({ filter, categoryFilters, ratingFilter }: PlacesFilterState): number {
|
||||
return (filter !== 'all' ? 1 : 0) + (categoryFilters.size > 0 ? 1 : 0) + (ratingFilter !== 'all' ? 1 : 0)
|
||||
}
|
||||
@@ -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-049
|
||||
//
|
||||
// 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<string>() })
|
||||
seedStore(useTripStore, { places: [], placesFilter: 'all', placesCategoryFilter: new Set<string>(), placesRatingFilter: 'all' })
|
||||
})
|
||||
|
||||
describe('MMapArea', () => {
|
||||
@@ -952,3 +952,41 @@ 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')
|
||||
// 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')
|
||||
})
|
||||
|
||||
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).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')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-049: a road trip keeps the places filter on its plan map, Dawarich or not', () => {
|
||||
// The stack used to stand down in road-trip mode unless Dawarich had a pill in
|
||||
// it; the plan map still shows the trip's places there, so its filter stays.
|
||||
renderArea({ trTab: 'plan' }, { roadtripActive: true, dawarichEnabled: false })
|
||||
expect(screen.getByTestId('places-filter-pill')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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'
|
||||
import type { CollectionListResponse } from '@trek/shared'
|
||||
import { toursApi } from '../../../../src/api/client'
|
||||
import type { CollectionListResponse, TourListItem } from '@trek/shared'
|
||||
import { useAddonStore } from '../../../../src/store/addonStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
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-036
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 1, name: 'Sights', color: '#123456', icon: 'landmark' },
|
||||
@@ -125,11 +126,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<TripPlanner>))
|
||||
expect(screen.queryByRole('button', { name: 'places.filterTracks' })).not.toBeInTheDocument()
|
||||
expect(useTripStore.getState().placesFilter).toBe('all')
|
||||
expect(screen.getByText('places.count:2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -153,7 +154,7 @@ describe('MPlacesBrowser', () => {
|
||||
|
||||
it('FE-MOB-PBROW-010: the category panel toggles the shared filter set and badges its size', () => {
|
||||
renderBrowser()
|
||||
const catBtn = screen.getByRole('button', { name: 'places.allCategories' })
|
||||
const catBtn = screen.getByRole('button', { name: 'places.filters' })
|
||||
expect(catBtn).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(catBtn)
|
||||
expect(catBtn).toHaveAttribute('aria-expanded', 'true')
|
||||
@@ -169,10 +170,63 @@ 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<TripPlanner>)
|
||||
renderBrowser(planner)
|
||||
const panelBtn = screen.getByRole('button', { name: 'places.filters' })
|
||||
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(within(floors).getByRole('button', { name: '4+' })).toHaveAttribute('aria-pressed', 'true')
|
||||
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-035: the panel badge counts kinds of filter, like the map badge', () => {
|
||||
renderBrowser()
|
||||
const panelBtn = screen.getByRole('button', { name: 'places.filters' })
|
||||
fireEvent.click(panelBtn)
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Food' }))
|
||||
// Two categories are one kind of filter.
|
||||
expect(panelBtn).toHaveTextContent('1')
|
||||
fireEvent.click(within(screen.getByRole('group', { name: 'Filter by rating' })).getByRole('button', { name: '4+' }))
|
||||
expect(panelBtn).toHaveTextContent('2')
|
||||
// The pool has its own chips in plain sight, so the panel badge leaves it out.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.unplanned' }))
|
||||
expect(panelBtn).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-036: a new rating floor starts a fresh selection, as a new pool does', () => {
|
||||
renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
fireEvent.click(row('Louvre'))
|
||||
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
|
||||
fireEvent.click(within(screen.getByRole('group', { name: 'Filter by rating' })).getByRole('button', { name: '1+' }))
|
||||
expect(screen.queryByText('places.selectionCount:1')).not.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<TripPlanner>))
|
||||
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('button', { name: 'places.filters' }))
|
||||
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,8 +235,8 @@ describe('MPlacesBrowser', () => {
|
||||
it('FE-MOB-PBROW-012: the uncategorized row is dropped when every place has a category', () => {
|
||||
const planner = makePlanner({ places: [LOUVRE, SEINE] } as Partial<TripPlanner>)
|
||||
renderBrowser(planner)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' }))
|
||||
expect(screen.queryByRole('checkbox', { name: 'places.noCategory' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
|
||||
expect(screen.queryByRole('checkbox', { name: 'No Category' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: 'Food' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -308,6 +362,34 @@ describe('MPlacesBrowser', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.deselectAll' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps Tour-backed Places out of the Places pool and Tours mode browse/attach only', async () => {
|
||||
const ridge: TourListItem = {
|
||||
place_id: TOUR.id, name: 'Ridge walk', tour_type: 'hike', distance: 4, elevation_gain: 100,
|
||||
elevation_loss: 80, duration: null, difficulty: null, wanderer_ref: null, match_confidence: 1,
|
||||
tour_group_id: null, max_hiking_difficulty: 2, planned: false, caution: false,
|
||||
}
|
||||
seedStore(useAddonStore, { addons: [{ id: 'collections', enabled: true }, { id: 'tours', enabled: true }] })
|
||||
vi.spyOn(toursApi, 'list').mockResolvedValue({ tours: [ridge] })
|
||||
const planner = makePlanner({
|
||||
places: [...PLACES, TOUR],
|
||||
tours: [ridge],
|
||||
isTourPlace: vi.fn((placeId: number) => placeId === TOUR.id),
|
||||
})
|
||||
const { shell } = renderBrowser(planner)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('places.count:3')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Ridge walk')).not.toBeInTheDocument()
|
||||
expect(toursApi.list).toHaveBeenCalledWith(planner.tripId)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /^Tours$/i }))
|
||||
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'mobileTrip.importPlaces' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.add' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.select' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /tours.addToDay|Add to day/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-020b: a failed bulk delete keeps the selection for a retry', async () => {
|
||||
const planner = makePlanner()
|
||||
;(planner.confirmDeletePlaces as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('nope'))
|
||||
@@ -380,6 +462,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<TripPlanner>), 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' } })
|
||||
@@ -416,7 +505,7 @@ describe('MPlacesBrowser', () => {
|
||||
|
||||
// Narrow to the two categorised places: two visible, two selected — but not
|
||||
// the same two, so the toolbar must still offer select-all.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.filters' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Food' }))
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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, toursEnabled = false }: {
|
||||
places?: Place[]
|
||||
categories?: Category[]
|
||||
open?: boolean
|
||||
toursEnabled?: boolean
|
||||
} = {}) {
|
||||
const onClose = vi.fn()
|
||||
render(<MPlacesFilterSheet open={open} onClose={onClose} places={places} categories={categories} toursEnabled={toursEnabled} />)
|
||||
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('hides Tracks from the map filter while Tours is enabled', async () => {
|
||||
const tourTrack = buildPlace({ id: 4, route_geometry: '[[5,6],[7,8]]' })
|
||||
renderSheet({ places: [TAGGED, TRACK, tourTrack], toursEnabled: true })
|
||||
await screen.findByRole('dialog', { name: 'Filters' })
|
||||
expect(within(group('Show')).queryByRole('button', { name: 'Tracks' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-003: picking a pool writes it to the trip store', async () => {
|
||||
renderSheet()
|
||||
await screen.findByRole('dialog', { name: 'Filters' })
|
||||
const show = group('Show')
|
||||
expect(within(show).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(within(show).getByRole('button', { name: 'Unplanned' }))
|
||||
expect(useTripStore.getState().placesFilter).toBe('unplanned')
|
||||
// The chosen chip says so to a screen reader, and only that one.
|
||||
expect(within(show).getByRole('button', { name: 'Unplanned' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(within(show).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-MOB-PFSHEET-004: picking a floor writes the rating filter to the trip store', async () => {
|
||||
renderSheet()
|
||||
await screen.findByRole('dialog', { name: 'Filters' })
|
||||
const floors = group('Filter by rating')
|
||||
fireEvent.click(within(floors).getByRole('button', { name: '3+' }))
|
||||
expect(useTripStore.getState().placesRatingFilter).toBe(3)
|
||||
expect(within(floors).getByRole('button', { name: '3+' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(within(floors).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -481,8 +481,8 @@ describe('NoteRow', () => {
|
||||
|
||||
expect(screen.getByText('09:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('Buy museum tickets')).toBeInTheDocument()
|
||||
// Two separate blocks since #1629: the extra title lines are plain text, the
|
||||
// detail is rendered Markdown, so they cannot be joined into one string.
|
||||
// Two separate blocks since #1629: the extra title lines and the detail are
|
||||
// each rendered Markdown, so they cannot be joined into one string.
|
||||
expect(screen.getByText('Cash only')).toBeInTheDocument()
|
||||
expect(screen.getByText('at the kiosk')).toBeInTheDocument()
|
||||
})
|
||||
@@ -532,6 +532,17 @@ describe('NoteRow', () => {
|
||||
fireEvent.click(screen.getByText('Buy museum tickets'))
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-045: extra title lines keep their line breaks and turn a pasted URL into a link (#2576)', () => {
|
||||
const url = 'https://www.google.com/maps/place/Museum/@48.1371079,11.5753822,17z/data=!3m1!4b1!4m6!3m5!1s0x0'
|
||||
const { container } = render(<NoteRow {...base} note={note({ text: `Museum
|
||||
Open at nine
|
||||
${url}`, time: null })} />)
|
||||
|
||||
expect(screen.getByRole('link', { name: url })).toHaveAttribute('href', url)
|
||||
// remark-breaks turns the kept newline into a <br>; joined with a space it would not be there.
|
||||
expect(container.querySelector('.collab-note-md br')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Blur booking codes on the plan row (#2457) ────────────────────────────────
|
||||
|
||||
@@ -11,9 +11,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
|
||||
@@ -238,6 +238,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')
|
||||
|
||||
@@ -354,6 +354,32 @@ describe('tripStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('places filters', () => {
|
||||
it('FE-TRIP-PF-001: the rating floor starts at "all" and is shared through the store', () => {
|
||||
expect(useTripStore.getState().placesRatingFilter).toBe('all');
|
||||
useTripStore.getState().setPlacesRatingFilter(4);
|
||||
expect(useTripStore.getState().placesRatingFilter).toBe(4);
|
||||
});
|
||||
|
||||
it('FE-TRIP-PF-002: resetPlacesFilters lifts the pool, the categories and the rating at once', () => {
|
||||
const s = useTripStore.getState();
|
||||
s.setPlacesFilter('unplanned');
|
||||
s.setPlacesCategoryFilter(new Set(['3', 'uncategorized']));
|
||||
s.setPlacesRatingFilter(5);
|
||||
useTripStore.getState().resetPlacesFilters();
|
||||
const after = useTripStore.getState();
|
||||
expect(after.placesFilter).toBe('all');
|
||||
expect(after.placesCategoryFilter.size).toBe(0);
|
||||
expect(after.placesRatingFilter).toBe('all');
|
||||
});
|
||||
|
||||
it('FE-TRIP-PF-003: resetTrip drops the rating floor of the previous trip', () => {
|
||||
useTripStore.getState().setPlacesRatingFilter(3);
|
||||
useTripStore.getState().resetTrip();
|
||||
expect(useTripStore.getState().placesRatingFilter).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTag', () => {
|
||||
it('FE-TRIP-010: addTag creates tag and appends to tags', async () => {
|
||||
const existingTag = buildTag();
|
||||
|
||||
@@ -61,6 +61,7 @@ const places: TranslationStrings = {
|
||||
'places.filterByLocation': 'تصفية حسب البلد أو المنطقة',
|
||||
'places.allLocations': 'كل الدول',
|
||||
'places.filterShow': 'عرض',
|
||||
'places.filters': 'عوامل التصفية',
|
||||
'places.clearSearch': 'مسح البحث',
|
||||
'places.yourRating': 'تقييمك',
|
||||
'places.notRated': 'لم يُقيَّم بعد',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ó',
|
||||
|
||||
@@ -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í',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -61,9 +61,13 @@ const places: TranslationStrings = {
|
||||
'places.sortUpdated': 'Recently changed',
|
||||
'places.filterByLocation': 'Filter by country or region',
|
||||
'places.allLocations': 'All countries',
|
||||
/** 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 button that opens the places filter panel, and the panel's name: the phone
|
||||
* map's control and the title of its sheet, and the phone places list's panel button.
|
||||
*/
|
||||
'places.filters': 'Filters',
|
||||
'places.clearSearch': 'Clear search',
|
||||
'places.yourRating': 'Your rating',
|
||||
'places.notRated': 'Not rated yet',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -62,6 +62,7 @@ const places: TranslationStrings = {
|
||||
'places.filterByLocation': 'Φιλτράρισμα ανά χώρα ή περιοχή',
|
||||
'places.allLocations': 'Όλες οι χώρες',
|
||||
'places.filterShow': 'Εμφάνιση',
|
||||
'places.filters': 'Φίλτρα',
|
||||
'places.clearSearch': 'Καθαρισμός αναζήτησης',
|
||||
'places.yourRating': 'Η βαθμολογία σου',
|
||||
'places.notRated': 'Χωρίς βαθμολογία ακόμη',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -61,6 +61,7 @@ const places: TranslationStrings = {
|
||||
'places.filterByLocation': '国または地域で絞り込む',
|
||||
'places.allLocations': 'すべての国',
|
||||
'places.filterShow': '表示',
|
||||
'places.filters': 'フィルター',
|
||||
'places.clearSearch': '検索をクリア',
|
||||
'places.yourRating': 'あなたの評価',
|
||||
'places.notRated': '未評価',
|
||||
|
||||
@@ -60,6 +60,7 @@ const places: TranslationStrings = {
|
||||
'places.filterByLocation': '국가 또는 지역으로 필터링',
|
||||
'places.allLocations': '모든 국가',
|
||||
'places.filterShow': '표시',
|
||||
'places.filters': '필터',
|
||||
'places.clearSearch': '검색 지우기',
|
||||
'places.yourRating': '내 평점',
|
||||
'places.notRated': '아직 평점 없음',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -61,6 +61,7 @@ const places: TranslationStrings = {
|
||||
'places.filterByLocation': 'Фильтр по стране или региону',
|
||||
'places.allLocations': 'Все страны',
|
||||
'places.filterShow': 'Показать',
|
||||
'places.filters': 'Фильтры',
|
||||
'places.clearSearch': 'Очистить поиск',
|
||||
'places.yourRating': 'Твоя оценка',
|
||||
'places.notRated': 'Ещё нет оценок',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -55,6 +55,7 @@ const places: TranslationStrings = {
|
||||
'places.filterByLocation': 'กรองตามประเทศหรือภูมิภาค',
|
||||
'places.allLocations': 'ทุกประเทศ',
|
||||
'places.filterShow': 'แสดง',
|
||||
'places.filters': 'ตัวกรอง',
|
||||
'places.clearSearch': 'ล้างการค้นหา',
|
||||
'places.yourRating': 'คะแนนของคุณ',
|
||||
'places.notRated': 'ยังไม่ได้รับการจัดอันดับ',
|
||||
|
||||
@@ -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ı',
|
||||
|
||||
@@ -61,6 +61,7 @@ const places: TranslationStrings = {
|
||||
'places.filterByLocation': 'Фільтр за країною або регіоном',
|
||||
'places.allLocations': 'Усі країни',
|
||||
'places.filterShow': 'Показати',
|
||||
'places.filters': 'Фільтри',
|
||||
'places.clearSearch': 'Очистити пошук',
|
||||
'places.yourRating': 'Твоя оцінка',
|
||||
'places.notRated': 'Ще не оцінено',
|
||||
|
||||
@@ -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á',
|
||||
|
||||
@@ -60,6 +60,7 @@ const places: TranslationStrings = {
|
||||
'places.filterByLocation': '依國家或地區篩選',
|
||||
'places.allLocations': '所有國家',
|
||||
'places.filterShow': '顯示',
|
||||
'places.filters': '篩選',
|
||||
'places.clearSearch': '清除搜尋',
|
||||
'places.yourRating': '你的評分',
|
||||
'places.notRated': '尚未評分',
|
||||
|
||||
@@ -60,6 +60,7 @@ const places: TranslationStrings = {
|
||||
'places.filterByLocation': '按国家或地区筛选',
|
||||
'places.allLocations': '所有国家',
|
||||
'places.filterShow': '显示',
|
||||
'places.filters': '筛选',
|
||||
'places.clearSearch': '清除搜索',
|
||||
'places.yourRating': '你的评分',
|
||||
'places.notRated': '暂无评分',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user