From 345764655845d1433df92ab5bd3c85b357684053 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Thu, 27 Aug 2026 00:46:54 -0400 Subject: [PATCH] Let users toggle nearby-breweries distances between km and mi --- .../breweries/BREWERY_INDEX_HANDOFF.md | 65 +++++++++++-------- .../components/NearbyBreweriesSection.tsx | 57 ++++++++++------ .../app/features/breweries/utils/distance.ts | 11 +++- 3 files changed, 83 insertions(+), 50 deletions(-) diff --git a/web/frontend/app/features/breweries/BREWERY_INDEX_HANDOFF.md b/web/frontend/app/features/breweries/BREWERY_INDEX_HANDOFF.md index 055f3cf6..4a1c7b23 100644 --- a/web/frontend/app/features/breweries/BREWERY_INDEX_HANDOFF.md +++ b/web/frontend/app/features/breweries/BREWERY_INDEX_HANDOFF.md @@ -1,6 +1,9 @@ # Brewery index — data requirements -`/breweries` (`app/features/breweries/routes/breweries.tsx`) is built and live. This document inventories every piece of data the page needs, section by section: what's already wired to real data, what's filler pending backend work, and what the backend would need to add to close each gap. +`/breweries` (`app/features/breweries/routes/breweries.tsx`) is built and live. +This document inventories every piece of data the page needs, section by +section: what's already wired to real data, what's filler pending backend work, +and what the backend would need to add to close each gap. ## Status legend @@ -14,44 +17,47 @@ Static copy only. No data. ## 2. Featured brewery -| Field | Status | Current source | Gap | -|---|---|---|---| -| Name, description, address, location | ✅ | `GET /api/brewery` (`BreweryDto`) | — | -| "Editorially featured" selection | ❌ | none — defaults to the most recently created brewery | No `IsFeatured` flag or rotation concept anywhere in `BreweryPost`. Needs a field + an admin toggle, or a scheduled rotation query, to back the "THIS WEEK" cadence the badge implies. | -| Founded year | ❌ | none | `BreweryPost` has no founding date. Would need a new column (distinct from `CreatedAt`, which is post-creation time, not the brewery's actual founding). | -| Beers listed count | ❌ | none | No beer-count aggregate exists. Blocked on the beers gap below. | -| House style | ❌ | none | No style/tag field on `BreweryPost`, and no aggregate over its beers' styles either. | +| Field | Status | Current source | Gap | +|--------------------------------------|--------|------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Name, description, address, location | ✅ | `GET /api/brewery` (`BreweryDto`) | — | +| "Editorially featured" selection | ❌ | none — defaults to the most recently created brewery | No `IsFeatured` flag or rotation concept anywhere in `BreweryPost`. Needs a field + an admin toggle, or a scheduled rotation query, to back the "THIS WEEK" cadence the badge implies. | +| Founded year | ❌ | none | `BreweryPost` has no founding date. Would need a new column (distinct from `CreatedAt`, which is post-creation time, not the brewery's actual founding). | +| Beers listed count | ❌ | none | No beer-count aggregate exists. Blocked on the beers gap below. | +| House style | ❌ | none | No style/tag field on `BreweryPost`, and no aggregate over its beers' styles either. | ## 3. Breweries near you -| Field | Status | Current source | Gap | -|---|---|---|---| -| Nearby breweries by coordinates + radius | ✅ | `GET /api/brewery/locations/nearby?latitude&longitude&rangeInMetres` | — | -| Distance from search center | ✅ | Computed client-side (Haversine) from the coordinates the endpoint already returns | — | -| One-line note / tagline per brewery | ❌ | none | `SimplifiedBreweryDto` (used by both `/locations` and `/locations/nearby`) has no description-like field. List and map-popup text use filler. Needs either a short tagline field or a truncated `Description` added to that DTO. | -| User's current location | ✅ | Browser Geolocation API | — | -| Fallback region (before permission granted / on denial) | ⚠️ | Falls back to the featured brewery's own coordinates as a proxy center | Works, but isn't a deliberate "default region." A real deployment likely wants a configured default (e.g. per-tenant HQ city) rather than borrowing the featured brewery's location. | -| Manual location entry ("Change location") | ❌ | Re-requests browser geolocation only | No geocoding/place-search endpoint exists to resolve a typed address or city name to coordinates. | -| Search radius | ✅ | Client-side state (10–150 km slider), passed straight through to `rangeInMetres` | — | +| Field | Status | Current source | Gap | +|---------------------------------------------------------|--------|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Nearby breweries by coordinates + radius | ✅ | `GET /api/brewery/locations/nearby?latitude&longitude&rangeInMetres` | — | +| Distance from search center | ✅ | Computed client-side (Haversine) from the coordinates the endpoint already returns | — | +| One-line note / tagline per brewery | ❌ | none | `SimplifiedBreweryDto` (used by both `/locations` and `/locations/nearby`) has no description-like field. List and map-popup text use filler. Needs either a short tagline field or a truncated `Description` added to that DTO. | +| User's current location | ✅ | Browser Geolocation API | — | +| Fallback region (before permission granted / on denial) | ⚠️ | Falls back to the featured brewery's own coordinates as a proxy center | Works, but isn't a deliberate "default region." A real deployment likely wants a configured default (e.g. per-tenant HQ city) rather than borrowing the featured brewery's location. | +| Manual location entry ("Change location") | ❌ | Re-requests browser geolocation only | No geocoding/place-search endpoint exists to resolve a typed address or city name to coordinates. | +| Search radius | ✅ | Client-side state (10–150 km slider), passed straight through to `rangeInMetres` | — | ## 4. Recently added -| Field | Status | Current source | Gap | -|---|---|---|---| -| Newest breweries, name/city/description | ✅ | `GET /api/brewery` (already sorted newest-first) | — | +| Field | Status | Current source | Gap | +|-----------------------------------------|--------|--------------------------------------------------|-----| +| Newest breweries, name/city/description | ✅ | `GET /api/brewery` (already sorted newest-first) | — | No gaps — this section is fully real. ## 5. Directory CTA -| Field | Status | Current source | Gap | -|---|---|---|---| -| Total partner brewery count | ❌ | Filler number (`128`) | No lightweight count endpoint. Needs something like `GET /api/brewery/count` rather than fetching the full unbounded list just to measure it. | -| Filter by country / region / beer style | ❌ | Copy only — not implemented | This is CTA copy describing the destination directory page's future capability, not a data need of this page itself. Tracked here because it implies the directory page will eventually need country/region/style filter params. | +| Field | Status | Current source | Gap | +|-----------------------------------------|--------|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Total partner brewery count | ❌ | Filler number (`128`) | No lightweight count endpoint. Needs something like `GET /api/brewery/count` rather than fetching the full unbounded list just to measure it. | +| Filter by country / region / beer style | ❌ | Copy only — not implemented | This is CTA copy describing the destination directory page's future capability, not a data need of this page itself. Tracked here because it implies the directory page will eventually need country/region/style filter params. | ## Cross-cutting: the Beers feature is empty -`Features.Beers` (backend) has a `.csproj` and nothing else — no query handlers, no controller, no DTOs. `BeerPost` exists as a domain entity (with `BrewedById` linking to `BreweryPost`) but nothing exposes it over the API. This blocks: +`Features.Beers` (backend) has a `.csproj` and nothing else — no query handlers, +no controller, no DTOs. `BeerPost` exists as a domain entity (with `BrewedById` +linking to `BreweryPost`) but nothing exposes it over the API. This blocks: + - "Beers listed" count on the featured card. - "House style" (would likely aggregate from a brewery's beers' `BeerStyle`). - Any future beer-style filter on the directory. @@ -60,8 +66,11 @@ No gaps — this section is fully real. 1. `IsFeatured` flag (or equivalent rotation query) on `BreweryPost`. 2. `FoundedYear` (or similar) column on `BreweryPost`. -3. A short tagline/note field on `BreweryPost`, surfaced through `SimplifiedBreweryDto`. -4. Stand up `Features.Beers` — at minimum a query to count beers per brewery and read their styles. +3. A short tagline/note field on `BreweryPost`, surfaced through + `SimplifiedBreweryDto`. +4. Stand up `Features.Beers` — at minimum a query to count beers per brewery and + read their styles. 5. `GET /api/brewery/count`. 6. A geocoding/place-search endpoint for manual location entry. -7. (Product decision, not code) a configured default region to fall back to instead of the featured brewery's coordinates. +7. (Product decision, not code) a configured default region to fall back to + instead of the featured brewery's coordinates. diff --git a/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx b/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx index 6660222d..6cfd4f24 100644 --- a/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx +++ b/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx @@ -4,7 +4,7 @@ import type { LatLngTuple } from 'leaflet'; import ClientOnly from '../../../components/ClientOnly'; import type { SimplifiedBrewery } from '../breweries.server'; import type { loader as nearbyLoader } from '../routes/breweries-nearby'; -import { formatDistance, haversineDistanceMetres } from '../utils/distance'; +import { formatDistance, haversineDistanceMetres, type DistanceUnit } from '../utils/distance'; import type { NearbyMapPin } from './NearbyBreweryMap'; const NearbyBreweryMap = lazy(() => import('./NearbyBreweryMap')); @@ -53,6 +53,7 @@ const NearbyBreweriesSection = ({ const [locationLabel, setLocationLabel] = useState('your location'); const [geoAttempted, setGeoAttempted] = useState(false); const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM); + const [unit, setUnit] = useState('km'); const fetcher = useFetcher(); const requestGeolocation = () => { @@ -124,31 +125,49 @@ const NearbyBreweriesSection = ({

