mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Let users toggle nearby-breweries distances between km and mi
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
# Brewery index — data requirements
|
# 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
|
## Status legend
|
||||||
|
|
||||||
@@ -15,7 +18,7 @@ Static copy only. No data.
|
|||||||
## 2. Featured brewery
|
## 2. Featured brewery
|
||||||
|
|
||||||
| Field | Status | Current source | Gap |
|
| Field | Status | Current source | Gap |
|
||||||
|---|---|---|---|
|
|--------------------------------------|--------|------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| Name, description, address, location | ✅ | `GET /api/brewery` (`BreweryDto`) | — |
|
| 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. |
|
| "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). |
|
| 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). |
|
||||||
@@ -25,7 +28,7 @@ Static copy only. No data.
|
|||||||
## 3. Breweries near you
|
## 3. Breweries near you
|
||||||
|
|
||||||
| Field | Status | Current source | Gap |
|
| Field | Status | Current source | Gap |
|
||||||
|---|---|---|---|
|
|---------------------------------------------------------|--------|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| Nearby breweries by coordinates + radius | ✅ | `GET /api/brewery/locations/nearby?latitude&longitude&rangeInMetres` | — |
|
| 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 | ✅ | 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. |
|
| 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. |
|
||||||
@@ -37,7 +40,7 @@ Static copy only. No data.
|
|||||||
## 4. Recently added
|
## 4. Recently added
|
||||||
|
|
||||||
| Field | Status | Current source | Gap |
|
| Field | Status | Current source | Gap |
|
||||||
|---|---|---|---|
|
|-----------------------------------------|--------|--------------------------------------------------|-----|
|
||||||
| Newest breweries, name/city/description | ✅ | `GET /api/brewery` (already sorted newest-first) | — |
|
| Newest breweries, name/city/description | ✅ | `GET /api/brewery` (already sorted newest-first) | — |
|
||||||
|
|
||||||
No gaps — this section is fully real.
|
No gaps — this section is fully real.
|
||||||
@@ -45,13 +48,16 @@ No gaps — this section is fully real.
|
|||||||
## 5. Directory CTA
|
## 5. Directory CTA
|
||||||
|
|
||||||
| Field | Status | Current source | Gap |
|
| 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. |
|
| 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. |
|
| 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
|
## 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.
|
- "Beers listed" count on the featured card.
|
||||||
- "House style" (would likely aggregate from a brewery's beers' `BeerStyle`).
|
- "House style" (would likely aggregate from a brewery's beers' `BeerStyle`).
|
||||||
- Any future beer-style filter on the directory.
|
- 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`.
|
1. `IsFeatured` flag (or equivalent rotation query) on `BreweryPost`.
|
||||||
2. `FoundedYear` (or similar) column on `BreweryPost`.
|
2. `FoundedYear` (or similar) column on `BreweryPost`.
|
||||||
3. A short tagline/note field on `BreweryPost`, surfaced through `SimplifiedBreweryDto`.
|
3. A short tagline/note field on `BreweryPost`, surfaced through
|
||||||
4. Stand up `Features.Beers` — at minimum a query to count beers per brewery and read their styles.
|
`SimplifiedBreweryDto`.
|
||||||
|
4. Stand up `Features.Beers` — at minimum a query to count beers per brewery and
|
||||||
|
read their styles.
|
||||||
5. `GET /api/brewery/count`.
|
5. `GET /api/brewery/count`.
|
||||||
6. A geocoding/place-search endpoint for manual location entry.
|
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.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { LatLngTuple } from 'leaflet';
|
|||||||
import ClientOnly from '../../../components/ClientOnly';
|
import ClientOnly from '../../../components/ClientOnly';
|
||||||
import type { SimplifiedBrewery } from '../breweries.server';
|
import type { SimplifiedBrewery } from '../breweries.server';
|
||||||
import type { loader as nearbyLoader } from '../routes/breweries-nearby';
|
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';
|
import type { NearbyMapPin } from './NearbyBreweryMap';
|
||||||
|
|
||||||
const NearbyBreweryMap = lazy(() => import('./NearbyBreweryMap'));
|
const NearbyBreweryMap = lazy(() => import('./NearbyBreweryMap'));
|
||||||
@@ -53,6 +53,7 @@ const NearbyBreweriesSection = ({
|
|||||||
const [locationLabel, setLocationLabel] = useState('your location');
|
const [locationLabel, setLocationLabel] = useState('your location');
|
||||||
const [geoAttempted, setGeoAttempted] = useState(false);
|
const [geoAttempted, setGeoAttempted] = useState(false);
|
||||||
const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM);
|
const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM);
|
||||||
|
const [unit, setUnit] = useState<DistanceUnit>('km');
|
||||||
const fetcher = useFetcher<typeof nearbyLoader>();
|
const fetcher = useFetcher<typeof nearbyLoader>();
|
||||||
|
|
||||||
const requestGeolocation = () => {
|
const requestGeolocation = () => {
|
||||||
@@ -124,17 +125,18 @@ const NearbyBreweriesSection = ({
|
|||||||
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
|
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
|
||||||
<span className="text-sm text-base-content/60">
|
<span className="text-sm text-base-content/60">
|
||||||
{center
|
{center
|
||||||
? `${withDistance.length} within ${radiusKm} km of ${centerLabel}`
|
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
|
||||||
: 'Finding breweries near you…'}
|
: 'Finding breweries near you…'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 items-stretch md:grid-cols-[22rem_1fr]">
|
<div className="grid gap-5 items-stretch md:grid-cols-[22rem_1fr]">
|
||||||
<div className="flex flex-col gap-3 order-2 md:order-1">
|
<div className="flex flex-col gap-3 order-2 md:order-1">
|
||||||
<label className="flex flex-col gap-1 text-sm text-base-content/70">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<label className="flex-1 flex flex-col gap-1 text-sm text-base-content/70">
|
||||||
<span className="flex justify-between">
|
<span className="flex justify-between">
|
||||||
<span>Search radius</span>
|
<span>Search radius</span>
|
||||||
<span className="font-semibold">{radiusKm} km</span>
|
<span className="font-semibold">{formatDistance(radiusKm * 1000, unit)}</span>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -146,9 +148,26 @@ const NearbyBreweriesSection = ({
|
|||||||
className="range range-primary range-sm"
|
className="range range-primary range-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="join self-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnit('km')}
|
||||||
|
className={`btn btn-xs join-item ${unit === 'km' ? 'btn-active' : ''}`}
|
||||||
|
>
|
||||||
|
km
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnit('mi')}
|
||||||
|
className={`btn btn-xs join-item ${unit === 'mi' ? 'btn-active' : ''}`}
|
||||||
|
>
|
||||||
|
mi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{withDistance.length === 0 && center && (
|
{withDistance.length === 0 && center && (
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/60">
|
||||||
No partner breweries within {radiusKm} km.
|
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{withDistance.map(({ brewery, distanceMetres }) => (
|
{withDistance.map(({ brewery, distanceMetres }) => (
|
||||||
@@ -161,7 +180,7 @@ const NearbyBreweriesSection = ({
|
|||||||
<div className="flex justify-between items-baseline gap-3">
|
<div className="flex justify-between items-baseline gap-3">
|
||||||
<h3 className="font-serif text-lg m-0">{brewery.breweryName}</h3>
|
<h3 className="font-serif text-lg m-0">{brewery.breweryName}</h3>
|
||||||
<span className="text-sm font-semibold text-primary whitespace-nowrap">
|
<span className="text-sm font-semibold text-primary whitespace-nowrap">
|
||||||
{formatDistance(distanceMetres)}
|
{formatDistance(distanceMetres, unit)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export const haversineDistanceMetres = (a: Coordinates, b: Coordinates): number
|
|||||||
return 2 * EARTH_RADIUS_METRES * Math.asin(Math.sqrt(h));
|
return 2 * EARTH_RADIUS_METRES * Math.asin(Math.sqrt(h));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDistance = (metres: number): string => {
|
export type DistanceUnit = 'km' | 'mi';
|
||||||
const km = metres / 1000;
|
|
||||||
return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`;
|
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}`;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user