Skip to content

Commit 201caeb

Browse files
committed
docs(adr-0006): POI provider strategy — hybrid by category, phased
- Foursquare for café/restaurant (commercial), Geoapify/OSM for park/beach/viewpoint/museum (geography) - CompositePoiProvider routes by category behind PoiProvider interface - Phase 0 (now): Geoapify only, gap acknowledged - Phase 1 (when Foursquare account ready): T-16 - Phase 3 (suggested-places panel): T-17 (depends on T-16) - README + research note + TASKS.md updated
1 parent 86ca313 commit 201caeb

9 files changed

Lines changed: 233 additions & 24 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ Open <http://localhost:3000>. See [docs/DEVELOPING.md](docs/DEVELOPING.md) for t
2525
- [ADR-0002 — Isochrone engine](docs/adr/0002-isochrone-engine.md)
2626
- [ADR-0003 — POI and tile providers](docs/adr/0003-poi-and-tile-providers.md)
2727
- [ADR-0004 — Deployment and AI workflow](docs/adr/0004-deployment-and-ai-workflow.md)
28+
- [ADR-0005 — Navigation handoff](docs/adr/0005-navigation-handoff.md)
29+
- [ADR-0006 — POI provider strategy (hybrid plan)](docs/adr/0006-poi-provider-strategy.md)
2830

2931
## Stack
3032