Breweries near you

{center - ? `${withDistance.length} within ${radiusKm} km of ${centerLabel}` + ? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}` : 'Finding breweries near you…'}
- +
+ +
+ + +
+
{withDistance.length === 0 && center && (

- No partner breweries within {radiusKm} km. + No partner breweries within {formatDistance(radiusKm * 1000, unit)}.

)} {withDistance.map(({ brewery, distanceMetres }) => ( @@ -161,7 +180,7 @@ const NearbyBreweriesSection = ({

{brewery.breweryName}

- {formatDistance(distanceMetres)} + {formatDistance(distanceMetres, unit)}
{brewery.location && ( diff --git a/web/frontend/app/features/breweries/utils/distance.ts b/web/frontend/app/features/breweries/utils/distance.ts index b0b949ba..4451b681 100644 --- a/web/frontend/app/features/breweries/utils/distance.ts +++ b/web/frontend/app/features/breweries/utils/distance.ts @@ -19,7 +19,12 @@ export const haversineDistanceMetres = (a: Coordinates, b: Coordinates): number return 2 * EARTH_RADIUS_METRES * Math.asin(Math.sqrt(h)); }; -export const formatDistance = (metres: number): string => { - const km = metres / 1000; - return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`; +export type DistanceUnit = 'km' | 'mi'; + +const METRES_PER_MILE = 1609.344; + +export const formatDistance = (metres: number, unit: DistanceUnit = 'km'): string => { + const value = unit === 'mi' ? metres / METRES_PER_MILE : metres / 1000; + const rounded = value < 10 ? value.toFixed(1) : String(Math.round(value)); + return `${rounded} ${unit}`; };