Skip to content

F-018: Filter Refinement

Status: ✅ Done · Priority: P1 · Branch: feature/F-018-filter-refinement · Updated: Mar 5, 2026

Summary

Comprehensive filter UX overhaul and map/grid view merge:

  1. Price range — Dual-thumb range slider ($0–$5,000+, step $50) with range labels
  2. Property type — Single-select dropdown → multi-select with checkboxes
  3. Bedrooms — Single-select dropdown → multi-select with checkboxes
  4. Postal code — New text input filter by postal code prefix (e.g., H2T)
  5. Area range — Dual-thumb range slider (0–3,000+ ft², step 50) with range labels
  6. Available date — Removed (felt out of place, backend API param kept)
  7. Filter sheet — All filters in a sheet/drawer (bottom sheet mobile, centered dialog desktop)
  8. Grid/Map view toggle — Merged map into listings page with shared filters
  9. Bug fixes — Fixed /mo/mo price display in map popups, improved stacked popup image transitions

Requirements

  • [x] Price filter: dual-thumb range slider with range labels ($0–$5,000+)
  • [x] Property type filter: multi-select with checkboxes (like neighborhoods)
  • [x] Bedrooms filter: multi-select with checkboxes (like neighborhoods)
  • [x] Backend supports comma-separated propertyType and bedrooms params
  • [x] All filters URL-synced (bookmarkable)
  • [x] Backward-compatible with existing single-value URLs
  • [x] Bilingual labels (FR/EN)
  • [x] Postal code prefix filter
  • [x] Available date filter — removed from UI, backend kept
  • [x] Area range filter: dual-thumb range slider with range labels (0–3,000+ ft²)
  • [x] Filter sheet: bottom sheet (mobile), centered dialog (desktop)
  • [x] Grid/Map view toggle on listings page
  • [x] Map uses same filters as grid, bounds-based query (200 limit)
  • [x] Old /map route redirects to /listings?view=map
  • [x] Fix /mo/mo price display bug in map popups
  • [x] Preload images in stacked popup for smooth navigation

Design

Price Range

  • Popover with dual-thumb <Slider> ($0–$5,000, step $50) + two number inputs for precise entry
  • User types in dollars, URL stores cents (×100)
  • Slider fires on onValueCommit (mouse/touch release), inputs use 500ms debounce
  • Button label: "$500 – $2,000" when active, "Price" when inactive
  • Uses components/ui/slider.tsx — shadcn-style wrapper around Radix UI Slider

Property Type Multi-Select

  • Popover + checkboxes iterating all 9 property types
  • Button label: "{N} selected" or "Type"
  • Clear button: "All Types"
  • Backend: propertyType=apartment,condoinArray() query
  • Reuses the neighborhoods popover pattern

Bedrooms Multi-Select

  • Popover + checkboxes: Studio, 1 bed, 2 beds, 3 beds, 4+ beds
  • Backend: bedrooms=0,1,2inArray() for exact values, gte(4) for 4+
  • Mixed: bedrooms=1,4bedrooms IN (1) OR bedrooms >= 4

URL Format

?propertyType=apartment,condo&bedrooms=0,1,2&minPrice=80000&maxPrice=150000

Files Modified

FileChange
services/api/src/routes/listings.tsMulti-value propertyType (inArray) and bedrooms (string type, comma-split, 4+ OR logic)
services/web/lib/api.tspropertyType: string[], bedrooms: number[], generalized array serialization, removed availableBy
services/web/hooks/use-listings-filters.tsParse comma-separated arrays from URL, view/setView (separate from API filters), clearFilters preserves view, removed availableBy
services/web/components/listings/listing-filters.tsxAll filters in vertical sheet layout, price/area as inline dual-thumb sliders with range labels, removed number inputs
services/web/components/ui/slider.tsxNEW — shadcn-style Radix UI Slider wrapper (dual-thumb support)
services/web/components/ui/sheet.tsxNEW — Radix Dialog as sheet (bottom sheet mobile, centered dialog desktop)
services/web/app/[locale]/listings/page.tsxGrid/Map view toggle, filter sheet, conditional grid/map rendering, separate queries
services/web/app/[locale]/map/page.tsxReplaced with client redirect to /listings?view=map
services/web/components/layout/header.tsxRemoved map nav link
services/web/components/map/listing-map.tsxFixed /mo/mo price duplication in popup
services/web/components/map/stacked-popup.tsxFixed /mo/mo, added image preloading for smooth navigation
services/web/messages/en.jsonAdded filter sheet, view toggle, slider label keys
services/web/messages/fr.jsonSame

Discussion Notes

Mar 5, 2026

  • Merged map into listings page with grid/map view toggle — removes need for separate /map route
  • Fixed /mo/mo price duplication bug in map popups (formatPriceShort already includes /mo suffix)
  • Added image preloading in stacked popup for smooth listing navigation

