Add sub_sport editing to activity edit drawer

This commit is contained in:
Davide Scaini
2026-05-12 23:01:12 +02:00
parent 93f6109028
commit 867da767eb
5 changed files with 48 additions and 3 deletions
+5
View File
@@ -13,7 +13,10 @@ from typing import Any, Optional
# ── Shared constants (imported by edit/server.py and serve/server.py) ───────── # ── 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"] 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"] STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"]
VALID_ACTIVITY_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9\-]{0,250}$') 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'])}") lines.append(f"title: {json.dumps(payload['title'])}")
if payload.get("sport") and payload["sport"] in SPORTS and payload["sport"] != "other": if payload.get("sport") and payload["sport"] in SPORTS and payload["sport"] != "other":
lines.append(f"sport: {payload['sport']}") 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"): if payload.get("gear"):
lines.append(f"gear: {json.dumps(payload['gear'])}") lines.append(f"gear: {json.dumps(payload['gear'])}")
if payload.get("highlight"): if payload.get("highlight"):
+8
View File
@@ -99,6 +99,14 @@ _SUB_SPORT_MAPPING: dict[str, str] = {
BAS_SPORTS = {"cycling", "running", "hiking", "walking", "swimming", "skiing", "other"} 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: def _normalise_key(raw: object) -> str:
key = str(raw).strip() key = str(raw).strip()
+4
View File
@@ -54,6 +54,8 @@ def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
d["title"] = str(fm["title"]) d["title"] = str(fm["title"])
if "sport" in fm: if "sport" in fm:
d["sport"] = str(fm["sport"]) 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: if "gear" in fm:
d["gear"] = str(fm["gear"]) if fm["gear"] else d.get("gear") d["gear"] = str(fm["gear"]) if fm["gear"] else d.get("gear")
if body: if body:
@@ -80,6 +82,8 @@ def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
s["title"] = str(fm["title"]) s["title"] = str(fm["title"])
if "sport" in fm: if "sport" in fm:
s["sport"] = str(fm["sport"]) 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: if "highlight" in fm:
s["custom"]["highlight"] = bool(fm["highlight"]) s["custom"]["highlight"] = bool(fm["highlight"])
if "private" in fm: if "private" in fm:
+1
View File
@@ -100,6 +100,7 @@ class ActivityEditRequest(BaseModel):
title: str | None = Field(default=None, description="Activity title") title: str | None = Field(default=None, description="Activity title")
description: str | None = Field(default=None, description="Activity description (markdown)") description: str | None = Field(default=None, description="Activity description (markdown)")
sport: str | None = Field(default=None, description="Sport type") 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") private: bool | None = Field(default=None, description="Hide from public feed")
highlight: bool | None = Field(default=None, description="Mark as favorite") highlight: bool | None = Field(default=None, description="Mark as favorite")
gear: str | None = Field(default=None, description="Gear used (e.g., 'Trek Domane')") gear: str | None = Field(default=None, description="Gear used (e.g., 'Trek Domane')")
+30 -3
View File
@@ -8,6 +8,13 @@
const dispatch = createEventDispatcher<{ saved: { title: string; description: string }; close: void; deleted: void }>(); const dispatch = createEventDispatcher<{ saved: { title: string; description: string }; close: void; deleted: void }>();
const SPORTS: Sport[] = ['cycling', 'running', 'hiking', 'walking', 'swimming', 'skiing', 'other']; const SPORTS: Sport[] = ['cycling', 'running', 'hiking', 'walking', 'swimming', 'skiing', 'other'];
const SUB_SPORTS: Partial<Record<Sport, { key: string; label: string }[]>> = {
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 = [ const STAT_PANELS = [
{ key: 'elevation', label: 'Elevation' }, { key: 'elevation', label: 'Elevation' },
{ key: 'speed', label: 'Speed' }, { key: 'speed', label: 'Speed' },
@@ -32,6 +39,7 @@
// Form state // Form state
let title = ''; let title = '';
let sport: Sport = 'cycling'; let sport: Sport = 'cycling';
let subSport = '';
let gear = ''; let gear = '';
let description = ''; let description = '';
let highlight = false; let highlight = false;
@@ -55,6 +63,7 @@
const d = await res.json(); const d = await res.json();
title = d.title ?? ''; title = d.title ?? '';
sport = d.sport ?? 'cycling'; sport = d.sport ?? 'cycling';
subSport = d.sub_sport ?? '';
gear = d.gear ?? ''; gear = d.gear ?? '';
// Strip any auto-inserted image markdown refs — images are tracked via custom.images // Strip any auto-inserted image markdown refs — images are tracked via custom.images
description = (d.description ?? '').replace(/!\[[^\]]*\]\([^)]+\)\n?/g, '').trim(); description = (d.description ?? '').replace(/!\[[^\]]*\]\([^)]+\)\n?/g, '').trim();
@@ -79,7 +88,7 @@
const res = await fetch(api, { const res = await fetch(api, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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()); if (!res.ok) throw new Error(await res.text());
saveStatus = 'Saved'; saveStatus = 'Saved';
@@ -204,13 +213,14 @@
/> />
</div> </div>
<!-- Sport + Gear --> <!-- Sport + Sub-sport + Gear -->
<div class="grid grid-cols-2 gap-3 mb-4"> <div class="grid grid-cols-2 gap-3 mb-3">
<div> <div>
<label class="block text-xs text-zinc-500 mb-1" for="ed-sport">Sport</label> <label class="block text-xs text-zinc-500 mb-1" for="ed-sport">Sport</label>
<select <select
id="ed-sport" id="ed-sport"
bind:value={sport} bind:value={sport}
on:change={() => { if (!SUB_SPORTS[sport]?.some(o => o.key === subSport)) subSport = ''; }}
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white outline-none focus:border-blue-500 transition-colors" class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white outline-none focus:border-blue-500 transition-colors"
> >
{#each SPORTS as s} {#each SPORTS as s}
@@ -229,6 +239,23 @@
/> />
</div> </div>
</div> </div>
{#if SUB_SPORTS[sport]}
<div class="mb-4">
<label class="block text-xs text-zinc-500 mb-1" for="ed-subsport">Sub-sport</label>
<select
id="ed-subsport"
bind:value={subSport}
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white outline-none focus:border-blue-500 transition-colors"
>
<option value="">— Auto (from file) —</option>
{#each SUB_SPORTS[sport] as o}
<option value={o.key}>{o.label}</option>
{/each}
</select>
</div>
{:else}
<div class="mb-4"></div>
{/if}
<!-- Description --> <!-- Description -->
<div class="mb-4"> <div class="mb-4">