From 7330eabebec5d8b26ade436de715e5a413161422 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Thu, 27 Aug 2026 02:40:58 -0400 Subject: [PATCH] Compute nearby-brewery distance in SQL instead of client-side Haversine, and add the spatial index it relies on. --- .../01-schema/11-BreweryPostLocation.sql | 18 +- .../Domain.Entities/Entities/BreweryPost.cs | 3 + .../Entities/BreweryPostLocation.cs | 2 + .../Features.Breweries/Dtos/BreweryDto.cs | 7 +- .../Dtos/BreweryDtoMapper.cs | 3 +- .../Repository/BreweryRepository.cs | 34 ++- .../breweries/BREWERY_INDEX_HANDOFF.md | 10 +- .../features/breweries/breweries.server.ts | 1 + .../components/NearbyBreweriesSection.tsx | 266 ++++++++++++------ .../app/features/breweries/utils/distance.ts | 21 -- 10 files changed, 244 insertions(+), 121 deletions(-) diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostLocation.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostLocation.sql index 2b6cf591..f3eefa9a 100644 --- a/web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostLocation.sql +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostLocation.sql @@ -41,14 +41,10 @@ CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City ON BreweryPostLocation(CityID); --- To assess when the time comes: - --- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations. - --- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates --- ON BreweryPostLocation(Coordinates) --- USING GEOGRAPHY_GRID --- WITH ( --- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM), --- CELLS_PER_OBJECT = 16 --- ); +CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates + ON BreweryPostLocation(Coordinates) + USING GEOGRAPHY_GRID + WITH ( + GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM), + CELLS_PER_OBJECT = 16 + ); diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs index 46a4efca..384473c1 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs @@ -21,4 +21,7 @@ public sealed class BreweryPost // Navigation Properties public BreweryPostLocation? Location { get; set; } public List BeerPosts { get; set; } = []; + + // Query-computed + public DistanceInformation? Distance { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs index 24222197..a0d4e3e2 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs @@ -2,6 +2,8 @@ namespace Domain.Entities; public sealed record CoordinateData(double Latitude, double Longitude); +/// The distance, as computed by the database, from a query's origin point to a brewery. +public sealed record DistanceInformation(CoordinateData From, double DistanceMetres); public sealed class BreweryPostLocation { diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs index ceaee690..f9801023 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs @@ -32,8 +32,13 @@ public record BreweryDto( byte[]? RowVersion, BreweryLocationDto? Location ); +/// +/// The distance, in metres, from a query's origin point, or for queries with no +/// origin point (e.g. GET /api/brewery/locations). +/// public record SimplifiedBreweryDto( Guid BreweryPostId, string BreweryName, - BreweryLocationDto? Location + BreweryLocationDto? Location, + double? DistanceMetres ); diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs index 6b3f62c1..f8c5f792 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs @@ -68,7 +68,8 @@ public static class BreweryDtoMapper AddressLine2: brewery.Location.AddressLine2, PostalCode: brewery.Location.PostalCode, Coordinates: brewery.Location.Coordinates - ) + ), + DistanceMetres: brewery.Distance?.DistanceMetres ); } } diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index 7fa18d12..437a0f54 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -134,6 +134,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) City, StateProvince, Country, + DistanceRow, BreweryPost > ( """ @@ -154,7 +155,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) sp.ISO3166_2 AS Iso31662, co.CountryID, co.CountryName, - co.ISO3166_1 AS Iso31661 + co.ISO3166_1 AS Iso31661, + bpl.Coordinates.STDistance(@Origin) AS DistanceMetres FROM dbo.BreweryPost bp INNER JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID LEFT JOIN dbo.City c ON bpl.CityID = c.CityID @@ -164,14 +166,15 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres ORDER BY bpl.Coordinates.STDistance(@Origin) ASC """, - MapBreweryRow, + (post, location, city, stateProvince, country, distance) => + MapBreweryRowWithDistance(post, location, city, stateProvince, country, distance, coords), param: new { coords.Latitude, coords.Longitude, RangeInMetres = rangeInMetres }, - splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" + splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres" ); } @@ -583,4 +586,29 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) post.Location = location; return post; } + + /// Dapper multi-mapping row for the DistanceMetres column computed by a range query. + private sealed class DistanceRow + { + public double DistanceMetres { get; set; } + } + + /// + /// Composes a joined row via , then attaches the DB-computed distance + /// from as the result's . + /// + private static BreweryPost MapBreweryRowWithDistance( + BreweryPost post, + BreweryPostLocation location, + City city, + StateProvince stateProvince, + Country country, + DistanceRow distance, + CoordinateData origin + ) + { + BreweryPost mapped = MapBreweryRow(post, location, city, stateProvince, country); + mapped.Distance = new DistanceInformation(origin, distance.DistanceMetres); + return mapped; + } } \ No newline at end of file diff --git a/web/frontend/app/features/breweries/BREWERY_INDEX_HANDOFF.md b/web/frontend/app/features/breweries/BREWERY_INDEX_HANDOFF.md index 4a1c7b23..2f30ce85 100644 --- a/web/frontend/app/features/breweries/BREWERY_INDEX_HANDOFF.md +++ b/web/frontend/app/features/breweries/BREWERY_INDEX_HANDOFF.md @@ -18,7 +18,7 @@ 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). | @@ -28,9 +28,9 @@ Static copy only. No data. ## 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 | — | +| Distance from search center | ✅ | `SimplifiedBreweryDto.DistanceMetres`, computed server-side via `geography::STDistance` | — | | 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. | @@ -40,7 +40,7 @@ Static copy only. No data. ## 4. Recently added | Field | Status | Current source | Gap | -|-----------------------------------------|--------|--------------------------------------------------|-----| +| --------------------------------------- | ------ | ------------------------------------------------ | --- | | Newest breweries, name/city/description | ✅ | `GET /api/brewery` (already sorted newest-first) | — | No gaps — this section is fully real. @@ -48,7 +48,7 @@ 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. | diff --git a/web/frontend/app/features/breweries/breweries.server.ts b/web/frontend/app/features/breweries/breweries.server.ts index a2db14ce..f02bec01 100644 --- a/web/frontend/app/features/breweries/breweries.server.ts +++ b/web/frontend/app/features/breweries/breweries.server.ts @@ -41,6 +41,7 @@ export interface SimplifiedBrewery { breweryPostId: string; breweryName: string; location: BreweryLocation | null; + distanceMetres: number | null; } const fetchApi = async (path: string): Promise => { diff --git a/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx b/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx index 6cfd4f24..8c750d7b 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, type DistanceUnit } from '../utils/distance'; +import { formatDistance, type DistanceUnit } from '../utils/distance'; import type { NearbyMapPin } from './NearbyBreweryMap'; const NearbyBreweryMap = lazy(() => import('./NearbyBreweryMap')); @@ -43,6 +43,139 @@ const cardClassName = (selected: boolean) => selected ? 'border-l-primary' : 'border-l-transparent' }`; +interface BreweriesNearByHeaderParams { + center: LatLngTuple | null; + withDistance: { brewery: SimplifiedBrewery; distanceMetres: number }[]; + radiusKm: number; + unit: 'km' | 'mi'; + centerLabel: string; +} + +const BreweriesNearByHeader = ({ + center, + centerLabel, + radiusKm, + unit, + withDistance, +}: BreweriesNearByHeaderParams) => { + return ( +
+