Mar 4, 2026

  • Moved all filters into sheet/drawer (bottom sheet mobile, centered dialog desktop)
  • Price and area sliders changed from popover+inputs to inline range sliders with labels
  • Range labels show "+" at max values ($5,000+, 3,000+ ft²) to indicate uncapped range
  • Removed available date filter from UI (backend param kept)
  • Neighborhoods filter already uses multi-select popover — reuse same pattern for property type and bedrooms
  • Price preset buckets too rigid — users want custom ranges
  • Backend already supports minPrice/maxPrice independently, only needs propertyType and bedrooms multi-value support
  • bedrooms=4 means "4+" (gte), combined with exact values via OR

Implementation Notes

Backend (services/api/src/routes/listings.ts)

  • propertyType param: split by comma, use inArray() for multiple values, eq() for single
  • bedrooms param: changed from integer to string type. Splits CSV, maps to numbers. Exact values use inArray(), value 4+ uses gte(4). Multiple conditions combined with or()
  • Backward-compatible: single values like ?propertyType=apartment or ?bedrooms=2 still work

Frontend

  • API client (lib/api.ts): propertyTypestring[], bedroomsnumber[]. Generalized array serialization (all arrays join with commas)
  • Filter hook (hooks/use-listings-filters.ts): Parses propertyType and bedrooms as comma-separated arrays from URL params. Added number[] to value union type
  • Filter UI (components/listings/listing-filters.tsx): Property type and bedrooms use popover + checkbox pattern (same as neighborhoods). Price ($0–$5,000) and area (0–3,000 ft²) use inline dual-thumb range sliders with number inputs below — no popover wrapper. Slider fires on onValueCommit (mouse/touch release), inputs use 500ms debounce. User types dollars, URL stores cents (×100). useEffect syncs local state on external clear. Responsive layout: flex-col on mobile, flex-row flex-wrap on desktop (md: breakpoint)
  • Slider component (components/ui/slider.tsx): shadcn-style wrapper around radix-ui Slider. Renders one thumb per value (dual-thumb for value={[min, max]})
  • Sheet component (components/ui/sheet.tsx): Bottom sheet using Radix Dialog, slide-up animation, drag handle visual, close button. Used for mobile filter overlay
  • Mobile filter sheet: On mobile (<md), filters are hidden from inline layout. A floating "Filters" button (fixed bottom-right) opens a bottom sheet with all filters stacked vertically. Active filter indicator badge on button
  • Translations: Added minPrice/maxPrice/postalCode/price/area/filterButton keys, removed 8 preset price range keys (under800, range800_1200, etc.) from both EN and FR

Postal Code Filter

  • Backend: ILIKE prefix match on address_postal_code (strips whitespace, uppercases). E.g., ?postalCode=H2T matches H2T 1S6
  • Frontend: Text input, uppercases on type, maxLength 7
  • All 4,578 active listings (100%) have a postal code — no data gap

Available Date Filter (removed from UI)

  • Backend: lte(listings.availableDate, new Date(query.availableBy)) — API param kept for backward compatibility, but not exposed in filter UI
  • Removed: felt out of place in filter bar, low user value

Area Range Filter

  • Backend: gte(listings.areaSqFt, minArea) / lte(listings.areaSqFt, maxArea) — same pattern as price
  • Frontend: Inline dual-thumb range slider (0–3,000+ ft²) with range labels below
  • 81% of active listings have area data

Filter Sheet UX

  • All filters moved into a sheet triggered by "Filters" button (next to search bar)
  • Mobile: bottom sheet with slide-up animation (inset-x-0 bottom-0 rounded-t-xl)
  • Desktop: centered dialog with fade+zoom animation (md:left-1/2 md:top-1/2 md:-translate-x-1/2 md:-translate-y-1/2)
  • Built on Radix Dialog primitives with tw-animate-css for enter/exit animations
  • Visible title, instant apply (no apply button needed), clear all button
  • Filters button shows indicator badge when filters are active

Grid/Map View Merge

  • View toggle in listings page header (LayoutGrid / MapPin icons)
  • view URL param (grid | map) managed separately from ListingsFilters — not sent to API
  • Grid query: paginated (limit 20), enabled only in grid view
  • Map query: bounds-based (limit 200, no pagination), enabled only in map view, includes all active filters
  • clearFilters preserves the current view param
  • Old /map page replaced with client-side redirect to /listings?view=map
  • Map nav link removed from header

Bug Fixes

  • /mo/mo in map popups: formatPriceShort() already returns $X/mo, but popup templates appended an extra /mo. Fixed in both listing-map.tsx (single popup) and stacked-popup.tsx (stacked popup)
  • Stacked popup image smoothness: Added useEffect to preload all listing images when stacked popup opens. Added CSS transition-opacity on images for visual smoothness