Skip to content

shelter search (SDB-121)#2001

Open
mikefeldberg wants to merge 19 commits intomainfrom
SDB-121/shelter-search
Open

shelter search (SDB-121)#2001
mikefeldberg wants to merge 19 commits intomainfrom
SDB-121/shelter-search

Conversation

@mikefeldberg
Copy link
Copy Markdown
Contributor

@mikefeldberg mikefeldberg commented Apr 1, 2026

SDB-121

  • add shelter name search
  • fix results "based on ..." display

the order in which the filters are selected affects which are applied:

  • area -> filter -> name: name
  • area -> name -> filter: area + name + filter
  • filter -> area -> name: name
  • filter -> name -> area: area + name (because name clears all other filters)
  • name -> area -> filter: area + name + filter
  • name -> filter -> area: area + name + filter

Summary by Sourcery

Add shelter name search with map fitting behavior and update how search sources and property filters are handled.

New Features:

  • Support searching shelters by name alongside existing location-based search.
  • Automatically fit the map view to shelter pins after a name search using symmetric bounds around results.

Bug Fixes:

  • Correct the "based on ..." results display to reflect actual active filters and search inputs.
  • Ensure property filters are cleared or applied consistently when performing a name search or changing address.

Enhancements:

  • Refine shelter search UI to separate location and name inputs with appropriate icons and controls.
  • Refactor shelter filter state into a dedicated property filters atom and improve pruning of empty filters.
  • Expose a reusable ResultsSource component describing the origin of the current shelter results.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Apr 1, 2026

Reviewer's Guide

Implements shelter name search alongside existing location/property filters, centralizes shelter property filters atom naming, updates query composition and results-source messaging, and wires name-based searches to recenter the map on returned shelter pins with symmetric bounds.

Sequence diagram for shelter name search and map recentering

sequenceDiagram
  actor User
  participant HomePage
  participant ShelterSearch
  participant shelterPropertyFiltersAtom
  participant SheltersDisplay
  participant useInfiniteScrollQuery
  participant Map

  User->>ShelterSearch: Type name into nameSearchValue
  User->>ShelterSearch: Click Search button
  ShelterSearch->>ShelterSearch: onSearchClick()
  ShelterSearch->>ShelterSearch: setQueryFilters(undefined)
  ShelterSearch->>shelterPropertyFiltersAtom: reset()
  ShelterSearch->>ShelterSearch: setNameFilter(trim(nameSearchValue))
  ShelterSearch->>HomePage: onNameSearch()

  HomePage->>HomePage: onNameSearch()
  HomePage->>HomePage: setMapBoundsFilter(undefined)
  HomePage->>HomePage: setShowSearchButton(false)
  HomePage->>HomePage: increment nameSearchPinFitRequestId

  HomePage->>ShelterSearch: Render with updated nameSearchPinFitRequestId
  ShelterSearch->>SheltersDisplay: Render with nameFilter and nameSearchPinFitRequestId

  SheltersDisplay->>SheltersDisplay: useMemo build queryVariables
  SheltersDisplay->>SheltersDisplay: filters.name = nameFilter
  SheltersDisplay->>useInfiniteScrollQuery: fetch shelters with queryVariables
  useInfiniteScrollQuery-->>SheltersDisplay: items, loading=false

  SheltersDisplay->>SheltersDisplay: useEffect on loading, nameSearchPinFitRequestId
  SheltersDisplay->>SheltersDisplay: shelterListToPinLatLng(items)
  SheltersDisplay->>HomePage: onShelterPinsReadyForMapFit(pinLocations)

  HomePage->>HomePage: symmetricBoundsAroundPinCentroid(pinLocations)
  HomePage->>Map: fitBounds(bounds)
Loading

Updated class diagram for shelter search components and filters atom

