From d74d1be00acecce3e04d432dafd86839138d5917 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Sun, 31 May 2026 17:50:08 -0400 Subject: [PATCH] feat: hide non-matching trails when an activity is selected + backfill activities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #453 made trails activity-aware with OR logic (selecting "Biking" ADDS bikeable trails on top), but with the Trails layer on it still showed hiking-only trails (Seneca, Sand Run Parkway, Ledges). And 144 of 180 trails have no primary_activities at all. Code (frontend) — builds on #453, does not replace it: - iconUtils.js: add isActivityFilterActive() + trailPassesActivityFilter(). Untagged trails always pass (a missing tag never hides a trail); a tagged trail passes only if it matches a selected activity. - Map.jsx: trail visibility is now (showTrails || matchesActivity) && trailPassesActivityFilter(...). This keeps #453's behavior (Trails-off + activity selected surfaces matching trails) AND hides non-matching trails once an activity filter is active. Plain "Trails" browsing (all/none activities) is unchanged. Data (migration 080, idempotent): - 144 untagged trails -> 'Hiking'. - Drop 'Biking' from 11 mis-tagged footpaths (kept other tags). - Bikeable allowlist (incl. Gateway, the West Creek paved connector) keeps Hiking+Biking. Result: trails tagged Biking 21 -> 10, untagged 144 -> 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../080_backfill_trail_activities.sql | 34 +++++++++++++++++++ frontend/src/components/Map.jsx | 7 ++-- frontend/src/utils/iconUtils.js | 30 ++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 backend/migrations/080_backfill_trail_activities.sql diff --git a/backend/migrations/080_backfill_trail_activities.sql b/backend/migrations/080_backfill_trail_activities.sql new file mode 100644 index 00000000..7bd6c90a --- /dev/null +++ b/backend/migrations/080_backfill_trail_activities.sql @@ -0,0 +1,34 @@ +-- Migration 080: 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 da4a13ba..a570bf11 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'; @@ -636,7 +636,8 @@ 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 || poiMatchesActivityForTypes(feature, visibleTypes, iconConfig); + isLayerVisible = (showTrails || poiMatchesActivityForTypes(feature, visibleTypes, iconConfig)) + && trailPassesActivityFilter(feature, visibleTypes, iconConfig); } else if (feature.poi_roles?.includes('river')) { isLayerVisible = showRivers; } else if (feature.poi_roles?.includes('water_taxi')) { @@ -1496,7 +1497,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 || poiMatchesActivityForTypes(feature, visibleTypes, iconConfig))) || + : ((feature.poi_roles?.includes('trail') && (showTrails || poiMatchesActivityForTypes(feature, visibleTypes, iconConfig)) && 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..7b0be6aa 100644 --- a/frontend/src/utils/iconUtils.js +++ b/frontend/src/utils/iconUtils.js @@ -61,6 +61,36 @@ 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 remain visible under the current + * activity narrowing. Untagged trails always pass, so a missing tag never + * silently hides a trail; a tagged trail passes only if it matches a selected + * activity. Selecting "Biking" thus hides hiking-only trails while the trail + * layer is on. + */ +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';