From 8ba308e267853a36ab62b8cec862f491ebefde5d Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Wed, 27 May 2026 03:25:22 -0400 Subject: [PATCH 1/2] fix: POI filter matches activities and unifies icon taxonomy (#410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POI panel filter and map legend chips now search activities, not just icon type. Typing "camping" or clicking the Camping chip shows all POIs with camping as an activity, regardless of their assigned icon type. - Add poiMatchesActivityForTypes() utility to match POI activities against enabled icon types' activity_fallbacks - Update Map.jsx visibility checks (MapBoundsTracker + marker rendering) and zoom-to-fit bounds to include activity-matched POIs - Extend text search in App.jsx and ResultsTab.jsx to match primary_activities - Add 4 new icon types: Fishing, Kayaking, Scenic, Art & Culture with SVGs - Update Nature icon fallbacks to include Bird Watching and Photography - Rename Visitor Center to Discovery (covers museums, libraries, visitor centers) - Remove museum from Historic keywords (Discovery claims it at higher priority) - Fix orphaned POIs: Gear Up Velo → biking, John Brown Monument → historic - Soft-delete duplicate Brecksville Reservation point POI (boundary exists) - Disable Other/default icon type — all point POIs now map to real types - Increase legend panel height from 480px to 580px for additional icon types Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrations/065_unify_activities_icons.sql | 57 +++++++++++++++++++ frontend/public/icons/art.svg | 9 +++ frontend/public/icons/fishing.svg | 7 +++ frontend/public/icons/kayaking.svg | 7 +++ frontend/public/icons/scenic.svg | 5 ++ frontend/src/App.css | 2 +- frontend/src/App.jsx | 6 +- frontend/src/components/Map.jsx | 44 ++++++++------ frontend/src/components/ResultsTab.jsx | 7 ++- frontend/src/utils/iconUtils.js | 18 ++++++ 10 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 backend/migrations/065_unify_activities_icons.sql create mode 100644 frontend/public/icons/art.svg create mode 100644 frontend/public/icons/fishing.svg create mode 100644 frontend/public/icons/kayaking.svg create mode 100644 frontend/public/icons/scenic.svg diff --git a/backend/migrations/065_unify_activities_icons.sql b/backend/migrations/065_unify_activities_icons.sql new file mode 100644 index 00000000..2d457920 --- /dev/null +++ b/backend/migrations/065_unify_activities_icons.sql @@ -0,0 +1,57 @@ +-- Migration 065: Unify activities table and icon activity_fallbacks +-- Adds 4 new icon types (fishing, kayaking, scenic, art), updates nature fallbacks, +-- and syncs the activities table with all icon activity_fallbacks. + +BEGIN; + +-- 1. Add new icons with inline SVG content +-- Each icon follows the project style: 32x32 viewBox, colored circle, white art + +INSERT INTO icons (name, label, svg_filename, title_keywords, activity_fallbacks, sort_order) +VALUES + ('fishing', 'Fishing', 'fishing.svg', 'fish,fishing,angler', 'Fishing', 15), + ('kayaking', 'Kayaking', 'kayaking.svg', 'kayak,canoe,paddle,paddling', 'Kayaking,Boat Rides', 16), + ('scenic', 'Scenic', 'scenic.svg', 'scenic,overlook,vista,viewpoint', 'Scenic Drives', 17), + ('art', 'Art & Culture', 'art.svg', 'art,gallery,studio', 'Art', 18) +ON CONFLICT (name) DO NOTHING; + +-- 2. Update nature icon to include Bird Watching and Photography as fallbacks +UPDATE icons +SET activity_fallbacks = 'Nature Study,Wildlife Viewing,Bird Watching,Photography' +WHERE name = 'nature'; + +-- 3. Rename visitor-center to Discovery, add library keyword +UPDATE icons +SET label = 'Discovery', + title_keywords = 'visitor center,info,information,museum,library' +WHERE name = 'visitor-center'; + +-- 4. Remove museum from historic keywords (visitor-center already claims it at higher priority) +UPDATE icons +SET title_keywords = 'historic,history,house,mill,lock,farm,farms' +WHERE name = 'historic'; + +-- 5. Fix specific POIs that fall to 'default' icon type +-- Gear Up Velo → biking (it's a bike shop) +UPDATE pois SET primary_activities = 'Biking' WHERE name = 'Gear Up Velo' AND primary_activities IS NULL; + +-- John Brown Monument → historic (it's a historical monument) +UPDATE pois SET primary_activities = 'Historical Tours' WHERE name = 'John Brown Monument' AND primary_activities IS NULL; + +-- Quaker Square → add Historical Tours (it's a historic building) +UPDATE pois SET primary_activities = 'Photography,Historical Tours' WHERE name = 'Quaker Square' AND primary_activities = 'Photography'; + +-- Brecksville Reservation point POI → soft-delete (boundary version exists) +UPDATE pois SET deleted = true WHERE id = 5745 AND name = 'Brecksville Reservation' AND 'point' = ANY(poi_roles); + +-- 6. Disable the 'default/Other' icon type — all point POIs now map to real types +UPDATE icons SET enabled = false WHERE name = 'default'; + +-- 7. Sync activities table — add missing activities that exist as icon fallbacks +INSERT INTO activities (name, sort_order) VALUES + ('Music', (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM activities)), + ('Art', (SELECT COALESCE(MAX(sort_order), 0) + 2 FROM activities)), + ('Boat Rides', (SELECT COALESCE(MAX(sort_order), 0) + 3 FROM activities)) +ON CONFLICT (name) DO NOTHING; + +COMMIT; diff --git a/frontend/public/icons/art.svg b/frontend/public/icons/art.svg new file mode 100644 index 00000000..413e020b --- /dev/null +++ b/frontend/public/icons/art.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/icons/fishing.svg b/frontend/public/icons/fishing.svg new file mode 100644 index 00000000..dd6422aa --- /dev/null +++ b/frontend/public/icons/fishing.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/icons/kayaking.svg b/frontend/public/icons/kayaking.svg new file mode 100644 index 00000000..00837a76 --- /dev/null +++ b/frontend/public/icons/kayaking.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/icons/scenic.svg b/frontend/public/icons/scenic.svg new file mode 100644 index 00000000..9ead7b01 --- /dev/null +++ b/frontend/public/icons/scenic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css index 3388604e..51401763 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2585,7 +2585,7 @@ body { box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 1000; width: 320px; - height: min(480px, 70vh); + height: min(580px, 75vh); box-sizing: border-box; display: flex; flex-direction: column; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e25f21e6..415ae6d0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1234,9 +1234,11 @@ function AppContent() { } if (activeFilters.search) { - // Title-only match: typing a name shows only items whose title contains it. const searchLower = activeFilters.search.toLowerCase(); - filtered = filtered.filter(d => d.name?.toLowerCase().includes(searchLower)); + filtered = filtered.filter(d => + d.name?.toLowerCase().includes(searchLower) || + (d.primary_activities || '').toLowerCase().includes(searchLower) + ); } setFilteredDestinations(filtered); diff --git a/frontend/src/components/Map.jsx b/frontend/src/components/Map.jsx index c02c5593..7c30a7e9 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 } from '../utils/iconUtils'; +import { getDestinationIconTypeFromConfig, poiMatchesActivityForTypes } from '../utils/iconUtils'; import { useTrip } from '../hooks/useTrip'; import { useNavigate } from 'react-router-dom'; import { generateSlug } from './sidebar/helpers'; @@ -238,7 +238,7 @@ function Legend({ onSearchChange(e.target.value)} /> @@ -589,7 +589,7 @@ function ZoomTooltipHider() { return null; } -function MapBoundsTracker({ destinations, visibleTypes, getDestinationIconType, onVisiblePoisChange, onMapStateChange, linearFeatures, showTrails, showRivers, showWaterTaxis, visibleBoundaries, searchQuery }) { +function MapBoundsTracker({ destinations, visibleTypes, getDestinationIconType, onVisiblePoisChange, onMapStateChange, linearFeatures, showTrails, showRivers, showWaterTaxis, visibleBoundaries, searchQuery, iconConfig }) { const map = useMap(); const search = (searchQuery || '').toLowerCase(); @@ -607,7 +607,7 @@ function MapBoundsTracker({ destinations, visibleTypes, getDestinationIconType, // While searching, destinations are already title-filtered upstream — count // them regardless of category; otherwise honor the category toggles. const iconType = getDestinationIconType(dest); - if (!search && !visibleTypes.has(iconType)) { + if (!search && !visibleTypes.has(iconType) && !poiMatchesActivityForTypes(dest, visibleTypes, iconConfig)) { return; } @@ -672,7 +672,7 @@ function MapBoundsTracker({ destinations, visibleTypes, getDestinationIconType, } } catch { } - }, [map, destinations, visibleTypes, getDestinationIconType, onVisiblePoisChange, onMapStateChange, linearFeatures, showTrails, showRivers, showWaterTaxis, visibleBoundaries, search]); + }, [map, destinations, visibleTypes, getDestinationIconType, onVisiblePoisChange, onMapStateChange, linearFeatures, showTrails, showRivers, showWaterTaxis, visibleBoundaries, search, iconConfig]); useMapEvents({ moveend: updateVisiblePois, @@ -1160,26 +1160,37 @@ function Map({ destinations, selectedPoi, selectedIsLinear, onSelectPoi, isAdmin // doesn't re-scan every POI on each click. Stored as [minLat,minLng,maxLat,maxLng]. // (PR #401 review) const typeBoundsById = useMemo(() => { - // globalThis.Map: the bare name `Map` is this file's component, so - // `new Map()` would construct the component. (PR #401 review) const byType = new globalThis.Map(); - for (const dest of destinations) { - if (!dest.latitude || !dest.longitude) continue; - const t = getDestinationIconType(dest); - const lat = parseFloat(dest.latitude); - const lng = parseFloat(dest.longitude); - const cur = byType.get(t); + const addToBounds = (type, lat, lng) => { + const cur = byType.get(type); if (!cur) { - byType.set(t, [lat, lng, lat, lng]); + byType.set(type, [lat, lng, lat, lng]); } else { if (lat < cur[0]) cur[0] = lat; if (lng < cur[1]) cur[1] = lng; if (lat > cur[2]) cur[2] = lat; if (lng > cur[3]) cur[3] = lng; } + }; + for (const dest of destinations) { + if (!dest.latitude || !dest.longitude) continue; + const t = getDestinationIconType(dest); + const lat = parseFloat(dest.latitude); + const lng = parseFloat(dest.longitude); + addToBounds(t, lat, lng); + const poiActs = (dest.primary_activities || '').toLowerCase(); + if (poiActs && iconConfig) { + for (const icon of iconConfig) { + if (icon.enabled === false || !icon.activity_fallbacks || icon.name === t) continue; + const fbs = icon.activity_fallbacks.split(',').map(a => a.trim().toLowerCase()); + if (fbs.some(fb => fb && poiActs.includes(fb))) { + addToBounds(icon.name, lat, lng); + } + } + } } return byType; - }, [destinations, getDestinationIconType]); + }, [destinations, getDestinationIconType, iconConfig]); // Fit the map to all POIs of the given icon type(s), from cached per-type bounds. const fitToTypes = useCallback((typeIds) => { @@ -1733,6 +1744,7 @@ function Map({ destinations, selectedPoi, selectedIsLinear, onSelectPoi, isAdmin showWaterTaxis={showWaterTaxis} visibleBoundaries={visibleBoundaries} searchQuery={searchQuery} + iconConfig={iconConfig} /> setMapMoveCount(c => c + 1)} /> @@ -1774,7 +1786,7 @@ function Map({ destinations, selectedPoi, selectedIsLinear, onSelectPoi, isAdmin // When a title search is active, App has already narrowed destinations to // name matches — show them regardless of the category toggles. const iconType = getDestinationIconType(dest); - if (!searchQuery && !visibleTypes.has(iconType)) return null; + if (!searchQuery && !visibleTypes.has(iconType) && !poiMatchesActivityForTypes(dest, visibleTypes, iconConfig)) return null; const isSelected = selectedDestination?.id === dest.id; const icon = getDestinationIcon(dest); diff --git a/frontend/src/components/ResultsTab.jsx b/frontend/src/components/ResultsTab.jsx index 972c752b..23c93cc6 100644 --- a/frontend/src/components/ResultsTab.jsx +++ b/frontend/src/components/ResultsTab.jsx @@ -186,7 +186,8 @@ const ResultsTab = memo(function ResultsTab({ const search = searchText.toLowerCase(); filtered = filtered.filter(poi => (poi.name || '').toLowerCase().includes(search) || - (poi.brief_description || '').toLowerCase().includes(search) + (poi.brief_description || '').toLowerCase().includes(search) || + (poi.primary_activities || '').toLowerCase().includes(search) ); } @@ -392,7 +393,7 @@ const ResultsTab = memo(function ResultsTab({ { setSearchText(e.target.value); setCurrentPage(1); }} /> @@ -495,7 +496,7 @@ const ResultsTab = memo(function ResultsTab({ setSearchText(e.target.value)} /> diff --git a/frontend/src/utils/iconUtils.js b/frontend/src/utils/iconUtils.js index c3f62063..a39bd00e 100644 --- a/frontend/src/utils/iconUtils.js +++ b/frontend/src/utils/iconUtils.js @@ -43,6 +43,24 @@ export function getDestinationIconTypeFromConfig(destination, iconConfig) { return 'default'; } +export function poiMatchesActivityForTypes(poi, visibleTypes, iconConfig) { + if (!iconConfig || iconConfig.length === 0) return false; + const poiActivities = (poi.primary_activities || '').toLowerCase(); + if (!poiActivities) return false; + + for (const icon of iconConfig) { + if (icon.enabled === false) continue; + if (!visibleTypes.has(icon.name)) continue; + if (!icon.activity_fallbacks) continue; + + const fallbacks = icon.activity_fallbacks.split(',').map(a => a.trim().toLowerCase()); + for (const fb of fallbacks) { + if (fb && matchesWholeWord(poiActivities, fb)) return true; + } + } + return false; +} + export function getIconUrlForPOI(poi, iconConfig, poiType) { if (poiType === 'trail') return '/icons/layers/trails.svg'; if (poiType === 'river') return '/icons/layers/rivers.svg'; From ffaa6318e05945be96c63b641a568f95d8b215cb Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Wed, 27 May 2026 03:28:01 -0400 Subject: [PATCH 2/2] fix: use matchesWholeWord consistently in activity matching (#410) Gatehouse review found typeBoundsById used substring includes() while poiMatchesActivityForTypes used whole-word regex matching. Standardize on matchesWholeWord and export it from iconUtils.js. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/Map.jsx | 4 ++-- frontend/src/utils/iconUtils.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Map.jsx b/frontend/src/components/Map.jsx index 7c30a7e9..e66677c2 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 } from '../utils/iconUtils'; +import { getDestinationIconTypeFromConfig, poiMatchesActivityForTypes, matchesWholeWord } from '../utils/iconUtils'; import { useTrip } from '../hooks/useTrip'; import { useNavigate } from 'react-router-dom'; import { generateSlug } from './sidebar/helpers'; @@ -1183,7 +1183,7 @@ function Map({ destinations, selectedPoi, selectedIsLinear, onSelectPoi, isAdmin for (const icon of iconConfig) { if (icon.enabled === false || !icon.activity_fallbacks || icon.name === t) continue; const fbs = icon.activity_fallbacks.split(',').map(a => a.trim().toLowerCase()); - if (fbs.some(fb => fb && poiActs.includes(fb))) { + if (fbs.some(fb => fb && matchesWholeWord(poiActs, fb))) { addToBounds(icon.name, lat, lng); } } diff --git a/frontend/src/utils/iconUtils.js b/frontend/src/utils/iconUtils.js index a39bd00e..3a423f54 100644 --- a/frontend/src/utils/iconUtils.js +++ b/frontend/src/utils/iconUtils.js @@ -1,4 +1,4 @@ -function matchesWholeWord(text, keyword) { +export function matchesWholeWord(text, keyword) { const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`\\b${escaped}\\b`, 'i'); return regex.test(text);