classDiagram
  class ShelterSearch {
    +mapBoundsFilter: TMapBounds
    +nameSearchPinFitRequestId: number
    +onShelterPinsReadyForMapFit(pinLocations: TLatLng[]): void
    +onNameSearch(): void
    -location: TLatLng
    -queryFilters: TShelterPropertyFilters
    -nameFilter: string
    -nameSearchValue: string
    -submitQueryTs: number
    +onPlaceSelect(place: TPlaceResult): void
    +onSubmitFilters(): void
    +onFilterClick(): void
    +onNameSearchChange(value: string): void
    +onSearchClick(): void
  }

  class SheltersDisplay {
    +className: string
    +coordinates: TLatLng
    +mapBoundsFilter: TMapBounds
    +propertyFilters: TShelterPropertyFilters
    +rangeInMiles: number
    +nameFilter: string
    +nameSearchPinFitRequestId: number
    +onShelterPinsReadyForMapFit(pinLocations: TLatLng[]): void
    -queryVariables: ViewSheltersQueryVariables
    -sheltersData: TShelter[]
    +SheltersDisplay(props: TProps): void
    +shelterListToPinLatLng(shelters: TShelter[]): TLatLng[]
    +pruneFilters(filters: TShelterPropertyFilters): TShelterPropertyFilters
  }

  class ResultsSource {
    +className: string
    +queryFilters: ViewSheltersQueryVariables_filters
    +ResultsSource(props: TProps): void
    -propertyFiltersAffectQuery(propertyFilters: TShelterPropertyFilters): boolean
    -formatWithOxfordComma(parts: string[]): string
  }

  class AddressAutocomplete {
    +className: string
    +placeholder: string
    +countryRestrictions: ISO3166Alpha2
    +leftIcon: ReactElement
    +onPlaceSelect(place: TPlaceResult): void
  }

  class shelterPropertyFiltersAtom {
    +value: TShelterPropertyFilters
    +reset(): void
  }

  class HomePage {
    -location: TLatLng
    -mapBoundsFilter: TMapBounds
    -nameSearchPinFitRequestId: number
    +onShelterPinsReadyForMapFit(pinLocations: TLatLng[]): void
    +onNameSearch(): void
    +symmetricBoundsAroundPinCentroid(pinLocations: TLatLng[]): LatLngBounds
  }

  ShelterSearch --> SheltersDisplay
  ShelterSearch --> AddressAutocomplete
  ShelterSearch --> shelterPropertyFiltersAtom
  SheltersDisplay --> ResultsSource
  HomePage --> ShelterSearch
  HomePage --> SheltersDisplay
  HomePage --> shelterPropertyFiltersAtom
Loading

File-Level Changes

Change Details Files
Add shelter name search input, query filter, and map-fit behavior, including clearing property filters when searching by name and emitting pin locations after name search results load.
  • Extend SheltersDisplay props and GraphQL query variables to include a name filter and compute filters via useMemo, adding name into filters when present.
  • Track name search pin-fit requests in SheltersDisplay and HomePage; after name search results finish loading, emit shelter pin locations and fit the map with symmetric bounds around the pin centroid.
  • Update ShelterSearch to manage name search state, provide a dedicated name search input and button, clear and reset property filters on name search, and notify the parent via onNameSearch and shelter pin callbacks.
libs/react/shelter/src/lib/components/ShelterSearch/SheltersDisplay.tsx
libs/react/shelter/src/lib/components/ShelterSearch/ShelterSearch.tsx
libs/react/shelter/src/lib/pages/home/HomePage.tsx
Refine how active filters are tracked, reset, and displayed, including renaming the property filters atom and adjusting UI copy for the "based on" results source text.
  • Rename shelterFiltersAtom to shelterPropertyFiltersAtom (and nullShelterFilters to nullShelterPropertyFilters), updating all imports and usages across atoms, filter components, and search components.
  • Adjust FiltersActions and ShelterFilters to use the renamed property filters atom for state and reset behavior.
  • Replace SearchSource with ResultsSource, which infers the results description from map bounds, property filters, and name search, formatting multi-part descriptions with an Oxford comma.
