c7f0013e57
After saving, show "Saved! Add another from this activity?" with two buttons: "Add another" (resets name/handles, keeps map loaded) and "Done" (navigates to /segments/).
233 lines
9.2 KiB
Markdown
233 lines
9.2 KiB
Markdown
# Bincio Segments — Design Plan
|
|
|
|
## Overview
|
|
|
|
Segments are named GPS stretches that can be defined by any user. Every time an activity passes through a segment, an "effort" is recorded automatically. Users can track their personal history and PRs on each segment.
|
|
|
|
---
|
|
|
|
## Data Model
|
|
|
|
### Segment (global, shared)
|
|
|
|
File: `/var/bincio/segments/{segment-id}.json`
|
|
|
|
```json
|
|
{
|
|
"id": "climb-to-passo-rolle-a3f2",
|
|
"name": "Climb to Passo Rolle",
|
|
"sport": "cycling",
|
|
"polyline": [[46.123, 11.456], [46.124, 11.457], "..."],
|
|
"distance_m": 4320.5,
|
|
"bbox": [11.456, 46.120, 11.502, 46.135],
|
|
"created_by": "brut",
|
|
"created_at": "2026-05-13T10:00:00Z"
|
|
}
|
|
```
|
|
|
|
Segment ID: slugified name + 4-char hash to avoid collisions.
|
|
|
|
### SegmentEffort (per user)
|
|
|
|
File: `/var/bincio/data/{handle}/segment_efforts/{segment-id}.json`
|
|
|
|
Array of efforts, newest first:
|
|
|
|
```json
|
|
[
|
|
{
|
|
"activity_id": "2026-04-26T053013Z-ccbas-...",
|
|
"started_at": "2026-04-26T06:12:33Z",
|
|
"elapsed_s": 1842,
|
|
"avg_speed_kmh": 12.4,
|
|
"avg_hr_bpm": 158,
|
|
"avg_power_w": 245,
|
|
"np_power_w": 261,
|
|
"detected_at": "2026-05-13T10:05:00Z"
|
|
}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## Detection Algorithm
|
|
|
|
**Input:** activity GPS track (lat/lon, ~1 Hz), segment polyline
|
|
|
|
**Steps:**
|
|
|
|
1. **Bbox pre-filter** — skip if activity bbox doesn't overlap segment bbox (padded by ~50 m). O(1) per segment.
|
|
|
|
2. **Start proximity** — find all activity points within 25 m of `polyline[0]`. Each is a candidate entry point.
|
|
|
|
3. **End proximity** — from each candidate entry, scan forward (never backward) for an activity point within 25 m of `polyline[-1]`. Enforces correct direction.
|
|
|
|
4. **Path conformance** — between entry and exit indices, project each activity point onto the nearest segment polyline segment and compute perpendicular distance. If more than 30% of points exceed 50 m deviation, reject the match. Configurable thresholds.
|
|
|
|
5. **Effort extraction** — from the matched entry/exit indices, slice the timeseries and compute `elapsed_s` and avg metrics.
|
|
|
|
6. **Multiple efforts** — after a successful match, continue scanning from the exit point to detect repeat efforts in the same activity (e.g. repeat climbs).
|
|
|
|
**Configurable constants** (easy to tune):
|
|
- `MATCH_RADIUS_M = 25` — proximity to start/end points
|
|
- `CONFORMANCE_MAX_DEVIATION_M = 50` — max perpendicular distance
|
|
- `CONFORMANCE_MAX_FRACTION = 0.30` — fraction of points allowed to exceed max deviation
|
|
|
|
---
|
|
|
|
## Server-side Code Structure
|
|
|
|
```
|
|
bincio/segments/
|
|
__init__.py
|
|
models.py — Segment, SegmentEffort dataclasses
|
|
store.py — read/write segments and efforts to/from /var/bincio
|
|
detect.py — matching algorithm
|
|
cli.py — bincio segments detect [--activity X] [--segment Y] [--user U] [--all]
|
|
```
|
|
|
|
Detection is called:
|
|
- **At ingest time** — after each new activity is processed, reads all segment definitions (bbox pre-filter makes this fast), writes matches to the user's `segment_efforts/`
|
|
- **Retroactively** — via CLI or API endpoint, for one user re-running all activities against all (or one) segment(s)
|
|
|
|
---
|
|
|
|
## API Endpoints
|
|
|
|
All new, added to `server.py`:
|
|
|
|
```
|
|
GET /api/segments?bbox=lon_min,lat_min,lon_max,lat_max list segments in viewport
|
|
POST /api/segments create segment (auth required)
|
|
DELETE /api/segments/{id} delete (created_by or admin only)
|
|
|
|
GET /api/segments/{id}/efforts current user's efforts (auth)
|
|
POST /api/segments/{id}/detect retrigger detection for current user
|
|
```
|
|
|
|
Bbox filtering is done in memory at request time. All segment definitions are read from disk on startup (or lazily cached). Works fine for hundreds of segments.
|
|
|
|
---
|
|
|
|
## Frontend
|
|
|
|
### New pages / routes
|
|
|
|
| Route | Purpose |
|
|
|---|---|
|
|
| `/segments/` | Map + list of all segments; entry point for browsing and creating |
|
|
| `/segments/new/` | Segment creation flow |
|
|
| `/segments/{id}/` | Segment detail: polyline on map + user's effort history |
|
|
|
|
### `/segments/` page
|
|
|
|
- Full-width map showing segment polylines for segments whose bbox overlaps the current map viewport
|
|
- Segment list below (or sidebar): name, sport, distance, creator
|
|
- "New segment" button → `/segments/new/`
|
|
- Clicking a segment → `/segments/{id}/`
|
|
|
|
### `/segments/new/` — creation flow
|
|
|
|
1. Searchable list of the user's own activities with GPS → select one
|
|
2. Activity track shown on map
|
|
3. Dual-handle range slider below the map, one position per GPS point in the track
|
|
- Moving handles highlights the selected portion on the map in a contrasting colour
|
|
- Start and end coordinates are the GPS points at the slider handle positions
|
|
4. Name input + optional sport selector
|
|
5. Confirm → `POST /api/segments`
|
|
|
|
### `/segments/{id}/` — segment detail
|
|
|
|
- Segment polyline on map
|
|
- Metadata: name, sport, distance, created by
|
|
- Effort history table (sorted by date, newest first):
|
|
- Date, elapsed time, avg speed, avg HR, avg power, NP
|
|
- Delta vs. personal best highlighted
|
|
- "Retrigger detection" button → `POST /api/segments/{id}/detect`
|
|
|
|
### Activity detail page (existing)
|
|
|
|
Below the stats table: if any segment efforts were detected for this activity, show a compact list — segment name + elapsed time + Δ vs. PR.
|
|
|
|
### Athlete page (existing)
|
|
|
|
New "Segments" section: list of segments the user has efforts on, showing best time and effort count for each.
|
|
|
|
---
|
|
|
|
## Segment Creation UX (refined)
|
|
|
|
Standalone `/segments/new/` page with an activity picker. Additionally, the activity detail page gets a **"Create segment from this activity"** button that links to `/segments/new/?activity={id}`, pre-selecting the activity and skipping the picker step.
|
|
|
|
Rationale: users will typically think "I want to turn this climb I just did into a segment" — the shortcut from activity detail makes that fast, while the standalone page remains the canonical entry point for discovery.
|
|
|
|
---
|
|
|
|
## Implementation Strategy
|
|
|
|
UI-first, so users can start populating the segment collection immediately. Detection comes after.
|
|
|
|
**Phase 1 — Minimal backend to unblock UI** ✅
|
|
1. `bincio/segments/models.py` — Segment and SegmentEffort dataclasses ✅
|
|
2. `bincio/segments/store.py` — read/write segments to/from disk ✅
|
|
3. `POST /api/segments` + `GET /api/segments?bbox=...` + `DELETE /api/segments/{id}` ✅
|
|
|
|
**Phase 2 — Creation UI** ✅
|
|
4. `/segments/new/` page — activity picker → map + dual-handle slider → name/save ✅
|
|
5. `/segments/` page — map + list of existing segments, bbox-filtered ✅
|
|
6. Activity detail — "Create segment from this activity" shortcut button ✅
|
|
|
|
**Phase 3 — Detection** ✅
|
|
7. `bincio/segments/detect.py` — matching algorithm, tested in isolation ✅
|
|
8. `bincio/segments/cli.py` — retroactive detection CLI (`bincio segments detect`) ✅
|
|
9. Ingest hook — run detection at end of each ingest ✅
|
|
10. `POST /api/segments/{id}/detect` + `GET /api/segments/{id}/efforts` ✅
|
|
|
|
**Phase 4 — Display** (next)
|
|
11. `/segments/{id}/` page — segment detail + effort history table
|
|
12. Activity detail — show matched segment efforts below stats
|
|
13. Athlete page — segments section
|
|
|
|
---
|
|
|
|
## Phase 4 Detail
|
|
|
|
### New API endpoints
|
|
|
|
| Endpoint | Notes |
|
|
|---|---|
|
|
| `GET /api/segments/{id}` | Single segment metadata |
|
|
| `GET /api/activities/{activity_id}/segment_efforts` | Scans user's effort files; returns `[{segment_id, segment_name, elapsed_s, started_at, ...}]` for hits on this activity. Auth required. |
|
|
| `GET /api/users/{handle}/segment_summary` | Public. Returns `[{segment: {...}, best_elapsed_s, effort_count}]` — all segments where this user has efforts, best time + count each. |
|
|
|
|
### SegmentDetail page (`/segments/{id}/`)
|
|
|
|
- Map with segment polyline (reuse MapLibre, same style as `SegmentsView`)
|
|
- Metadata row: name, sport, distance, created by
|
|
- Effort history table sorted newest-first; columns: Date, Time, Avg speed, Avg HR, Avg power, NP, Δ vs PR
|
|
- PR row highlighted; Δ column shows `+N s` / `-N s` in red/green vs best effort
|
|
- "Retrigger detection" button with loading state (blocks, shows count when done)
|
|
- Auth-gated: effort table only rendered if logged in
|
|
|
|
### Activity detail additions
|
|
|
|
Below the laps table: if the user is logged in and this activity has any segment effort hits, show a compact block — segment name (linked to `/segments/{id}/`) + elapsed time + Δ vs PR.
|
|
|
|
Fetch: `GET /api/activities/{activity_id}/segment_efforts` (gracefully hidden if 401 or empty).
|
|
|
|
### Athlete page additions
|
|
|
|
New "Segments" tab in `AthleteView.svelte`. Fetches `GET /api/users/{handle}/segment_summary` (public, no auth needed). Shows a table: Segment name (linked), Sport, Distance, Best time, # efforts. Empty state if no efforts yet.
|
|
|
|
### Effort time display
|
|
|
|
Use compact `m:ss` format (e.g. "5:23") for segment effort tables — add `formatElapsed(s: number): string` to `format.ts`.
|
|
|
|
### Post-save UX in SegmentCreate (updated)
|
|
|
|
After a successful save, instead of immediately redirecting to `/segments/`, show an inline prompt:
|
|
> Segment saved! Add another from this activity? [Yes, add another] [Done]
|
|
|
|
- **"Yes, add another"** — clears `segName`, `segSport`; resets handles to full range; keeps the map and activity loaded
|
|
- **"Done"** — navigates to `/segments/`
|