mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
merge: update Tours branch with current Places filters
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user