diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e56ff..5bcb5e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,22 @@ - **No error handling in `uploadImages`** (`EditDrawer.svelte`) — wrapped upload loop in try/catch/finally so a network error clears the `uploading` spinner and surfaces an error message instead of locking the UI - **Stats page pagination** (`StatsView.svelte`) — heatmap now shows 4 years per page with ← Newer / Older → controls; `?page=` persisted in URL +### Bug fixes — data (continued) + +- **`_best_climb` joins non-contiguous elevation segments** (`metrics.py`) — `None` elevation samples now reset the Kadane's window instead of being skipped and joined; GPS blackout segments can no longer inflate climb values +- **`save_athlete` mutated `athlete.json` in-place** (`edit/server.py`, `render/merge.py`) — server now only writes `edits/athlete.yaml`; `merge_all()` applies the sidecar overlay when producing `_merged/athlete.json`, preserving extract immutability +- **`preview_coords` off-by-one** (`simplify.py`) — subsampler was appending the final GPS point on top of `max_points`, returning `max_points + 1`; now samples `max_points - 1` slots then appends last point +- **Non-monotonic timeseries** (`timeseries.py`) — dedup guard changed from `t == last_t` to `t <= last_t`; backwards timestamps from corrupt files are now dropped instead of creating out-of-order `t` arrays +- **`_patch_duplicate_of` silently swallows exceptions** (`cli.py`) — changed bare `except: pass` to log a warning so failures surface during extract + +### Bug fixes — frontend (continued) + +- **Hardcoded `/activity/` URLs ignore `BASE_URL`** (`RecordsView.svelte`, `Base.astro`) — `base` prop now threaded from Astro page → `AthleteView` → `RecordsView`; upload redirect uses `import.meta.env.BASE_URL` via `define:vars` +- **No error handling on stats page fetch** (`StatsView.svelte`) — `index.json` fetch wrapped in try/catch; error message displayed in place of heatmap instead of silent failure +- **Map doesn't resize on container change** (`ActivityMap.svelte`) — `ResizeObserver` added to call `map.resize()` when the map container is resized +- **`formatDuration` fractional seconds** (`format.ts`) — input floored with `Math.floor` before arithmetic; `1500.7 s` no longer displays as `25m 00.7s` +- **Empty YAML config crashes** (`render/cli.py`, `edit/cli.py`) — `yaml.safe_load()` result guarded with `or {}`; empty config file no longer throws `AttributeError` on `.get()` + ### Schema - **Writer output now matches schema** (`bas-v1.schema.json`) — `mmp`, `best_efforts`, `best_climb_m`, `preview_coords`, and `custom` are all declared in the schema; previously `additionalProperties: false` caused validation failures @@ -34,6 +50,12 @@ - **Sub-sport enum extended** — `nordic`, `alpine`, `open_water`, `pool` added to schema - **Activity ID format corrected in SCHEMA.md** — examples updated from `+0200` offset to `Z` UTC suffix (matching actual code behaviour since v0.1.0) +### Tests + +- **Exact ID assertions** (`test_writer.py`) — `test_id_with_title` and `test_id_without_title` now assert the full ID string (`2024-06-01T073012Z-morning-ride`) instead of substrings +- **`normalise_sub_sport` test coverage** (`test_sport.py`) — 3 new tests: Strava CamelCase conversion, ski variants, and unknown/None → `None` +- **Invalid sport in test_merge** (`test_merge.py`) — `sport: "gravel"` replaced with valid `"running"` + ### Navigation - **URL state persistence** — filter and tab state is now stored in the URL query string so the browser back button always restores the exact view you left diff --git a/bincio/edit/cli.py b/bincio/edit/cli.py index ce19483..55406c7 100644 --- a/bincio/edit/cli.py +++ b/bincio/edit/cli.py @@ -64,7 +64,7 @@ def _resolve_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Pa if config_path and Path(config_path).exists(): import yaml - raw = yaml.safe_load(Path(config_path).read_text()) + raw = yaml.safe_load(Path(config_path).read_text()) or {} out = raw.get("output", {}).get("dir") if out: return Path(out).expanduser().resolve() diff --git a/bincio/edit/server.py b/bincio/edit/server.py index 71fbef1..f8e5fe3 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -506,12 +506,7 @@ async def save_athlete(payload: dict[str, Any]) -> JSONResponse: encoding="utf-8", ) - # Patch athlete.json in-place (preserves power_curve, updated_at, etc.) - data = json.loads(athlete_path.read_text(encoding="utf-8")) - data.update(overrides) - athlete_path.write_text(json.dumps(data, indent=2, ensure_ascii=False)) - - # Re-merge so _merged/athlete.json symlink stays valid + # Re-merge — merge_all() applies edits/athlete.yaml on top of athlete.json from bincio.render.merge import merge_all merge_all(dd) diff --git a/bincio/extract/cli.py b/bincio/extract/cli.py index 473fe88..4a5fee7 100644 --- a/bincio/extract/cli.py +++ b/bincio/extract/cli.py @@ -346,5 +346,6 @@ def _patch_duplicate_of(output_dir: Path, activity_id: str, canonical_id: str) - data = json.loads(p.read_text()) data["duplicate_of"] = canonical_id p.write_text(json.dumps(data, indent=2, ensure_ascii=False)) - except Exception: - pass + except Exception as e: + import logging + logging.getLogger(__name__).warning("_patch_duplicate_of failed for %s: %s", activity_id, e) diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index 426c623..216db4e 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -237,19 +237,29 @@ def _fastest_time_for_distance(speed_1hz: list[float], target_km: float) -> Opti def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]: """Maximum net elevation gain over any contiguous window (Kadane's on deltas). - Ignores samples where elevation is None. Returns None if fewer than two - valid elevation samples exist. + None samples are treated as breaks between segments — the Kadane window is + reset to 0 at each gap so non-contiguous elevation data is never joined. + Returns None if fewer than two non-None samples exist. """ - valid = [e for e in ele_1hz if e is not None] - if len(valid) < 2: + non_null = sum(1 for e in ele_1hz if e is not None) + if non_null < 2: return None max_gain = 0.0 current = 0.0 - for a, b in zip(valid, valid[1:]): - current = max(0.0, current + (b - a)) - if current > max_gain: - max_gain = current + prev: Optional[float] = None + + for e in ele_1hz: + if e is None: + # Gap — reset window so we don't bridge the discontinuity + current = 0.0 + prev = None + continue + if prev is not None: + current = max(0.0, current + (e - prev)) + if current > max_gain: + max_gain = current + prev = e return round(max_gain, 1) if max_gain > 0 else None diff --git a/bincio/extract/simplify.py b/bincio/extract/simplify.py index e4dac55..7fd900f 100644 --- a/bincio/extract/simplify.py +++ b/bincio/extract/simplify.py @@ -43,11 +43,11 @@ def preview_coords( mask = rdp(coords, epsilon=0.001, return_mask=True) reduced = [gps[i] for i, keep in enumerate(mask) if keep] - # Subsample if still too many + # Subsample if still too many — always include last point without exceeding max_points if len(reduced) > max_points: - step = len(reduced) / max_points - reduced = [reduced[int(i * step)] for i in range(max_points)] - reduced.append(gps[-1]) # always include the last point + step = len(reduced) / (max_points - 1) + reduced = [reduced[int(i * step)] for i in range(max_points - 1)] + reduced.append(gps[-1]) return [[round(lat, 5), round(lon, 5)] for lat, lon in reduced] diff --git a/bincio/extract/timeseries.py b/bincio/extract/timeseries.py index 987f110..6b6a0b7 100644 --- a/bincio/extract/timeseries.py +++ b/bincio/extract/timeseries.py @@ -29,8 +29,8 @@ def build_timeseries( t = int((p.timestamp - started_at).total_seconds()) if t < 0: continue - if last_t is not None and t == last_t: - continue # skip sub-second duplicates + if last_t is not None and t <= last_t: + continue # skip sub-second duplicates and non-monotonic points sampled.append(p) last_t = t diff --git a/bincio/render/cli.py b/bincio/render/cli.py index 2a2466f..f8dbf89 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -38,7 +38,7 @@ def _find_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path: if config_path and Path(config_path).exists(): import yaml - raw = yaml.safe_load(Path(config_path).read_text()) + raw = yaml.safe_load(Path(config_path).read_text()) or {} out = raw.get("output", {}).get("dir") if out: return Path(out).expanduser().resolve() @@ -47,7 +47,7 @@ def _find_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path: auto_config = Path.cwd() / "extract_config.yaml" if auto_config.exists(): import yaml - raw = yaml.safe_load(auto_config.read_text()) + raw = yaml.safe_load(auto_config.read_text()) or {} out = raw.get("output", {}).get("dir") if out: return Path(out).expanduser().resolve() diff --git a/bincio/render/merge.py b/bincio/render/merge.py index c3be9c6..d204771 100644 --- a/bincio/render/merge.py +++ b/bincio/render/merge.py @@ -140,13 +140,27 @@ def merge_all(data_dir: Path) -> int: if not dest_img.exists(): dest_img.symlink_to(img_dir.resolve()) - # Symlink athlete.json if present + # Produce merged athlete.json — base from extract overlaid with edits/athlete.yaml athlete_src = data_dir / "athlete.json" athlete_dest = merged_dir / "athlete.json" if athlete_dest.exists() or athlete_dest.is_symlink(): athlete_dest.unlink() if athlete_src.exists(): - athlete_dest.symlink_to(athlete_src.resolve()) + athlete_edits_path = data_dir / "edits" / "athlete.yaml" + if athlete_edits_path.exists(): + try: + import yaml as _yaml + edits = _yaml.safe_load(athlete_edits_path.read_text(encoding="utf-8")) or {} + except Exception: + edits = {} + else: + edits = {} + if edits: + athlete_data = json.loads(athlete_src.read_text(encoding="utf-8")) + athlete_data.update(edits) + athlete_dest.write_text(json.dumps(athlete_data, indent=2, ensure_ascii=False)) + else: + athlete_dest.symlink_to(athlete_src.resolve()) # Write merged index.json (private filtered, highlight sorted) index_path = data_dir / "index.json" diff --git a/site/src/components/ActivityMap.svelte b/site/src/components/ActivityMap.svelte index 653003d..1131959 100644 --- a/site/src/components/ActivityMap.svelte +++ b/site/src/components/ActivityMap.svelte @@ -120,7 +120,17 @@ return el; } - onDestroy(() => map?.remove()); + onDestroy(() => { + resizeObserver?.disconnect(); + map?.remove(); + }); + + let resizeObserver: ResizeObserver; + $: if (mapEl && map) { + resizeObserver?.disconnect(); + resizeObserver = new ResizeObserver(() => map?.resize()); + resizeObserver.observe(mapEl); + }
diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index 822e984..ab3ba32 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -5,6 +5,8 @@ import RecordsView from './RecordsView.svelte'; import AthleteDrawer from './AthleteDrawer.svelte'; + export let base: string = '/'; + let athlete: AthleteJson | null = null; let activities: ActivitySummary[] = []; let loading = true; @@ -106,7 +108,7 @@ {:else if activeTab === 'records'} - + {:else if activeTab === 'profile'} diff --git a/site/src/components/RecordsView.svelte b/site/src/components/RecordsView.svelte index 0ee7b5d..6531369 100644 --- a/site/src/components/RecordsView.svelte +++ b/site/src/components/RecordsView.svelte @@ -4,6 +4,7 @@ import { formatDate, sportIcon, sportColor } from '../lib/format'; export let athlete: AthleteJson; + export let base: string = '/'; // ── Distance label formatting ────────────────────────────────────────────── function distLabel(km: number): string { @@ -96,7 +97,7 @@ return (athlete as any).records?.[sport]?.[key] ?? null; } - const activityUrl = (id: string) => `/activity/${id}/`; + const activityUrl = (id: string) => `${base}activity/${id}/`; diff --git a/site/src/components/StatsView.svelte b/site/src/components/StatsView.svelte index 50a34bf..d1bee17 100644 --- a/site/src/components/StatsView.svelte +++ b/site/src/components/StatsView.svelte @@ -9,6 +9,7 @@ let sport: Sport | 'all' = 'all'; let page = 0; let loading = true; + let error = ''; let theme = 'dark'; let mounted = false; @@ -28,9 +29,14 @@ sport = (params.get('sport') as Sport | 'all') ?? 'all'; page = parseInt(params.get('page') ?? '0', 10) || 0; mounted = true; - const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`); - const index: BASIndex = await res.json(); - all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m); + try { + const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const index: BASIndex = await res.json(); + all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m); + } catch (e: any) { + error = e.message; + } loading = false; theme = document.documentElement.getAttribute('data-theme') ?? 'dark'; @@ -282,6 +288,8 @@ {#if loading}
+{:else if error} +

{error}

{:else} diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 7f09cf5..3ffc0a2 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -5,6 +5,7 @@ interface Props { } const { title = 'BincioActivity', description = 'Your personal activity stats' } = Astro.props; const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; +const baseUrl = import.meta.env.BASE_URL ?? '/'; --- @@ -174,7 +175,7 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; {editUrl && ( -