Breweries near you

+ + {center + ? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}` + : 'Finding breweries near you…'} + +
+ ); +}; + +interface RadiusUnitControlsProps { + radiusKm: number; + unit: DistanceUnit; + onRadiusChange: (event: React.ChangeEvent) => void; + onSelectKm: () => void; + onSelectMi: () => void; +} + +const RadiusUnitControls = ({ + radiusKm, + unit, + onRadiusChange, + onSelectKm, + onSelectMi, +}: RadiusUnitControlsProps) => { + return ( +
+ +
+ + +
+
+ ); +}; + +interface BreweryWithDistance { + brewery: SimplifiedBrewery; + distanceMetres: number; +} + +interface NearbyBreweryListProps { + withDistance: BreweryWithDistance[]; + center: LatLngTuple | null; + radiusKm: number; + unit: DistanceUnit; + renderItem: (item: BreweryWithDistance) => React.JSX.Element; +} + +const NearbyBreweryList = ({ + withDistance, + center, + radiusKm, + unit, + renderItem, +}: NearbyBreweryListProps) => { + return ( + <> + {withDistance.length === 0 && center && ( +

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

+ )} + {withDistance.map(renderItem)} + + ); +}; + +interface ChangeLocationButtonProps { + onClick: () => void; +} + +const ChangeLocationButton = ({ onClick }: ChangeLocationButtonProps) => { + return ( +
+ +
+ ); +}; + +interface ClientOnlyMapProps { + render: () => React.JSX.Element; +} + +const ClientOnlyMap = ({ render }: ClientOnlyMapProps) => { + return }>{render}; +}; + const NearbyBreweriesSection = ({ featured, fallbackCenter, @@ -77,7 +210,10 @@ const NearbyBreweriesSection = ({ }, []); const center: LatLngTuple | null = - userLocation ?? (geoAttempted && fallbackCenter ? [fallbackCenter.latitude, fallbackCenter.longitude] : null); + userLocation ?? + (geoAttempted && fallbackCenter + ? [fallbackCenter.latitude, fallbackCenter.longitude] + : null); const centerLabel = userLocation ? locationLabel : (fallbackCenter?.label ?? locationLabel); useEffect(() => { @@ -91,13 +227,10 @@ const NearbyBreweriesSection = ({ const nearby: SimplifiedBrewery[] = fetcher.data?.breweries ?? []; const withDistance = center ? nearby - .filter((b) => b.location?.coordinates) + .filter((b) => b.location?.coordinates && b.distanceMetres !== null) .map((b) => ({ brewery: b, - distanceMetres: haversineDistanceMetres( - { latitude: center[0], longitude: center[1] }, - b.location!.coordinates!, - ), + distanceMetres: b.distanceMetres as number, })) .sort((a, b) => a.distanceMetres - b.distanceMetres) : []; @@ -105,7 +238,9 @@ const NearbyBreweriesSection = ({ const pins: NearbyMapPin[] = withDistance.map(({ brewery }) => ({ id: brewery.breweryPostId, name: brewery.breweryName, - cityLabel: brewery.location ? `${brewery.location.cityName}, ${brewery.location.stateProvinceCode}` : null, + cityLabel: brewery.location + ? `${brewery.location.cityName}, ${brewery.location.stateProvinceCode}` + : null, latitude: brewery.location!.coordinates!.latitude, longitude: brewery.location!.coordinates!.longitude, })); @@ -121,86 +256,59 @@ const NearbyBreweriesSection = ({ return (
-
-

Breweries near you

- - {center - ? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}` - : 'Finding breweries near you…'} - -
+
-
- -
+ setRadiusKm(Number(e.target.value))} + onSelectKm={() => setUnit('km')} + onSelectMi={() => setUnit('mi')} + /> + ( - -
-
- {withDistance.length === 0 && center && ( -

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

- )} - {withDistance.map(({ brewery, distanceMetres }) => ( - - ))} -
- -
+ {brewery.location && ( +
+ {brewery.location.cityName},{' '} + {brewery.location.stateProvinceCode} +
+ )} +
{FILLER_NOTE}
+ + )} + /> +
- }> - {() => + center ? ( }> ) } - + />
diff --git a/web/frontend/app/features/breweries/utils/distance.ts b/web/frontend/app/features/breweries/utils/distance.ts index 4451b681..68183f01 100644 --- a/web/frontend/app/features/breweries/utils/distance.ts +++ b/web/frontend/app/features/breweries/utils/distance.ts @@ -1,24 +1,3 @@ -const EARTH_RADIUS_METRES = 6_371_000; - -export interface Coordinates { - latitude: number; - longitude: number; -} - -export const haversineDistanceMetres = (a: Coordinates, b: Coordinates): number => { - const toRadians = (degrees: number) => (degrees * Math.PI) / 180; - const deltaLat = toRadians(b.latitude - a.latitude); - const deltaLng = toRadians(b.longitude - a.longitude); - const lat1 = toRadians(a.latitude); - const lat2 = toRadians(b.latitude); - - const h = - Math.sin(deltaLat / 2) ** 2 + - Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) ** 2; - - return 2 * EARTH_RADIUS_METRES * Math.asin(Math.sqrt(h)); -}; - export type DistanceUnit = 'km' | 'mi'; const METRES_PER_MILE = 1609.344;