libs/react/shelter/src/lib/atoms/shelterPropertyFiltersAtom.ts
libs/react/shelter/src/lib/atoms/index.ts
libs/react/shelter/src/lib/components/ShelterFilters/FiltersActions.tsx
libs/react/shelter/src/lib/components/ShelterFilters/ShelterFilters.tsx
libs/react/shelter/src/lib/components/ShelterSearch/ResultsSource.tsx
libs/react/shelter/src/lib/components/ShelterSearch/SheltersDisplay.tsx
libs/react/shelter/src/lib/components/ShelterSearch/index.ts
Improve search input UX by supporting customizable icons and differentiating location vs name search affordances.
  • Extend AddressAutocomplete to accept an optional leftIcon prop, defaulting to the existing search icon when not provided.
  • Update ShelterSearch to supply a location-specific icon to AddressAutocomplete and to introduce a separate text Input with a search icon for name search.
libs/react/shelter/src/lib/components/AddressAutocomplete/AddressAutocomplete.tsx
libs/react/shelter/src/lib/components/ShelterSearch/ShelterSearch.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In SheltersDisplay, the geolocation filter is now skipped entirely when mapBoundsFilter is present, whereas previously only rangeInMiles was omitted; if the intent is still to bias results by user location while constraining to map bounds, consider retaining geolocation without rangeInMiles instead of dropping it altogether.
  • The logic in ResultsSource.propertyFiltersAffectQuery diverges from pruneFilters in SheltersDisplay (e.g., it doesn’t check for empty arrays), which can cause the source text to say filters affect the query even when they don’t; consider reusing pruneFilters or aligning the conditions so the display matches the actual applied filters.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `SheltersDisplay`, the geolocation filter is now skipped entirely when `mapBoundsFilter` is present, whereas previously only `rangeInMiles` was omitted; if the intent is still to bias results by user location while constraining to map bounds, consider retaining `geolocation` without `rangeInMiles` instead of dropping it altogether.
- The logic in `ResultsSource.propertyFiltersAffectQuery` diverges from `pruneFilters` in `SheltersDisplay` (e.g., it doesn’t check for empty arrays), which can cause the source text to say filters affect the query even when they don’t; consider reusing `pruneFilters` or aligning the conditions so the display matches the actual applied filters.

## Individual Comments

