Skip to content

feat: hide non-matching trails when an activity is selected + backfill activities#457

Merged
fatherlinux merged 1 commit into
masterfrom
feature/trail-activity-filter-v2
May 31, 2026
Merged

feat: hide non-matching trails when an activity is selected + backfill activities#457
fatherlinux merged 1 commit into
masterfrom
feature/trail-activity-filter-v2

Conversation

@fatherlinux
Copy link
Copy Markdown
Member

Summary

Follow-up to #453. That PR made trails activity-aware with OR logic — selecting an activity adds matching trails on top — but with the Trails layer on, hiking-only trails (Seneca, Sand Run Parkway, Ledges) still showed under "Biking". Separately, 144 of 180 trails have no primary_activities at all, and a dozen footpaths were mis-tagged as bikeable.

This PR keeps #453's behavior and adds the missing piece: once an activity filter is active, non-matching trails are hidden.

Code (frontend) — extends #453, does not replace it

  • iconUtils.js: isActivityFilterActive() + trailPassesActivityFilter(). Untagged trails always pass (a missing tag never silently hides a trail); a tagged trail passes only if it matches a selected activity.
  • Map.jsx: trail visibility is now (showTrails || matchesActivity) && trailPassesActivityFilter(...) at both render sites. Trails-off + activity-selected still surfaces matching trails (feat: add regional bikeways, fix trail hover rendering #453); an active activity filter now also hides non-matching trails. Plain "Trails" browsing (all/none activities) is unchanged.

Data (migration 080, idempotent)

  • 144 untagged trails -> Hiking.
  • Drop Biking from 11 mis-tagged footpaths (kept their other tags).
  • Bikeable allowlist keeps Hiking+Biking, including Gateway (West Creek paved connector). Result: trails tagged Biking 21 -> 10, untagged 144 -> 0.

Test plan

  • Verified locally (build + browser): None -> Biking -> Trails shows exactly the 10 bikeable trails; Seneca / Parkway Jogging / Ledges hidden.
  • None -> Trails alone shows all trails (filter only engages for a specific activity).
  • frontend production build passes; gourmand --full . = 0 violations.
  • Migration 080 re-runs as UPDATE 0 / UPDATE 0 (idempotent).

Note: prod already had 144 untagged + 22 biking trails (from #453's additions); this migration corrects them in place.

🤖 Generated with Claude Code

…l activities

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) <noreply@anthropic.com>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a database migration to backfill and correct trail activities, alongside frontend updates to support filtering trails on the map by activity (e.g., hiding hiking-only trails when 'Biking' is selected). The review feedback highlights a critical bug in the SQL migration where removing 'Biking' from a trail with no other activities results in a NULL value, allowing it to incorrectly bypass the filter; a fallback to 'Hiking' is recommended. Additionally, performance and safety improvements are suggested for the frontend activity filtering logic to cache redundant computations and add defensive guards.

Comment on lines +26 to +30
SET primary_activities = (
SELECT string_agg(trim(part), ', ')
FROM unnest(string_to_array(primary_activities, ',')) AS part
WHERE lower(trim(part)) <> 'biking'
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If a trail only has 'Biking' as its primary activity, removing 'Biking' will cause the subquery to return NULL. This would set primary_activities to NULL (or empty), which in the frontend is treated as an untagged trail. Since untagged trails always pass the activity filter, this trail would incorrectly continue to show up when the "Biking" filter is active.

To prevent this, we should fallback to 'Hiking' if removing 'Biking' leaves the activities list empty.

SET primary_activities = COALESCE(
      NULLIF(
        (
          SELECT string_agg(trim(part), ', ')
          FROM unnest(string_to_array(primary_activities, ',')) AS part
          WHERE lower(trim(part)) <> 'biking'
        ),
        ''
      ),
      'Hiking'
    )

Comment on lines +64 to +92
/**
* 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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performance & Safety Improvements

  1. Redundant Computations: isActivityFilterActive is called for every single trail on every map move/zoom and render cycle. Since its result only depends on visibleTypes and iconConfig (which do not change per trail), we can cache the last computed result using simple reference equality checks. This avoids O(N * M) redundant loops over iconConfig during rendering.
  2. Defensive Programming: Added safety guards to ensure visibleTypes is a valid Set (or has a .has method) and feature is not null/undefined before accessing their properties.
let lastVisibleTypes = null;
let lastIconConfig = null;
let lastResult = 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;
  if (!visibleTypes || typeof visibleTypes.has !== 'function') return false;

  if (visibleTypes === lastVisibleTypes && iconConfig === lastIconConfig) {
    return lastResult;
  }

  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++;
  }

  lastVisibleTypes = visibleTypes;
  lastIconConfig = iconConfig;
  lastResult = selected > 0 && selected < activityTypes;
  return lastResult;
}

/**
 * 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 (!feature) return false;
  if (!isActivityFilterActive(visibleTypes, iconConfig)) return true;
  if (!(feature.primary_activities || '').trim()) return true;
  return poiMatchesActivityForTypes(feature, visibleTypes, iconConfig);
}

@fatherlinux fatherlinux merged commit 797f8b1 into master May 31, 2026
3 checks passed
@fatherlinux fatherlinux deleted the feature/trail-activity-filter-v2 branch May 31, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant