From 4d2df860cef5d1077805477fe3ec0beec34073ce Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 13 May 2026 00:50:39 +0200 Subject: [PATCH] Segments Phase 3: detection algorithm, CLI, ingest hook, and efforts API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detect.py: ActivityTrack + detect_one/detect_all (bbox pre-filter → start/end proximity 25m → path conformance 50m/30% → effort extraction with avg speed/HR/power and Coggan NP) - cli.py: `bincio segments detect` for retroactive detection over stored timeseries JSONs, with optional --activity-id / --segment-id filters - ingest.py: non-fatal hook at end of ingest_parsed runs detect_all - server.py: GET /api/segments/{id}/efforts and POST /api/segments/{id}/detect --- bincio/cli.py | 2 + bincio/extract/ingest.py | 9 ++ bincio/segments/cli.py | 114 ++++++++++++++++ bincio/segments/detect.py | 278 ++++++++++++++++++++++++++++++++++++++ bincio/serve/server.py | 81 +++++++++++ segments-plan.md | 189 ++++++++++++++++++++++++++ 6 files changed, 673 insertions(+) create mode 100644 bincio/segments/cli.py create mode 100644 bincio/segments/detect.py create mode 100644 segments-plan.md diff --git a/bincio/cli.py b/bincio/cli.py index 5b22e19..0adae7b 100644 --- a/bincio/cli.py +++ b/bincio/cli.py @@ -20,6 +20,7 @@ from bincio.serve.cli import serve # noqa: E402 from bincio.dev import dev # noqa: E402 from bincio.reextract_cmd import reextract_originals # noqa: E402 from bincio.sync_strava import sync_strava_cmd # noqa: E402 +from bincio.segments.cli import segments_group # noqa: E402 main.add_command(extract) main.add_command(render) @@ -30,3 +31,4 @@ main.add_command(serve) main.add_command(dev) main.add_command(reextract_originals) main.add_command(sync_strava_cmd) +main.add_command(segments_group) diff --git a/bincio/extract/ingest.py b/bincio/extract/ingest.py index de44618..986e4c9 100644 --- a/bincio/extract/ingest.py +++ b/bincio/extract/ingest.py @@ -74,6 +74,15 @@ def ingest_parsed( pass write_athlete_json(list(summaries.values()), data_dir, athlete_config) + # Detect segment efforts for this activity (non-fatal if it fails). + try: + from bincio.segments.detect import track_from_parsed, detect_all + track = track_from_parsed(parsed, activity_id) + if track is not None: + detect_all(track, data_dir.name, data_dir.parent) + except Exception: + pass + return activity_id diff --git a/bincio/segments/cli.py b/bincio/segments/cli.py new file mode 100644 index 0000000..18f22ef --- /dev/null +++ b/bincio/segments/cli.py @@ -0,0 +1,114 @@ +"""bincio segments — segment management CLI commands.""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +import click + + +def _dt(s: str) -> datetime: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + + +@click.group("segments") +def segments_group() -> None: + """Manage segments and detect efforts.""" + + +@segments_group.command("detect") +@click.option("--data-dir", required=True, type=click.Path(), help="BAS data directory (e.g. /var/bincio)") +@click.option("--handle", required=True, help="User handle to run detection for") +@click.option("--activity-id", default=None, help="Limit to a single activity ID (optional)") +@click.option("--segment-id", default=None, help="Limit to a single segment ID (optional)") +def detect_cmd(data_dir: str, handle: str, activity_id: str | None, segment_id: str | None) -> None: + """Retroactively detect segment efforts for stored activities. + + Walks every activity with GPS data, runs the detection algorithm against + all (or a single) segment, and persists any new efforts found. + """ + from bincio.segments.detect import track_from_timeseries_json, detect_one, detect_all + from bincio.segments import store as _store + + dd = Path(data_dir).expanduser().resolve() + user_dir = dd / handle + acts_dir = user_dir / "activities" + + if not acts_dir.exists(): + click.echo(f"No activities directory at {acts_dir}", err=True) + sys.exit(1) + + # Choose which segments to check. + if segment_id: + seg = _store.load_segment(dd, segment_id) + if seg is None: + click.echo(f"Segment not found: {segment_id}", err=True) + sys.exit(1) + segments = [seg] + else: + segments = _store.list_segments(dd) + + if not segments: + click.echo("No segments defined.", err=True) + sys.exit(0) + + # Choose which activities to process. + if activity_id: + detail_files = [acts_dir / f"{activity_id}.json"] + else: + detail_files = sorted(acts_dir.glob("*.json")) + # Exclude timeseries files. + detail_files = [f for f in detail_files if ".timeseries." not in f.name] + + total_efforts = 0 + processed = 0 + + for detail_path in detail_files: + try: + detail = json.loads(detail_path.read_text(encoding="utf-8")) + except Exception: + continue + + ts_url = detail.get("timeseries_url") + if not ts_url: + continue + + act_id = detail.get("id", detail_path.stem) + sport = detail.get("sport", "other") + started = detail.get("started_at") + if not started: + continue + try: + started_at = _dt(started) + except Exception: + continue + + ts_path = user_dir / ts_url + if not ts_path.exists(): + continue + try: + ts = json.loads(ts_path.read_text(encoding="utf-8")) + except Exception: + continue + + track = track_from_timeseries_json(ts, act_id, sport, started_at) + if track is None: + continue + + processed += 1 + for seg in segments: + from bincio.segments.detect import detect_one + efforts = detect_one(track, seg) + for effort in efforts: + _store.add_effort(dd, handle, seg.id, effort) + if efforts: + click.echo( + f" {act_id}: {len(efforts)} effort(s) on '{seg.name}' " + f"({', '.join(str(e.elapsed_s) + 's' for e in efforts)})" + ) + total_efforts += len(efforts) + + click.echo(f"\nProcessed {processed} activities, found {total_efforts} effort(s).") diff --git a/bincio/segments/detect.py b/bincio/segments/detect.py new file mode 100644 index 0000000..f7cbb96 --- /dev/null +++ b/bincio/segments/detect.py @@ -0,0 +1,278 @@ +"""Segment effort detection. + +Matches GPS tracks against stored segment polylines and produces SegmentEffort +records. Works from either a live ParsedActivity (ingest path) or from a +stored timeseries JSON (retroactive path). +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +from bincio.segments.models import Segment, SegmentEffort + +# ── tuning constants ────────────────────────────────────────────────────────── + +MATCH_RADIUS_M = 25 # max distance to segment start/end to open/close an effort +CONFORMANCE_MAX_DEV_M = 50 # max allowed deviation for each interior segment point +CONFORMANCE_MAX_FRAC = 0.30 # max fraction of interior points allowed to deviate + +# ── fast distance approximation ─────────────────────────────────────────────── + +_R = 6_371_000.0 # Earth radius in metres + + +def _dist(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Equirectangular approximation — fast, accurate to <0.1% within 100 km.""" + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + mlat = math.radians((lat1 + lat2) / 2.0) + return math.hypot(dlat * _R, dlon * _R * math.cos(mlat)) + + +# ── activity track representation ──────────────────────────────────────────── + +@dataclass +class ActivityTrack: + """Common internal representation for detection, independent of source format.""" + activity_id: str + sport: str + started_at: datetime + # Parallel arrays — all same length, GPS-only points (lat/lon not None). + lats: list[float] + lons: list[float] + times: list[int] # seconds from started_at + speeds: list[Optional[float]] + hrs: list[Optional[int]] + powers: list[Optional[int]] + bbox: list[float] = field(default_factory=list) # [lon_min, lat_min, lon_max, lat_max] + + def __post_init__(self) -> None: + if self.lats and not self.bbox: + self.bbox = [ + min(self.lons), min(self.lats), + max(self.lons), max(self.lats), + ] + + +def track_from_parsed(parsed: "ParsedActivity", activity_id: str) -> Optional[ActivityTrack]: # noqa: F821 + """Build an ActivityTrack from a ParsedActivity (used during ingest).""" + lats, lons, times, speeds, hrs, powers = [], [], [], [], [], [] + last_t = -1 + for p in parsed.points: + if p.lat is None or p.lon is None: + continue + t = int((p.timestamp - parsed.started_at).total_seconds()) + if t < 0 or t == last_t: + continue + last_t = t + lats.append(p.lat) + lons.append(p.lon) + times.append(t) + speeds.append(p.speed_kmh) + hrs.append(p.hr_bpm) + powers.append(p.power_w) + if len(lats) < 2: + return None + return ActivityTrack( + activity_id=activity_id, + sport=parsed.sport, + started_at=parsed.started_at, + lats=lats, lons=lons, times=times, + speeds=speeds, hrs=hrs, powers=powers, + ) + + +def track_from_timeseries_json( + ts: dict, + activity_id: str, + sport: str, + started_at: datetime, +) -> Optional[ActivityTrack]: + """Build an ActivityTrack from a stored timeseries JSON dict.""" + raw_lats = ts.get("lat") or [] + raw_lons = ts.get("lon") or [] + raw_t = ts.get("t") or [] + raw_spd = ts.get("speed_kmh") or [] + raw_hr = ts.get("hr_bpm") or [] + raw_pwr = ts.get("power_w") or [] + n = len(raw_t) + if n < 2 or not raw_lats or len(raw_lats) != n: + return None + + def _pad(arr: list, length: int) -> list: + return arr + [None] * (length - len(arr)) + + raw_spd = _pad(raw_spd, n) + raw_hr = _pad(raw_hr, n) + raw_pwr = _pad(raw_pwr, n) + + lats, lons, times, speeds, hrs, powers = [], [], [], [], [], [] + for i in range(n): + if raw_lats[i] is None or raw_lons[i] is None: + continue + lats.append(float(raw_lats[i])) + lons.append(float(raw_lons[i])) + times.append(int(raw_t[i])) + speeds.append(raw_spd[i]) + hrs.append(raw_hr[i]) + powers.append(raw_pwr[i]) + + if len(lats) < 2: + return None + return ActivityTrack( + activity_id=activity_id, + sport=sport, + started_at=started_at, + lats=lats, lons=lons, times=times, + speeds=speeds, hrs=hrs, powers=powers, + ) + + +# ── effort metric helpers ───────────────────────────────────────────────────── + +def _avg_nonnull(vals: list, lo: int, hi: int) -> Optional[float]: + nums = [v for v in vals[lo:hi + 1] if v is not None] + return sum(nums) / len(nums) if nums else None + + +def _np_power(powers: list[Optional[int]], lo: int, hi: int) -> Optional[int]: + """Coggan NP from a slice of 1Hz power data (may have gaps/nulls).""" + WIN = 30 + chunk = powers[lo:hi + 1] + filled = [v if v is not None else 0 for v in chunk] + n = len(filled) + if n < WIN: + # Too short for rolling average — just return avg power. + non_null = [v for v in chunk if v is not None] + return int(round(sum(non_null) / len(non_null))) if non_null else None + half = WIN // 2 + window_sum = sum(filled[:WIN]) + fourth_powers = [] + for i in range(half, n - half): + fourth_powers.append((window_sum / WIN) ** 4) + if i + half + 1 < n: + window_sum += filled[i + half + 1] - filled[i - half] + if not fourth_powers: + return None + return int(round((sum(fourth_powers) / len(fourth_powers)) ** 0.25)) + + +# ── detection algorithm ─────────────────────────────────────────────────────── + +def _bboxes_overlap(a: list[float], b: list[float]) -> bool: + return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1]) + + +def _conformance_ok( + track: ActivityTrack, + seg: Segment, + i: int, + j: int, +) -> bool: + """Check that the track slice [i..j] follows the segment polyline.""" + interior = seg.polyline[1:-1] + if not interior: + return True # trivial 2-point segment + failing = 0 + for sp in interior: + slat, slon = sp[0], sp[1] + min_d = min( + _dist(slat, slon, track.lats[k], track.lons[k]) + for k in range(i, j + 1) + ) + if min_d > CONFORMANCE_MAX_DEV_M: + failing += 1 + return (failing / len(interior)) <= CONFORMANCE_MAX_FRAC + + +def _extract_effort( + track: ActivityTrack, + seg: Segment, + i: int, + j: int, +) -> SegmentEffort: + elapsed_s = track.times[j] - track.times[i] + started_at = track.started_at + timedelta(seconds=track.times[i]) + avg_speed = _avg_nonnull(track.speeds, i, j) + avg_hr_raw = _avg_nonnull(track.hrs, i, j) + avg_hr = int(round(avg_hr_raw)) if avg_hr_raw is not None else None + avg_pwr_raw = _avg_nonnull(track.powers, i, j) + avg_pwr = int(round(avg_pwr_raw)) if avg_pwr_raw is not None else None + np_pwr = _np_power(track.powers, i, j) if any(v is not None for v in track.powers[i:j + 1]) else None + return SegmentEffort( + activity_id=track.activity_id, + started_at=started_at, + elapsed_s=max(1, elapsed_s), + avg_speed_kmh=round(avg_speed, 2) if avg_speed is not None else None, + avg_hr_bpm=avg_hr, + avg_power_w=avg_pwr, + np_power_w=np_pwr, + detected_at=datetime.now(timezone.utc), + ) + + +def detect_one(track: ActivityTrack, seg: Segment) -> list[SegmentEffort]: + """Return all matching efforts for a single segment against a track.""" + if not track.bbox or not _bboxes_overlap(track.bbox, seg.bbox): + return [] + if seg.sport and seg.sport != track.sport: + return [] + + seg_start_lat, seg_start_lon = seg.polyline[0][0], seg.polyline[0][1] + seg_end_lat, seg_end_lon = seg.polyline[-1][0], seg.polyline[-1][1] + n = len(track.lats) + efforts: list[SegmentEffort] = [] + + search_from = 0 + while search_from < n - 1: + # Find next start candidate from search_from. + start_idx = None + for i in range(search_from, n): + if _dist(seg_start_lat, seg_start_lon, track.lats[i], track.lons[i]) <= MATCH_RADIUS_M: + start_idx = i + break + if start_idx is None: + break + + # Scan forward from start_idx for an end candidate. + end_idx = None + for j in range(start_idx + 1, n): + if _dist(seg_end_lat, seg_end_lon, track.lats[j], track.lons[j]) <= MATCH_RADIUS_M: + end_idx = j + break + + if end_idx is None: + # No end found — no more efforts possible starting at or after start_idx. + break + + if _conformance_ok(track, seg, start_idx, end_idx): + efforts.append(_extract_effort(track, seg, start_idx, end_idx)) + search_from = end_idx + 1 + else: + # Conformance failed; try next start candidate after start_idx. + search_from = start_idx + 1 + + return efforts + + +def detect_all( + track: ActivityTrack, + handle: str, + data_dir: Path, +) -> int: + """Detect efforts for all segments and persist them. Returns effort count.""" + from bincio.segments import store as _store + + segments = _store.list_segments(data_dir) + total = 0 + for seg in segments: + efforts = detect_one(track, seg) + for effort in efforts: + _store.add_effort(data_dir, handle, seg.id, effort) + total += len(efforts) + return total diff --git a/bincio/serve/server.py b/bincio/serve/server.py index ab2903d..e3eb32c 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -2484,6 +2484,87 @@ async def delete_segment( return JSONResponse({"ok": True}) +@app.get("/api/segments/{segment_id}/efforts") +async def get_segment_efforts( + segment_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Return all efforts on a segment for the logged-in user.""" + user = _require_user(bincio_session) + dd = _get_data_dir() + seg = _seg_store.load_segment(dd, segment_id) + if seg is None: + raise HTTPException(404, "Segment not found") + efforts = _seg_store.load_efforts(dd, user.handle, segment_id) + return JSONResponse([ + { + "activity_id": e.activity_id, + "started_at": _seg_store._iso(e.started_at), + "elapsed_s": e.elapsed_s, + "avg_speed_kmh": e.avg_speed_kmh, + "avg_hr_bpm": e.avg_hr_bpm, + "avg_power_w": e.avg_power_w, + "np_power_w": e.np_power_w, + } + for e in efforts + ]) + + +@app.post("/api/segments/{segment_id}/detect") +async def trigger_detect( + segment_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Retroactively detect efforts on a segment for the logged-in user.""" + user = _require_user(bincio_session) + dd = _get_data_dir() + seg = _seg_store.load_segment(dd, segment_id) + if seg is None: + raise HTTPException(404, "Segment not found") + + from datetime import datetime as _datetime + from bincio.segments.detect import track_from_timeseries_json, detect_one + import json as _json + + user_dir = dd / user.handle + acts_dir = user_dir / "activities" + total = 0 + for detail_path in sorted(acts_dir.glob("*.json")): + if ".timeseries." in detail_path.name: + continue + try: + detail = _json.loads(detail_path.read_text(encoding="utf-8")) + except Exception: + continue + ts_url = detail.get("timeseries_url") + if not ts_url: + continue + ts_path = user_dir / ts_url + if not ts_path.exists(): + continue + try: + ts = _json.loads(ts_path.read_text(encoding="utf-8")) + except Exception: + continue + started_raw = detail.get("started_at") + if not started_raw: + continue + try: + started_at = _datetime.fromisoformat(started_raw.replace("Z", "+00:00")) + except Exception: + continue + track = track_from_timeseries_json(ts, detail.get("id", detail_path.stem), + detail.get("sport", "other"), started_at) + if track is None: + continue + efforts = detect_one(track, seg) + for effort in efforts: + _seg_store.add_effort(dd, user.handle, segment_id, effort) + total += len(efforts) + + return JSONResponse({"ok": True, "efforts_found": total}) + + # ── Feedback ────────────────────────────────────────────────────────────────── _FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"} diff --git a/segments-plan.md b/segments-plan.md new file mode 100644 index 0000000..1925094 --- /dev/null +++ b/segments-plan.md @@ -0,0 +1,189 @@ +# 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