diff --git a/bincio/edit/ops.py b/bincio/edit/ops.py index 50a1f16..0f6d2aa 100644 --- a/bincio/edit/ops.py +++ b/bincio/edit/ops.py @@ -13,7 +13,10 @@ from typing import Any, Optional # ── Shared constants (imported by edit/server.py and serve/server.py) ───────── +from bincio.extract.sport import SUB_SPORTS as _SUB_SPORTS + SPORTS = ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"] +_VALID_SUB_SPORTS = {v for vs in _SUB_SPORTS.values() for v in vs} STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"] VALID_ACTIVITY_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9\-]{0,250}$') @@ -36,6 +39,8 @@ def apply_sidecar_edit(activity_id: str, payload: dict[str, Any], data_dir: Path lines.append(f"title: {json.dumps(payload['title'])}") if payload.get("sport") and payload["sport"] in SPORTS and payload["sport"] != "other": lines.append(f"sport: {payload['sport']}") + if payload.get("sub_sport") and payload["sub_sport"] in _VALID_SUB_SPORTS: + lines.append(f"sub_sport: {payload['sub_sport']}") if payload.get("gear"): lines.append(f"gear: {json.dumps(payload['gear'])}") if payload.get("highlight"): diff --git a/bincio/extract/sport.py b/bincio/extract/sport.py index 47cac3e..a9e7482 100644 --- a/bincio/extract/sport.py +++ b/bincio/extract/sport.py @@ -99,6 +99,14 @@ _SUB_SPORT_MAPPING: dict[str, str] = { BAS_SPORTS = {"cycling", "running", "hiking", "walking", "swimming", "skiing", "other"} +# Valid sub_sport values per sport, in display order. +SUB_SPORTS: dict[str, list[str]] = { + "cycling": ["road", "mountain", "gravel", "indoor"], + "running": ["trail", "track", "indoor"], + "swimming": ["open_water", "pool"], + "skiing": ["nordic", "alpine"], +} + def _normalise_key(raw: object) -> str: key = str(raw).strip() diff --git a/bincio/render/merge.py b/bincio/render/merge.py index 264b6a7..d21a495 100644 --- a/bincio/render/merge.py +++ b/bincio/render/merge.py @@ -54,6 +54,8 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict: d["title"] = str(fm["title"]) if "sport" in fm: d["sport"] = str(fm["sport"]) + if "sub_sport" in fm: + d["sub_sport"] = str(fm["sub_sport"]) if fm["sub_sport"] else None if "gear" in fm: d["gear"] = str(fm["gear"]) if fm["gear"] else d.get("gear") if body: @@ -80,6 +82,8 @@ def _apply_sidecar_summary(summary: dict, fm: dict) -> dict: s["title"] = str(fm["title"]) if "sport" in fm: s["sport"] = str(fm["sport"]) + if "sub_sport" in fm: + s["sub_sport"] = str(fm["sub_sport"]) if fm["sub_sport"] else None if "highlight" in fm: s["custom"]["highlight"] = bool(fm["highlight"]) if "private" in fm: diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 77612ea..3ed5335 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -100,6 +100,7 @@ class ActivityEditRequest(BaseModel): title: str | None = Field(default=None, description="Activity title") description: str | None = Field(default=None, description="Activity description (markdown)") sport: str | None = Field(default=None, description="Sport type") + sub_sport: str | None = Field(default=None, description="Sport sub-category (e.g. road, trail, pool)") private: bool | None = Field(default=None, description="Hide from public feed") highlight: bool | None = Field(default=None, description="Mark as favorite") gear: str | None = Field(default=None, description="Gear used (e.g., 'Trek Domane')") diff --git a/site/src/components/EditDrawer.svelte b/site/src/components/EditDrawer.svelte index 985cef0..c262027 100644 --- a/site/src/components/EditDrawer.svelte +++ b/site/src/components/EditDrawer.svelte @@ -8,6 +8,13 @@ const dispatch = createEventDispatcher<{ saved: { title: string; description: string }; close: void; deleted: void }>(); const SPORTS: Sport[] = ['cycling', 'running', 'hiking', 'walking', 'swimming', 'skiing', 'other']; + + const SUB_SPORTS: Partial> = { + cycling: [{ key: 'road', label: 'Road' }, { key: 'mountain', label: 'Mountain' }, { key: 'gravel', label: 'Gravel' }, { key: 'indoor', label: 'Indoor' }], + running: [{ key: 'trail', label: 'Trail' }, { key: 'track', label: 'Track' }, { key: 'indoor', label: 'Indoor' }], + swimming: [{ key: 'open_water', label: 'Open water' }, { key: 'pool', label: 'Pool' }], + skiing: [{ key: 'nordic', label: 'Nordic' }, { key: 'alpine', label: 'Alpine' }], + }; const STAT_PANELS = [ { key: 'elevation', label: 'Elevation' }, { key: 'speed', label: 'Speed' }, @@ -32,6 +39,7 @@ // Form state let title = ''; let sport: Sport = 'cycling'; + let subSport = ''; let gear = ''; let description = ''; let highlight = false; @@ -55,6 +63,7 @@ const d = await res.json(); title = d.title ?? ''; sport = d.sport ?? 'cycling'; + subSport = d.sub_sport ?? ''; gear = d.gear ?? ''; // Strip any auto-inserted image markdown refs — images are tracked via custom.images description = (d.description ?? '').replace(/!\[[^\]]*\]\([^)]+\)\n?/g, '').trim(); @@ -79,7 +88,7 @@ const res = await fetch(api, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, sport, gear, description, highlight, private: isPrivate, hide_stats: hideStats }), + body: JSON.stringify({ title, sport, sub_sport: subSport || null, gear, description, highlight, private: isPrivate, hide_stats: hideStats }), }); if (!res.ok) throw new Error(await res.text()); saveStatus = 'Saved'; @@ -204,13 +213,14 @@ /> - -
+ +
+ + {#each SUB_SPORTS[sport] as o} + + {/each} + +
+ {:else} +
+ {/if}