diff --git a/backend/migrations/075_backfill_trail_activities.sql b/backend/migrations/075_backfill_trail_activities.sql new file mode 100644 index 00000000..be279399 --- /dev/null +++ b/backend/migrations/075_backfill_trail_activities.sql @@ -0,0 +1,34 @@ +-- Migration 075: Backfill and correct trail activities so the activity filter +-- (e.g. selecting "Biking") only surfaces trails that actually allow that activity. +-- +-- Three parts, all idempotent (guarded so re-runs are no-ops and so later admin +-- edits are not clobbered once a row no longer matches the known-bad state): +-- 1. Untagged trails -> 'Hiking' (most are CVNP/Metro foot trails). +-- 2. Drop 'Biking' from 11 footpaths that were mis-tagged as bikeable. +-- 3. Bikeable multi-use trails on the allowlist (Towpath, Bike & Hike, Gateway, +-- etc.) keep their existing 'Hiking, Biking' tags — no change needed. + +BEGIN; + +-- 1. Untagged trails -> 'Hiking' +UPDATE pois +SET primary_activities = 'Hiking' +WHERE 'trail' = ANY(poi_roles) + AND coalesce(deleted, false) = false + AND coalesce(nullif(trim(primary_activities), ''), '') = ''; + +-- 2. Remove 'Biking' from hiking-only footpaths that were incorrectly tagged. +-- Element-wise: split on commas, drop the 'Biking' item, rejoin. Guarded by id list +-- + "still contains Biking" (Postgres word boundaries are \m \M, NOT \b), so it +-- fires once and re-runs as UPDATE 0. Gateway Trail (West Creek paved connector, +-- id 1018) and the paved/limestone multi-use trails are intentionally excluded. +UPDATE pois +SET primary_activities = ( + SELECT string_agg(trim(part), ', ') + FROM unnest(string_to_array(primary_activities, ',')) AS part + WHERE lower(trim(part)) <> 'biking' + ) +WHERE id IN (974, 1021, 1036, 1045, 1050, 1057, 1068, 1074, 1089, 1099, 1095) + AND primary_activities ~* '\mBiking\M'; + +COMMIT; diff --git a/frontend/src/components/Map.jsx b/frontend/src/components/Map.jsx index 18c18cd9..5eca37e8 100644 --- a/frontend/src/components/Map.jsx +++ b/frontend/src/components/Map.jsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'; import { MapContainer, TileLayer, Marker, Tooltip, useMap, GeoJSON, useMapEvents, CircleMarker } from 'react-leaflet'; import L from 'leaflet'; import VirtualPoiCreator from './VirtualPoiCreator'; -import { getDestinationIconTypeFromConfig, poiMatchesActivityForTypes, matchesWholeWord } from '../utils/iconUtils'; +import { getDestinationIconTypeFromConfig, poiMatchesActivityForTypes, trailPassesActivityFilter, matchesWholeWord } from '../utils/iconUtils'; import { useTrip } from '../hooks/useTrip'; import { useNavigate } from 'react-router-dom'; import { generateSlug } from './sidebar/helpers'; @@ -635,7 +635,7 @@ function MapBoundsTracker({ destinations, visibleTypes, getDestinationIconType, // Title search matches across all linear types, ignoring layer toggles isLayerVisible = feature.name?.toLowerCase().includes(search); } else if (feature.poi_roles?.includes('trail')) { - isLayerVisible = showTrails; + isLayerVisible = showTrails && trailPassesActivityFilter(feature, visibleTypes, iconConfig); } else if (feature.poi_roles?.includes('river')) { isLayerVisible = showRivers; } else if (feature.poi_roles?.includes('water_taxi')) { @@ -1495,7 +1495,7 @@ function Map({ destinations, selectedPoi, selectedIsLinear, onSelectPoi, isAdmin // regardless of its layer toggle, and hide non-matches. const isVisible = searchQuery ? feature.name?.toLowerCase().includes(searchQuery.toLowerCase()) - : ((feature.poi_roles?.includes('trail') && showTrails) || + : ((feature.poi_roles?.includes('trail') && showTrails && trailPassesActivityFilter(feature, visibleTypes, iconConfig)) || (feature.poi_roles?.includes('river') && showRivers) || (feature.poi_roles?.includes('water_taxi') && showWaterTaxis) || (feature.poi_roles?.includes('boundary') && visibleBoundaries.has(feature.id))); diff --git a/frontend/src/utils/iconUtils.js b/frontend/src/utils/iconUtils.js index 3a423f54..cbf2801d 100644 --- a/frontend/src/utils/iconUtils.js +++ b/frontend/src/utils/iconUtils.js @@ -61,6 +61,34 @@ export function poiMatchesActivityForTypes(poi, visibleTypes, iconConfig) { return false; } +/** + * True when the legend is narrowed to a subset of activity-bearing types + * (e.g. just "Biking") rather than showing everything. The Trails layer is only + * refined by activity while this is true; with all (or no) activity types + * selected it stays all-or-nothing, preserving plain "show me the trails" browsing. + */ +export function isActivityFilterActive(visibleTypes, iconConfig) { + if (!iconConfig || iconConfig.length === 0) return false; + let activityTypes = 0, selected = 0; + for (const icon of iconConfig) { + if (icon.enabled === false || !icon.activity_fallbacks) continue; + activityTypes++; + if (visibleTypes.has(icon.name)) selected++; + } + return selected > 0 && selected < activityTypes; +} + +/** + * Whether a trail (linear feature) should render given the current activity + * narrowing. Untagged trails always show, so a missing tag never silently hides a + * trail. (Selecting "Biking" hides hiking-only trails.) + */ +export function trailPassesActivityFilter(feature, visibleTypes, iconConfig) { + if (!isActivityFilterActive(visibleTypes, iconConfig)) return true; + if (!(feature.primary_activities || '').trim()) return true; + return poiMatchesActivityForTypes(feature, visibleTypes, iconConfig); +} + export function getIconUrlForPOI(poi, iconConfig, poiType) { if (poiType === 'trail') return '/icons/layers/trails.svg'; if (poiType === 'river') return '/icons/layers/rivers.svg';