diff --git a/SCHEMA.md b/SCHEMA.md index d9bcc12..6235587 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -110,8 +110,8 @@ needed to render an activity card in a feed — no timeseries, no full track. |---|---|---|---| | `id` | string | yes | Unique identifier. See **Activity ID** section. | | `title` | string | yes | Human-readable name. May be auto-generated if not in source. | -| `sport` | string | yes | One of: `cycling`, `running`, `hiking`, `walking`, `swimming`, `other`. | -| `sub_sport` | string\|null | no | e.g. `road`, `mountain`, `gravel`, `indoor`, `trail`. | +| `sport` | string | yes | One of: `cycling`, `running`, `hiking`, `walking`, `swimming`, `skiing`, `other`. | +| `sub_sport` | string\|null | no | e.g. `road`, `mountain`, `gravel`, `indoor`, `trail`, `track`, `nordic`, `alpine`, `open_water`, `pool`. | | `started_at` | string | yes | ISO 8601 timestamp with timezone. | | `distance_m` | number\|null | no | Total distance in metres. | | `duration_s` | integer\|null | no | Total elapsed time in seconds. | @@ -125,8 +125,12 @@ needed to render an activity card in a feed — no timeseries, no full track. | `avg_power_w` | integer\|null | no | Average power in watts. | | `source` | string\|null | no | Origin of data. See **Source values**. | | `privacy` | string | yes | One of: `public`, `blur_start`, `no_gps`, `private`. | +| `mmp` | array\|null | no | Mean Maximal Power curve — `[[duration_s, avg_watts], ...]`. | +| `best_efforts` | array\|null | no | Best efforts by distance — `[[distance_km, time_s], ...]`. | +| `best_climb_m` | number\|null | no | Best single climb in metres (Kadane's algorithm). | | `detail_url` | string\|null | no | Relative or absolute URL to the full activity JSON. | | `track_url` | string\|null | no | Relative or absolute URL to the GeoJSON track. `null` if `privacy` is `no_gps`. | +| `preview_coords` | array\|null | no | Simplified track preview — `[[lon, lat], ...]` for card thumbnails. | ### Activity ID diff --git a/bincio/edit/server.py b/bincio/edit/server.py index f8e5fe3..0cd8077 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -265,11 +265,15 @@ async function uploadFiles(files) { } } +function escapeHtml(s) { + return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); +} + function renderImageList() { const list = document.getElementById('image-list'); list.innerHTML = uploadedImages.map(f => - `${f} - + `${escapeHtml(f)} + ` ).join(''); } @@ -405,7 +409,7 @@ async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONRespon lines: list[str] = [] if payload.get("title"): lines.append(f"title: {json.dumps(payload['title'])}") - if payload.get("sport") and payload["sport"] != "other": + if payload.get("sport") and payload["sport"] in SPORTS and payload["sport"] != "other": lines.append(f"sport: {payload['sport']}") if payload.get("gear"): lines.append(f"gear: {json.dumps(payload['gear'])}") @@ -443,7 +447,12 @@ async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONRe images_dir = dd / "edits" / "images" / activity_id images_dir.mkdir(parents=True, exist_ok=True) - dest = images_dir / Path(file.filename).name + safe_name = Path(file.filename).name + # Only allow image content types + ct = file.content_type or "" + if not ct.startswith("image/"): + raise HTTPException(400, f"Only image files are accepted (got {ct})") + dest = images_dir / safe_name dest.write_bytes(await file.read()) return JSONResponse({"ok": True, "filename": dest.name}) diff --git a/bincio/extract/config.py b/bincio/extract/config.py index 9df9916..1c8c6a1 100644 --- a/bincio/extract/config.py +++ b/bincio/extract/config.py @@ -58,7 +58,7 @@ class ExtractConfig: def load_config(path: Path) -> ExtractConfig: - raw = yaml.safe_load(path.read_text()) + raw = yaml.safe_load(path.read_text()) or {} inp = raw.get("input", {}) dirs = [Path(d).expanduser() for d in inp.get("dirs", [])] diff --git a/bincio/render/merge.py b/bincio/render/merge.py index d204771..53befd3 100644 --- a/bincio/render/merge.py +++ b/bincio/render/merge.py @@ -155,9 +155,10 @@ def merge_all(data_dir: Path) -> int: edits = {} else: edits = {} + _ATHLETE_EDITABLE = {"max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"} if edits: athlete_data = json.loads(athlete_src.read_text(encoding="utf-8")) - athlete_data.update(edits) + athlete_data.update({k: v for k, v in edits.items() if k in _ATHLETE_EDITABLE}) athlete_dest.write_text(json.dumps(athlete_data, indent=2, ensure_ascii=False)) else: athlete_dest.symlink_to(athlete_src.resolve()) diff --git a/site/src/components/MmpChart.svelte b/site/src/components/MmpChart.svelte index d2025f6..4a9efcb 100644 --- a/site/src/components/MmpChart.svelte +++ b/site/src/components/MmpChart.svelte @@ -149,9 +149,14 @@ $: renderChart(plotData, colorMap); - // Re-render on resize + // Re-render on resize — use indirect call so we always get current reactive values + let currentPlotData = plotData; + let currentColorMap = colorMap; + $: currentPlotData = plotData; + $: currentColorMap = colorMap; + onMount(() => { - const ro = new ResizeObserver(() => renderChart(plotData, colorMap)); + const ro = new ResizeObserver(() => renderChart(currentPlotData, currentColorMap)); ro.observe(chartEl); return () => ro.disconnect(); }); diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 3ffc0a2..3f9cf8f 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -98,12 +98,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/'; style="border-color: var(--border)" >
- + BincioActivity - Feed - Stats - Athlete + Feed + Stats + Athlete
{editUrl && ( diff --git a/site/src/lib/types.ts b/site/src/lib/types.ts index ba6763b..3bf0b27 100644 --- a/site/src/lib/types.ts +++ b/site/src/lib/types.ts @@ -99,7 +99,7 @@ export interface Timeseries { temperature_c: (number | null)[]; } -export interface ActivityDetail extends ActivitySummary { +export interface ActivityDetail extends Omit { description: string | null; elevation_loss_m: number | null; max_power_w: number | null;