### Comment 1
<location path="libs/react/shelter/src/lib/components/ShelterSearch/ResultsSource.tsx" line_range="45-47" />
<code_context>
+  if (queryFilters?.mapBounds) {
+    resultSourceParts.push('map area');
+  }
+  if (
+    propertyFiltersAffectQuery(
+      queryFilters?.properties as TShelterPropertyFilters | undefined
+    )
+  ) {
</code_context>
<issue_to_address>
**issue (bug_risk):** The "filters" label ignores the standalone `openNow` filter and only looks at `properties`, which can misrepresent the source of results.

Here `propertyFiltersAffectQuery` only receives `queryFilters?.properties`, but `openNow` lives on `filters.openNow`. So an "open now"–only query won’t add "filters" to `resultSourceParts`, even though the query is constrained. Consider either:

- Passing the full `TShelterPropertyFilters` (including `openNow`) into `propertyFiltersAffectQuery` and checking both `openNow` and other keys, or
- Adding an explicit `queryFilters?.openNow` check alongside the `properties` check.

This will keep the "based on …" description consistent for open‑now‑only queries.
</issue_to_address>

### Comment 2
<location path="libs/react/shelter/src/lib/components/ShelterSearch/SheltersDisplay.tsx" line_range="45" />
<code_context>
   const [_sheltersData, setSheltersData] = useAtom(sheltersAtom);

-  let queryVariables: ViewSheltersQueryVariables | undefined;
+  const queryVariables = useMemo<ViewSheltersQueryVariables | undefined>(() => {
+    let vars: ViewSheltersQueryVariables | undefined;

</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the query variable construction and splitting the map-fit effect into smaller focused effects to keep the new behaviors while making the code easier to follow.

You can reduce the added complexity in two focused spots without changing behavior: the query construction and the map‑fit effect.

---

### 1) Simplify `queryVariables` construction

The current `useMemo` uses a mutable `vars` with repeated `vars = vars || {}; vars.filters = vars.filters || {};` which makes it harder to scan.

You can build a `filters` object linearly and wrap it at the end:

```ts
const queryVariables = useMemo<ViewSheltersQueryVariables | undefined>(() => {
  const filters: NonNullable<ViewSheltersQueryVariables['filters']> = {};

  if (coordinates && !mapBoundsFilter) {
    const { latitude, longitude } = coordinates;
    filters.geolocation = {
      latitude,
      longitude,
      rangeInMiles,
    };
  }

  if (mapBoundsFilter) {
    filters.mapBounds = mapBoundsFilter;
  }

  if (propertyFilters) {
    const { openNow, ...propertyOnlyFilters } = propertyFilters;

    if (openNow) {
      filters.openNow = true;
    }

    const prunedFilters = pruneFilters(propertyOnlyFilters);
    if (prunedFilters) {
      filters.properties = prunedFilters;
    }
  }

  if (nameFilter) {
    filters.name = nameFilter;
  }

  // Return undefined when no filters were set, to preserve current behavior
  return Object.keys(filters).length > 0 ? { filters } : undefined;
}, [coordinates, mapBoundsFilter, propertyFilters, rangeInMiles, nameFilter]);
```

This keeps all existing behavior (including the “no mapBounds ⇒ geolocation range” rule and `pruneFilters` semantics) but removes the repeated lazy-init boilerplate and makes each branch’s intent clearer.

---

### 2) Decompose `onShelterPinsReadyForMapFit` logic

The current effect mixes:
- loading transition detection,
- “handle each `nameSearchPinFitRequestId` once” logic, and
- optional `requestAnimationFrame` scheduling

…all in one block. You can split the concerns and keep the same semantics (“call once per request id, after results are ready; use `requestAnimationFrame` when we didn’t *just* finish loading”).

First, track loading transitions in a tiny effect:

```ts
const wasLoadingRef = useRef(loading);

useEffect(() => {
  wasLoadingRef.current = loading;
}, [loading]);
```

Then, in a separate effect, handle the pin-fit callback:

```ts
const lastHandledRequestIdRef = useRef(0);

useEffect(() => {
  if (!onShelterPinsReadyForMapFit || nameSearchPinFitRequestId <= 0) {
    return;
  }
  if (lastHandledRequestIdRef.current === nameSearchPinFitRequestId) {
    return; // already handled this request
  }
  if (loading) {
    return; // wait until loading finishes
  }

  const emit = () => {
    if (lastHandledRequestIdRef.current === nameSearchPinFitRequestId) {
      return;
    }
    lastHandledRequestIdRef.current = nameSearchPinFitRequestId;
    onShelterPinsReadyForMapFit(shelterListToPinLatLng(items ?? []));
  };

  const loadingJustFinished = wasLoadingRef.current === true;

  if (loadingJustFinished) {
    emit();
    return;
  }

  const raf = requestAnimationFrame(emit);
  return () => cancelAnimationFrame(raf);
}, [loading, items, nameSearchPinFitRequestId, onShelterPinsReadyForMapFit]);
```

This preserves:
- the “only once per `nameSearchPinFitRequestId`” behavior,
- the special handling when a request has *just* finished loading vs. when we’re already idle, and
- the use of `requestAnimationFrame` when appropriate,

while making the mental model (“track loading separately, then react to `(id, items, loading)`”) more explicit and easier to maintain.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

const [_sheltersData, setSheltersData] = useAtom(sheltersAtom);

let queryVariables: ViewSheltersQueryVariables | undefined;
const queryVariables = useMemo<ViewSheltersQueryVariables | undefined>(() => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (complexity): Consider simplifying the query variable construction and splitting the map-fit effect into smaller focused effects to keep the new behaviors while making the code easier to follow.

You can reduce the added complexity in two focused spots without changing behavior: the query construction and the map‑fit effect.


1) Simplify queryVariables construction

The current useMemo uses a mutable vars with repeated vars = vars || {}; vars.filters = vars.filters || {}; which makes it harder to scan.

You can build a filters object linearly and wrap it at the end:

const queryVariables = useMemo<ViewSheltersQueryVariables | undefined>(() => {
  const filters: NonNullable<ViewSheltersQueryVariables['filters']> = {};

  if (coordinates && !mapBoundsFilter) {
    const { latitude, longitude } = coordinates;
    filters.geolocation = {
      latitude,
      longitude,
      rangeInMiles,
    };
  }

  if (mapBoundsFilter) {
    filters.mapBounds = mapBoundsFilter;
  }

  if (propertyFilters) {
    const { openNow, ...propertyOnlyFilters } = propertyFilters;

    if (openNow) {
      filters.openNow = true;
    }

    const prunedFilters = pruneFilters(propertyOnlyFilters);
    if (prunedFilters) {
      filters.properties = prunedFilters;
    }
  }

  if (nameFilter) {
    filters.name = nameFilter;
  }

  // Return undefined when no filters were set, to preserve current behavior
  return Object.keys(filters).length > 0 ? { filters } : undefined;
}, [coordinates, mapBoundsFilter, propertyFilters, rangeInMiles, nameFilter]);

This keeps all existing behavior (including the “no mapBounds ⇒ geolocation range” rule and pruneFilters semantics) but removes the repeated lazy-init boilerplate and makes each branch’s intent clearer.


2) Decompose onShelterPinsReadyForMapFit logic

The current effect mixes:

  • loading transition detection,
  • “handle each nameSearchPinFitRequestId once” logic, and
  • optional requestAnimationFrame scheduling

…all in one block. You can split the concerns and keep the same semantics (“call once per request id, after results are ready; use requestAnimationFrame when we didn’t just finish loading”).

First, track loading transitions in a tiny effect:

const wasLoadingRef = useRef(loading);

useEffect(() => {
  wasLoadingRef.current = loading;
}, [loading]);

Then, in a separate effect, handle the pin-fit callback:

const lastHandledRequestIdRef = useRef(0);

useEffect(() => {
  if (!onShelterPinsReadyForMapFit || nameSearchPinFitRequestId <= 0) {
    return;
  }
  if (lastHandledRequestIdRef.current === nameSearchPinFitRequestId) {
    return; // already handled this request
  }
  if (loading) {
    return; // wait until loading finishes
  }

  const emit = () => {
    if (lastHandledRequestIdRef.current === nameSearchPinFitRequestId) {
      return;
    }
    lastHandledRequestIdRef.current = nameSearchPinFitRequestId;
    onShelterPinsReadyForMapFit(shelterListToPinLatLng(items ?? []));
  };

  const loadingJustFinished = wasLoadingRef.current === true;

  if (loadingJustFinished) {
    emit();
    return;
  }

  const raf = requestAnimationFrame(emit);
  return () => cancelAnimationFrame(raf);
}, [loading, items, nameSearchPinFitRequestId, onShelterPinsReadyForMapFit]);

This preserves:

  • the “only once per nameSearchPinFitRequestId” behavior,
  • the special handling when a request has just finished loading vs. when we’re already idle, and
  • the use of requestAnimationFrame when appropriate,

while making the mental model (“track loading separately, then react to (id, items, loading)”) more explicit and easier to maintain.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 1, 2026

🔍 [shelter-web] Preview available at: https://shelter.dev.betterangels.la/branches/SDB-121-shelter-search

Last updated: 2026-04-02T22:36:08.038Z

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 1, 2026

🔍 [storybook-react] Preview available at: https://storybook.dev.betterangels.la/branches/SDB-121-shelter-search

Last updated: 2026-04-02T22:36:08.050Z

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 1, 2026

🔍 [betterangels-admin] Preview available at: https://admin.dev.betterangels.la/branches/SDB-121-shelter-search

Last updated: 2026-04-02T22:36:08.052Z

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.

2 participants