apps/web/src/app/page.tsx

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import {
2121
import { TimeSelector } from '@/components/controls/TimeSelector';
2222
import { ModeSelector } from '@/components/controls/ModeSelector';
2323
import { CategoryToggles } from '@/components/controls/CategoryToggles';
24+
import { SurpriseMe } from '@/components/controls/SurpriseMe';
2425
import { DestinationCard } from '@/components/destination/DestinationCard';
2526
import { tryGeolocate } from '@/lib/geolocation';
2627
import { useIsochrone } from '@/lib/hooks/useIsochrone';
2728
import { usePois } from '@/lib/hooks/usePois';
28-
import { bboxOf } from '@/lib/polygon';
29+
import { bboxOf, isInsidePolygon } from '@/lib/polygon';
2930
import { PUBLIC_CONFIG } from '@/lib/config';
3031
import {
3132
DEFAULT_CATEGORIES,
@@ -72,6 +73,12 @@ export default function HomePage() {
7273
const [state, setState] = useState<AppUrlState>(initialState);
7374
const [destination, setDestination] = useState<{ lng: number; lat: number } | null>(null);
7475
const [selectedPoi, setSelectedPoi] = useState<Poi | null>(null);
76+
const [cameraTarget, setCameraTarget] = useState<{
77+
lng: number;
78+
lat: number;
79+
zoom?: number;
80+
key: number;
81+
} | null>(null);
7582

7683
// On mount: if no `lng`/`lat` in URL, ask for geolocation and use it.
7784
const askedGeoRef = useRef(false);
@@ -152,7 +159,37 @@ export default function HomePage() {
152159
bbox: polygonBbox,
153160
categories: state.categories,
154161
});
155-
const pois = poiData?.pois ?? [];
162+
163+
// Polygon-clip the fetched POIs once and share the visible set with both
164+
// PoiLayer (renders them) and SurpriseMe (picks from them).
165+
const visiblePois = useMemo<Poi[]>(() => {
166+
const list = poiData?.pois ?? [];
167+
if (!data?.polygon) return list;
168+
return list.filter((p) => isInsidePolygon([p.lngLat[0], p.lngLat[1]], data.polygon));
169+
}, [poiData?.pois, data?.polygon]);
170+
171+
// If the selected POI falls out of the visible set (categories or time
172+
// changed), dismiss it so the card doesn't orphan over an absent marker.
173+
useEffect(() => {
174+
if (!selectedPoi) return;
175+
const stillVisible = visiblePois.some((p) => p.id === selectedPoi.id);
176+
if (!stillVisible) setSelectedPoi(null);
177+
}, [visiblePois, selectedPoi]);
178+
179+
const onSurprise = useCallback(() => {
180+
if (visiblePois.length === 0) return;
181+
const idx = Math.floor(Math.random() * visiblePois.length);
182+
const pick = visiblePois[idx];
183+
if (!pick) return;
184+
setSelectedPoi(pick);
185+
setDestination(null);
186+
setCameraTarget({
187+
lng: pick.lngLat[0],
188+
lat: pick.lngLat[1],
189+
zoom: 16,
190+
key: Date.now(),
191+
});
192+
}, [visiblePois]);
156193

157194
// Determine which destination (if any) gets the popup. POI selection wins
158195
// over a right-click drop point.
@@ -224,12 +261,12 @@ export default function HomePage() {
224261
}
225262
poiMarkers={
226263
<PoiLayer
227-
pois={pois}
228-
polygon={data?.polygon}
264+
pois={visiblePois}
229265
onSelect={onPoiSelect}
230266
selectedId={selectedPoi?.id}
231267
/>
232268
}
269+
cameraTarget={cameraTarget ?? undefined}
233270
/>
234271
</div>
235272

@@ -248,7 +285,18 @@ export default function HomePage() {
248285
<ModeSelector value={state.mode} onChange={onModeChange} />
249286
<TimeSelector value={state.minutes} onChange={onMinutesChange} />
250287
</div>
251-
<CategoryToggles value={state.categories} onChange={onCategoriesChange} />
288+
<div className="flex flex-wrap items-center gap-2">
289+
<CategoryToggles value={state.categories} onChange={onCategoriesChange} />
290+
<SurpriseMe
291+
disabled={visiblePois.length === 0}
292+
onClick={onSurprise}
293+
title={
294+
visiblePois.length === 0
295+
? 'No reachable POIs yet — toggle a category or expand time'
296+
: `Pick one of ${visiblePois.length} reachable places`
297+
}
298+
/>
299+
</div>
252300
</div>
253301
</header>
254302

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
3+
import { Shuffle } from 'lucide-react';
4+
import { cn } from '@/lib/utils';
5+
6+
interface Props {
7+
disabled?: boolean;
8+
onClick: () => void;
9+
className?: string;
10+
/** Tooltip override (useful for empty-state messaging). */
11+
title?: string;
12+
}
13+
14+
export function SurpriseMe({ disabled, onClick, className, title }: Props) {
15+
return (
16+
<button
17+
type="button"
18+
onClick={onClick}
19+
disabled={disabled}
20+
title={title ?? (disabled ? 'Toggle categories or expand time to surprise yourself' : 'Pick a random reachable POI')}
21+
aria-label="Surprise me"
22+
className={cn(
23+
'inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium shadow-sm transition-colors',
24+
disabled
25+
? 'cursor-not-allowed border-border bg-background text-muted-foreground opacity-60'
26+
: 'border-blue-600 bg-blue-600 text-white hover:bg-blue-700',
27+
className,
28+
)}
29+
>
30+
<Shuffle className="h-4 w-4" aria-hidden />
31+
<span>Surprise me</span>
32+
</button>
33+
);
34+
}

apps/web/src/components/map/IlsochroneMap.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* click events, and accepts a polygon and optional destination to render.
1010
* - All provider logic stays out of components — they receive primitives.
1111
*/
12-
import { useMemo } from 'react';
12+
import { useEffect, useMemo, useRef } from 'react';
1313
import Map, {
1414
Marker,
1515
Popup,
@@ -18,6 +18,7 @@ import Map, {
1818
NavigationControl,
1919
AttributionControl,
2020
type MapLayerMouseEvent,
21+
type MapRef,
2122
} from 'react-map-gl/maplibre';
2223
import 'maplibre-gl/dist/maplibre-gl.css';
2324
import type { Polygon, MultiPolygon, Feature } from 'geojson';
@@ -45,6 +46,12 @@ interface Props {
4546
onMapBackgroundClick?: () => void;
4647
/** Optional POI markers to overlay. */
4748
poiMarkers?: React.ReactNode;
49+
/**
50+
* When this changes, the camera animates to the target. Used by "Surprise me".
51+
* Include a `key` field that changes per invocation so repeated flights to
52+
* the same lng/lat still fire.
53+
*/
54+
cameraTarget?: { lng: number; lat: number; zoom?: number; key: number };
4855
}
4956

5057
export function IlsochroneMap({
@@ -58,12 +65,35 @@ export function IlsochroneMap({
5865
onPickDestination,
5966
onMapBackgroundClick,
6067
poiMarkers,
68+
cameraTarget,
6169
}: Props) {
6270
const polygonFeature = useMemo<Feature<Polygon | MultiPolygon> | null>(() => {
6371
if (!polygon) return null;
6472
return { type: 'Feature', geometry: polygon, properties: {} };
6573
}, [polygon]);
6674

75+
const mapRef = useRef<MapRef>(null);
76+
77+
// Drive camera flights from outside. Re-fires whenever cameraTarget.key
78+
// changes — pick any monotonic number (Date.now() works) when invoking.
79+
useEffect(() => {
80+
if (!cameraTarget) return;
81+
const map = mapRef.current;
82+
if (!map) return;
83+
const reduced =
84+
typeof window !== 'undefined' &&
85+
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
86+
map.flyTo({
87+
center: [cameraTarget.lng, cameraTarget.lat],
88+
zoom: cameraTarget.zoom ?? Math.max(viewState.zoom, 15),
89+
duration: reduced ? 0 : 1200,
90+
essential: true,
91+
});
92+
// We intentionally exclude viewState.zoom from the dep list — we want
93+
// this effect to fire only when cameraTarget changes.
94+
// eslint-disable-next-line react-hooks/exhaustive-deps
95+
}, [cameraTarget]);
96+
6797
const handleContextMenu = (e: MapLayerMouseEvent) => {
6898
// Suppress the native browser context menu over the map canvas.
6999
e.originalEvent?.preventDefault();
@@ -77,6 +107,7 @@ export function IlsochroneMap({
77107

78108
return (
79109
<Map
110+
ref={mapRef}
80111
mapStyle={tileStyle.styleUrl}
81112
attributionControl={false}
82113
longitude={viewState.longitude}

apps/web/src/components/map/PoiLayer.tsx

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,16 @@
33
/**
44
* PoiLayer — renders POI markers on the map.
55
*
6-
* Responsibilities:
7-
* - Filter incoming POIs to those inside the isochrone polygon (since the
8-
* fetch was scoped to the bbox, which is a superset).
9-
* - Render one Marker per POI, with a category-coloured dot.
10-
* - On marker click, emit `onSelect(poi)` so the page can show the
11-
* DestinationCard at the right place.
6+
* Dumb renderer: the parent passes already-visible POIs (i.e. inside the
7+
* isochrone polygon) and we just draw them. Polygon clipping lives in
8+
* page.tsx so "Surprise me" and the layer share the same visible set.
129
*/
13-
import { useMemo } from 'react';
1410
import { Marker } from 'react-map-gl/maplibre';
15-
import type { Polygon, MultiPolygon } from 'geojson';
1611
import type { Poi, PoiCategory } from '@ilsochrone/providers';
17-
import { isInsidePolygon } from '@/lib/polygon';
1812
import { cn } from '@/lib/utils';
1913

2014
interface Props {
2115
pois: Poi[];
22-
polygon?: Polygon | MultiPolygon;
2316
onSelect: (poi: Poi) => void;
2417
/** When set, render the selected POI marker emphasized. */
2518
selectedId?: string;
@@ -34,15 +27,10 @@ const CATEGORY_COLORS: Record<PoiCategory, string> = {
3427
beach: 'bg-cyan-500',
3528
};
3629

37-
export function PoiLayer({ pois, polygon, onSelect, selectedId }: Props) {
38-
const visible = useMemo(() => {
39-
if (!polygon) return pois;
40-
return pois.filter((p) => isInsidePolygon([p.lngLat[0], p.lngLat[1]], polygon));
41-
}, [pois, polygon]);
42-
30+
export function PoiLayer({ pois, onSelect, selectedId }: Props) {
4331
return (
4432
<>
45-
{visible.map((p) => (
33+
{pois.map((p) => (
4634
<Marker
4735
key={p.id}
4836
longitude={p.lngLat[0]}

docs/FIRST-RUN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ Copy the URL, paste in a new tab — same view.
8080

8181
POI markers (parks, cafés, restaurants, museums, viewpoints, beaches) render inside the polygon. Toggle categories from the panel under the time/mode controls. Click a marker → same destination card as right-click, with the POI's name pre-filled.
8282

83+
The **Surprise me** button picks a random reachable POI, flies the camera there, and opens the destination card. Disabled when no POIs are visible (toggle a category or expand time). Animation respects `prefers-reduced-motion`.
84+
8385
## 6. Known gaps (intentional)
8486

8587
These land in subsequent tasks:

docs/TASKS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ Conventions:
8585
- **Files.** `apps/web/src/app/opengraph-image.tsx`, `README.md`, `docs/screenshots/`.
8686
- **DoD.** OG card validates on opengraph.xyz. README links to the live URL.
8787

88+
### T-16 · POI accuracy phase 1 — Foursquare adapter + CompositePoiProvider
89+
- **Goal.** Land a `FoursquarePoiProvider` in `packages/providers/src/poi/foursquare.ts`. Introduce `CompositePoiProvider` that routes commercial categories (café, restaurant) to Foursquare and geographic categories (park, beach, viewpoint, museum) to Geoapify, per ADR-0006.
90+
- **Files.** `packages/providers/src/poi/{foursquare,composite}.ts`, `packages/providers/src/poi/foursquare.test.ts` (fixture-driven), `apps/web/src/app/api/pois/route.ts` (swap to composite when key present), `apps/web/.env.example` (add `FOURSQUARE_API_KEY`).
91+
- **DoD.** Unit tests cover the routing logic. With `FOURSQUARE_API_KEY` set, café/restaurant markers come from Foursquare; without it, the existing Geoapify-only path still works.
92+
- **Blocked by.** User provisioning a Foursquare developer account.
93+
94+
### T-17 · Suggested-places panel (sortable)
95+
- **Goal.** Side panel listing the visible POIs with sortable columns: rating (Foursquare/Google), distance from origin (computed client-side), category. Selecting a row opens the destination card and flies the camera (reusing the SurpriseMe camera path).
96+
- **Files.** `apps/web/src/components/panels/SuggestedPlaces.tsx`, small distance helper in `apps/web/src/lib/geo.ts`.
97+
- **DoD.** Toggling sort columns reorders the list instantly. Selecting a row updates the map. Works on mobile (panel collapses to a bottom sheet).
98+
- **Blocked by.** T-16 (needs rating data).
99+
88100
### T-15 · Production cutover
89101
- **Goal.** Promote latest preview to production. Tag `v0.1.0`. Post-mortem note: what worked, what didn't, what changed in the PRD.
90102
- **Files.** `CHANGELOG.md`, `docs/postmortem-v0.1.md`.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# ADR-0006: POI provider strategy — hybrid by category, phased rollout
2+
3+
- Status: Accepted (decision); Implementation deferred until user is ready to provision accounts
4+
- Date: 2026-05-11
5+
- Deciders: Tomer
6+
7+
## Context
8+
9+
ADR-0003 chose **Geoapify Places** (OSM-sourced) as the MVP POI provider. After a week of real-world use, an accuracy gap is visible: the user reports specific Tel Aviv places — cafés, restaurants, small businesses — that exist in the city but are missing from the map. This isn't a Geoapify bug; it's an OSM coverage gap. Commercial venue density on OSM has historically lagged commercial map products (Google, Foursquare, Apple) by years, especially outside major Western European cities.
10+
11+
The user also wants to add a "suggested places" panel sortable by user-configurable parameters (popularity, rating, distance). OSM data does not include rating/popularity signals at scale; commercial APIs do.
12+
13+
## Decision
14+
15+
Adopt a **hybrid POI sourcing strategy**, with implementation phased to track when external accounts are available.
16+
17+
### Category routing
18+
19+
Split categories by where the data lives best:
20+
21+
| Category | Best source | Why |
22+
|---|---|---|
23+
| Park | OSM (Geoapify) | OSM excels at physical geography; parks are well-mapped. |
24+
| Beach | OSM (Geoapify) | Same. |
25+
| Viewpoint | OSM (Geoapify) | Volunteer mappers love viewpoints. |
26+
| Museum | OSM (Geoapify) or commercial | Static, well-known set; either works. |
27+
| Café | Foursquare or Google | Commercial venue churn; OSM gaps are biggest here. |
28+
| Restaurant | Foursquare or Google | Same as café. |
29+
30+
The mapping lives in code as `CATEGORY_TO_PROVIDER`. UI and the rest of the app see one normalized `Poi[]` and don't know which provider produced which marker.
31+
32+
### Architecture
33+
34+
Introduce a `CompositePoiProvider` that wraps multiple sub-providers and routes requests by category:
35+
36+
```ts
37+
class CompositePoiProvider implements PoiProvider {
38+
constructor(private byCategory: Partial<Record<PoiCategory, PoiProvider>>) {}
39+
40+
async searchInBbox(q: PoiQuery): Promise<PoiResult> {
41+
const groups = groupBy(q.categories, (c) => this.byCategory[c]);
42+
const results = await Promise.all(
43+
[...groups.entries()].map(([provider, cats]) =>
44+
provider?.searchInBbox({ ...q, categories: cats }),
45+
),
46+
);
47+
return mergeResults(results);
48+
}
49+
}
50+
```
51+
52+
The route handler instantiates this at startup based on env-var configuration. Single-provider deployments (e.g. "Geoapify only") work without `CompositePoiProvider` by passing the single provider directly.
53+
54+
### Provider preference
55+
56+
Among the commercial options, **Foursquare > Google** as the default upgrade for this project:
57+
- 100k req/mo free tier vs. Google's $200/mo credit (~10k Nearby Search calls). Foursquare is 10× cheaper at scale.
58+
- No credit card required for Foursquare's free tier.
59+
- Permissive ToS — fewer restrictions on caching and display than Google.
60+
- Comparable venue data quality in dense urban areas, including Tel Aviv.
61+
62+
Google remains a credible second choice and may eventually be added as a third adapter for direct comparison. Apple Maps Server API is excluded (requires paid Apple Developer Program).
63+
64+
### Phasing
65+
66+
- **Phase 0 (now).** Geoapify only. CompositePoiProvider not yet introduced. Accuracy gap documented; user is informed.
67+
- **Phase 1 (when user provisions Foursquare).** Add `FoursquarePoiProvider`, introduce `CompositePoiProvider`, route commercial categories to Foursquare while keeping geographic categories on Geoapify. Add `FOURSQUARE_API_KEY` to `.env.example`.
68+
- **Phase 2 (optional).** Add `GooglePoiProvider` for comparison. Could be selected per category, or A/B'd against Foursquare via a feature flag.
69+
- **Phase 3 (suggested-places panel).** Build the side panel that lists POIs with sortable columns (rating, distance, category). Rating is sourced from Foursquare/Google; distance is computed client-side from `state.origin`; category is already in the `Poi` shape. The panel reuses the existing `PoiProvider` data path — no new provider work needed.
70+
71+
## Why not just add Foursquare now
72+
73+
The user explicitly opted out of provisioning new external accounts this round. Building a Foursquare adapter without an active Foursquare account means landing untested code in the repo, then either:
74+
75+
- Mocking the upstream and relying on integration tests we don't have, or
76+
- Leaving the code in a "compiles but never ran" state — high risk of subtle bugs when it eventually does run.
77+
78+
Better to write the design now (this ADR + a stub in TASKS.md), and implement when the account is in hand and the integration can be exercised end-to-end.
79+
80+
## Consequences
81+
82+
- Phase 0 ships with a known accuracy gap. The map will under-represent Tel Aviv's commercial venue density. We mitigate by showing the user we know.
83+
- The PRD's POI categories (FR-6) don't change — internal taxonomy is provider-agnostic on purpose.
84+
- Phase 1+ adds attribution requirements: any view that displays Foursquare data must show Foursquare attribution near the marker or in the destination card. ADR-0005's `DestinationCard` is the natural home.
85+
- The CompositePoiProvider pattern is a clean teaching moment for the abstraction: multiple adapters behind one interface, routing by domain key (category) without UI awareness. Worth highlighting in the README screenshots/walkthrough.
86+
- Cost ceilings: even Phase 2 (Google) stays inside Google's $200/mo credit for portfolio-level traffic.
87+
88+
## When this ADR should be reopened
89+
90+
- If Foursquare's free tier changes meaningfully (currently 100k req/mo).
91+
- If OSM coverage in Tel Aviv improves to the point that the gap closes (track via spot checks every few months).
92+
- If the user decides to go all-in on Google Maps for the UI overall (e.g. switching basemap to Google Maps tiles) — at which point a single-provider Google adapter becomes the simpler default.

docs/research/01-data-sources.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ The community-run `overpass-api.de` has been timing out under sustained scraper
6868
**Walking MVP (now):**
6969
- Isochrone engine: **OpenRouteService**
7070
- Basemap: **Stadia Maps** + MapLibre GL JS
71-
- POIs: **Geoapify Places**, with an Overpass (Private.coffee mirror) adapter behind the same interface for raw-query escape hatches
71+
- POIs: **Geoapify Places** (accuracy gap acknowledged; see ADR-0006 for the hybrid Foursquare/Google plan when the user is ready to provision accounts). Overpass (Private.coffee mirror) remains available behind the same interface as an escape hatch.
7272
- Architecture: four provider interfaces — `IsochroneProvider`, `PoiProvider`, `TileProvider`, and (phase-2) `TransitDataProvider` — each with at least one MVP adapter and a clear contract. No provider-specific types leak into UI components.
7373

7474
**Phase 2 (transit):**

0 commit comments

Comments
 (0)