docs+openspec: prior-art research (Organic Maps, Endurain, wanderer) and 15 proposals
Add docs/inspirations.md as the durable record of the 2026-07-05/06 prior-art research — per-project learnings with source paths, canonical credit lines, and the changes each spawned — and extend the acknowledgment lists in philosophy.md/architecture.md (Organic Maps, Endurain, wanderer). New OpenSpec changes (proposal/design/specs/tasks each): - Organic Maps: elevation-profile-hardening, gpx-parser-robustness, hiking-time-estimate, poi-index, hiking-foot-profile - Endurain: account-export, activity-duplicate-review, fit-parsing-hardening, activity-locations, self-hosting-guide, activity-privacy-controls - wanderer: federation-hardening, link-share-tokens - credits-page (user-visible acknowledgments) Updated in-flight changes with wanderer prior-art sections: route-federation, route-discovery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
be4f7e4ae8
commit
5dd4968626
80 changed files with 2600 additions and 2 deletions
2
openspec/changes/activity-locations/.openspec.yaml
Normal file
2
openspec/changes/activity-locations/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-07-06
|
||||
50
openspec/changes/activity-locations/design.md
Normal file
50
openspec/changes/activity-locations/design.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
## Context
|
||||
|
||||
Endurain resolves activity locations by HTTP-geocoding start coordinates against Nominatim or geocode.maps.co (`activity/utils.py:1271`) — exactly the third-party location leak trails' privacy posture forbids (same reasoning that just drove the `poi-index` change to remove Overpass). trails stores route/activity geometry in PostGIS and GPX; start point is trivially available at save time (`gpx-save.server.ts` already extracts coords and startTime).
|
||||
|
||||
GeoNames publishes redistributable (CC-BY) place datasets; `cities500` (~200k populated places with name, admin1, country, lat/lon) compresses to a few MB — small enough to bundle, dense enough that a nearest-place lookup names the right town for outdoor activity starts.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- "Freiburg im Breisgau, Germany" on activities and routes with zero third-party requests and zero config for self-hosters.
|
||||
- Deterministic, offline, fast (sub-millisecond lookup), computed once at write time.
|
||||
- The label never reveals more than the visible geometry already does.
|
||||
|
||||
**Non-Goals:**
|
||||
- Street/trailhead-level naming, multilingual place names (dataset ships primary names; country names localize via i18n), location-based search or grouping, online fallback of any kind.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Bundled GeoNames `cities500` + nearest-neighbor lookup, in-process
|
||||
|
||||
New `packages/geocode`: build script converts the GeoNames TSV into a compact sorted binary/JSON artifact checked into the package (with attribution + license file); runtime loads it once and answers `nearestPlace(lat, lon)` via a static grid index (place count is small enough that a 1°-cell grid with neighbor scan beats pulling in a kd-tree dep). Returns `{name, admin1, countryCode, distanceKm}`.
|
||||
|
||||
*Alternatives:* PostGIS table + spatial query — works, but turns a pure function into a schema + seed concern and complicates self-host setup; Nominatim self-hosted — absurd footprint for naming a start town; per-request third-party geocoding — ruled out by principle.
|
||||
|
||||
### 2. Label rules
|
||||
|
||||
Use the first point of the geometry. If the nearest place is > 30 km away (open ocean, wilderness), fall back to country only; if > 200 km or no country, store null (no label). Store `location_name` ("Freiburg im Breisgau") and `location_country` (ISO code, rendered localized client-side). Computed in `gpx-save.server.ts`'s derive step so every creation path (upload, planner save, Wahoo/Garmin/Komoot import, federation ingest of remote activities is **excluded** — remote rows keep whatever the origin published, we don't re-derive from remote geometry).
|
||||
|
||||
### 3. Masking follows the geometry, one rule
|
||||
|
||||
The label is shown only where the viewer may see the track start: today that means it renders wherever the map/GPX renders for that viewer. When `activity-privacy-controls` lands, its location/start-hiding flag governs the label through the same mask helper. This coupling is stated in both changes so neither ships a leak.
|
||||
|
||||
### 4. Backfill as a one-shot job
|
||||
|
||||
pg-boss job iterates rows with geometry and null `location_name` in batches. Runs once on deploy; re-runnable safely (idempotent predicate).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Nearest-place can name the wrong side of a border/ridge] → Cosmetic; 200k-place density makes gross errors rare; label is presentation-only, never used for logic.
|
||||
- [Dataset staleness (renames, new places)] → Refresh = re-run the build script in a PR; places churn slowly.
|
||||
- [CC-BY attribution obligation] → GeoNames attribution added to the package license file and the journal's about/credits, alongside existing OSM attribution.
|
||||
- [Memory: dataset resident in the journal process] → ~200k compact records ≈ tens of MB worst case; measured in tasks, with the grid artifact designed for lazy load.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Additive columns + package + backfill job; display changes are progressive (null label renders nothing). Rollback = revert code; columns can stay.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking. Whether route *end* location is also worth labeling ("Freiburg → Basel") is a display-only follow-up.
|
||||
29
openspec/changes/activity-locations/proposal.md
Normal file
29
openspec/changes/activity-locations/proposal.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
## Why
|
||||
|
||||
Activities and routes display as bare stat rows — nothing tells a feed reader *where* "Sunday ride, 62 km" happened, which is often the most interesting fact. Endurain reverse-geocodes activity start points into city/country names (reviewed 2026-07-06), but does it by calling Nominatim/geocode.maps.co — a third-party request carrying user location that trails' privacy principles rule out. We can have the feature without the leak: an **offline** reverse geocoder using a bundled place dataset, running entirely on-instance.
|
||||
|
||||
## What Changes
|
||||
|
||||
- New shared package capability: offline reverse geocoding — nearest populated place (name, admin region, ISO country) for a coordinate, from a bundled GeoNames-derived dataset (`cities500`-class, a few MB), no network calls.
|
||||
- Activities and routes gain a derived **location label** ("Freiburg, Germany"), computed at save/import time from the geometry's start point and stored on the row.
|
||||
- Location shows on detail pages and feed/list cards; localized country names via the existing i18n locales.
|
||||
- **Privacy-coupled**: the label derives from where the track starts, so it is masked whenever the start location is hidden — it follows the same rules as the map/start data under the `activity-privacy-controls` change (if that lands, its `hideLocation`-style flag governs this label; until then, the label is only shown where the geometry itself is visible).
|
||||
- Backfill of existing rows via a one-shot background job.
|
||||
- Not in scope: full address geocoding, location search/filtering, per-point location, and any online geocoding fallback.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `activity-locations`: Offline reverse geocoding of route/activity start points into display labels — dataset, derivation timing, storage, display, masking rule, and backfill.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
<!-- none — feed/detail specs gain content additively; masking defers to activity-privacy-controls' capability when present -->
|
||||
|
||||
## Impact
|
||||
|
||||
- New `packages/geocode` (or module in an existing package): bundled dataset + nearest-place lookup (kd-tree/grid), pure and unit-tested; used by the journal only.
|
||||
- `packages/db`: `location_name`, `location_country` (text, nullable) on `activities` and `routes` + migration.
|
||||
- Journal: set at gpx-save/import time; one-shot pg-boss backfill job; feed card + detail page display (i18n).
|
||||
- Privacy manifest: note that location labels are derived on-instance with no third-party calls.
|
||||
- Bundle size: the dataset ships server-side only (journal), never to the browser.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Offline reverse geocoding
|
||||
The Journal SHALL derive location labels from a bundled place dataset entirely on-instance; deriving a location SHALL NOT trigger any network request. The lookup SHALL return the nearest populated place with its region and ISO country for a coordinate, falling back to country-only beyond 30 km from any place and to no label beyond 200 km.
|
||||
|
||||
#### Scenario: No third-party calls
|
||||
- **WHEN** an activity is saved and its location derived
|
||||
- **THEN** no request leaves the instance
|
||||
|
||||
#### Scenario: Wilderness start
|
||||
- **WHEN** a track starts 50 km from the nearest populated place
|
||||
- **THEN** the label is the country only
|
||||
|
||||
### Requirement: Location labels on activities and routes
|
||||
Activities and routes with geometry SHALL store a derived location label (place name + country) computed from the track's start point at save/import time for locally-created content. Remote (federated) activities SHALL NOT be re-derived locally. Detail pages and feed/list cards SHALL display the label with the country name localized.
|
||||
|
||||
#### Scenario: Label on import
|
||||
- **WHEN** a Wahoo activity starting in Freiburg is imported
|
||||
- **THEN** the activity shows "Freiburg im Breisgau, Deutschland" in a German locale
|
||||
|
||||
#### Scenario: No geometry, no label
|
||||
- **WHEN** an activity has no GPS data
|
||||
- **THEN** no location label is stored or shown
|
||||
|
||||
### Requirement: Label visibility follows geometry visibility
|
||||
The location label SHALL be visible to a viewer only when that viewer may see the track's start (same masking as the map/geometry; governed by the activity privacy flags once those exist).
|
||||
|
||||
#### Scenario: Hidden geometry hides label
|
||||
- **WHEN** a viewer is not permitted to see an activity's map/start
|
||||
- **THEN** the location label is also absent for that viewer
|
||||
|
||||
### Requirement: Backfill of existing content
|
||||
Existing local activities and routes with geometry SHALL receive labels via an idempotent one-shot background job.
|
||||
|
||||
#### Scenario: Backfill run
|
||||
- **WHEN** the backfill job completes
|
||||
- **THEN** previously saved activities with geometry display location labels
|
||||
22
openspec/changes/activity-locations/tasks.md
Normal file
22
openspec/changes/activity-locations/tasks.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
## 1. Geocode package
|
||||
|
||||
- [ ] 1.1 Create `packages/geocode`: build script converting GeoNames `cities500` TSV → compact artifact (name, admin1, country, lat, lon), checked in with CC-BY attribution/license file
|
||||
- [ ] 1.2 Implement `nearestPlace(lat, lon)` with a 1°-grid index + neighbor scan; distance thresholds (30 km place / 200 km country) as constants; measure load time + resident memory and record in the package README
|
||||
- [ ] 1.3 Unit tests: city center, border town, mid-ocean (country-only/none), poles/antimeridian edge cells
|
||||
|
||||
## 2. Schema & derivation
|
||||
|
||||
- [ ] 2.1 Add `location_name` + `location_country` to `activities` and `routes` + migration
|
||||
- [ ] 2.2 Derive in the gpx-save shared step from the first geometry point for all local creation paths; skip remote/federated ingest
|
||||
- [ ] 2.3 One-shot idempotent pg-boss backfill job over rows with geometry and null label
|
||||
|
||||
## 3. Display
|
||||
|
||||
- [ ] 3.1 Detail pages + feed/list cards render the label (place + localized country name via i18n); null renders nothing
|
||||
- [ ] 3.2 Masking: render the label only where the viewer can see the geometry (shared visibility path); coordinate with `activity-privacy-controls` flag if landed
|
||||
- [ ] 3.3 Add GeoNames attribution to the journal credits alongside OSM attribution; update the privacy manifest (derived on-instance, no third parties)
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 E2E: import a fixture GPX → label appears on detail + feed; private activity's label invisible to another user
|
||||
- [ ] 4.2 Run `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e`
|
||||
Loading…
Add table
Add a link
Reference in a new issue