fix low level issues

This commit is contained in:
Davide Scaini
2026-03-31 23:22:12 +02:00
parent 8f91503cf7
commit 81438231b4
19 changed files with 126 additions and 44 deletions
+22
View File
@@ -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 - **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 - **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 ### 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 - **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 - **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) - **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 ### 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 - **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
+1 -1
View File
@@ -64,7 +64,7 @@ def _resolve_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Pa
if config_path and Path(config_path).exists(): if config_path and Path(config_path).exists():
import yaml 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") out = raw.get("output", {}).get("dir")
if out: if out:
return Path(out).expanduser().resolve() return Path(out).expanduser().resolve()
+1 -6
View File
@@ -506,12 +506,7 @@ async def save_athlete(payload: dict[str, Any]) -> JSONResponse:
encoding="utf-8", encoding="utf-8",
) )
# Patch athlete.json in-place (preserves power_curve, updated_at, etc.) # Re-merge — merge_all() applies edits/athlete.yaml on top of athlete.json
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
from bincio.render.merge import merge_all from bincio.render.merge import merge_all
merge_all(dd) merge_all(dd)
+3 -2
View File
@@ -346,5 +346,6 @@ def _patch_duplicate_of(output_dir: Path, activity_id: str, canonical_id: str) -
data = json.loads(p.read_text()) data = json.loads(p.read_text())
data["duplicate_of"] = canonical_id data["duplicate_of"] = canonical_id
p.write_text(json.dumps(data, indent=2, ensure_ascii=False)) p.write_text(json.dumps(data, indent=2, ensure_ascii=False))
except Exception: except Exception as e:
pass import logging
logging.getLogger(__name__).warning("_patch_duplicate_of failed for %s: %s", activity_id, e)
+18 -8
View File
@@ -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]: def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]:
"""Maximum net elevation gain over any contiguous window (Kadane's on deltas). """Maximum net elevation gain over any contiguous window (Kadane's on deltas).
Ignores samples where elevation is None. Returns None if fewer than two None samples are treated as breaks between segments — the Kadane window is
valid elevation samples exist. 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] non_null = sum(1 for e in ele_1hz if e is not None)
if len(valid) < 2: if non_null < 2:
return None return None
max_gain = 0.0 max_gain = 0.0
current = 0.0 current = 0.0
for a, b in zip(valid, valid[1:]): prev: Optional[float] = None
current = max(0.0, current + (b - a))
if current > max_gain: for e in ele_1hz:
max_gain = current 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 return round(max_gain, 1) if max_gain > 0 else None
+4 -4
View File
@@ -43,11 +43,11 @@ def preview_coords(
mask = rdp(coords, epsilon=0.001, return_mask=True) mask = rdp(coords, epsilon=0.001, return_mask=True)
reduced = [gps[i] for i, keep in enumerate(mask) if keep] 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: if len(reduced) > max_points:
step = len(reduced) / max_points step = len(reduced) / (max_points - 1)
reduced = [reduced[int(i * step)] for i in range(max_points)] reduced = [reduced[int(i * step)] for i in range(max_points - 1)]
reduced.append(gps[-1]) # always include the last point reduced.append(gps[-1])
return [[round(lat, 5), round(lon, 5)] for lat, lon in reduced] return [[round(lat, 5), round(lon, 5)] for lat, lon in reduced]
+2 -2
View File
@@ -29,8 +29,8 @@ def build_timeseries(
t = int((p.timestamp - started_at).total_seconds()) t = int((p.timestamp - started_at).total_seconds())
if t < 0: if t < 0:
continue continue
if last_t is not None and t == last_t: if last_t is not None and t <= last_t:
continue # skip sub-second duplicates continue # skip sub-second duplicates and non-monotonic points
sampled.append(p) sampled.append(p)
last_t = t last_t = t
+2 -2
View File
@@ -38,7 +38,7 @@ def _find_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path:
if config_path and Path(config_path).exists(): if config_path and Path(config_path).exists():
import yaml 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") out = raw.get("output", {}).get("dir")
if out: if out:
return Path(out).expanduser().resolve() 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" auto_config = Path.cwd() / "extract_config.yaml"
if auto_config.exists(): if auto_config.exists():
import yaml 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") out = raw.get("output", {}).get("dir")
if out: if out:
return Path(out).expanduser().resolve() return Path(out).expanduser().resolve()
+16 -2
View File
@@ -140,13 +140,27 @@ def merge_all(data_dir: Path) -> int:
if not dest_img.exists(): if not dest_img.exists():
dest_img.symlink_to(img_dir.resolve()) 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_src = data_dir / "athlete.json"
athlete_dest = merged_dir / "athlete.json" athlete_dest = merged_dir / "athlete.json"
if athlete_dest.exists() or athlete_dest.is_symlink(): if athlete_dest.exists() or athlete_dest.is_symlink():
athlete_dest.unlink() athlete_dest.unlink()
if athlete_src.exists(): 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) # Write merged index.json (private filtered, highlight sorted)
index_path = data_dir / "index.json" index_path = data_dir / "index.json"
+11 -1
View File
@@ -120,7 +120,17 @@
return el; 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);
}
</script> </script>
<div bind:this={mapEl} class="w-full h-full"></div> <div bind:this={mapEl} class="w-full h-full"></div>
+3 -1
View File
@@ -5,6 +5,8 @@
import RecordsView from './RecordsView.svelte'; import RecordsView from './RecordsView.svelte';
import AthleteDrawer from './AthleteDrawer.svelte'; import AthleteDrawer from './AthleteDrawer.svelte';
export let base: string = '/';
let athlete: AthleteJson | null = null; let athlete: AthleteJson | null = null;
let activities: ActivitySummary[] = []; let activities: ActivitySummary[] = [];
let loading = true; let loading = true;
@@ -106,7 +108,7 @@
<!-- Records tab --> <!-- Records tab -->
{:else if activeTab === 'records'} {:else if activeTab === 'records'}
<RecordsView {athlete} /> <RecordsView {athlete} {base} />
<!-- Profile tab --> <!-- Profile tab -->
{:else if activeTab === 'profile'} {:else if activeTab === 'profile'}
+2 -1
View File
@@ -4,6 +4,7 @@
import { formatDate, sportIcon, sportColor } from '../lib/format'; import { formatDate, sportIcon, sportColor } from '../lib/format';
export let athlete: AthleteJson; export let athlete: AthleteJson;
export let base: string = '/';
// ── Distance label formatting ────────────────────────────────────────────── // ── Distance label formatting ──────────────────────────────────────────────
function distLabel(km: number): string { function distLabel(km: number): string {
@@ -96,7 +97,7 @@
return (athlete as any).records?.[sport]?.[key] ?? null; return (athlete as any).records?.[sport]?.[key] ?? null;
} }
const activityUrl = (id: string) => `/activity/${id}/`; const activityUrl = (id: string) => `${base}activity/${id}/`;
</script> </script>
<!-- Sport tabs --> <!-- Sport tabs -->
+11 -3
View File
@@ -9,6 +9,7 @@
let sport: Sport | 'all' = 'all'; let sport: Sport | 'all' = 'all';
let page = 0; let page = 0;
let loading = true; let loading = true;
let error = '';
let theme = 'dark'; let theme = 'dark';
let mounted = false; let mounted = false;
@@ -28,9 +29,14 @@
sport = (params.get('sport') as Sport | 'all') ?? 'all'; sport = (params.get('sport') as Sport | 'all') ?? 'all';
page = parseInt(params.get('page') ?? '0', 10) || 0; page = parseInt(params.get('page') ?? '0', 10) || 0;
mounted = true; mounted = true;
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`); try {
const index: BASIndex = await res.json(); const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m); 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; loading = false;
theme = document.documentElement.getAttribute('data-theme') ?? 'dark'; theme = document.documentElement.getAttribute('data-theme') ?? 'dark';
@@ -282,6 +288,8 @@
{#if loading} {#if loading}
<div class="h-64 rounded-xl bg-zinc-800 animate-pulse mb-6"></div> <div class="h-64 rounded-xl bg-zinc-800 animate-pulse mb-6"></div>
{:else if error}
<p class="text-red-400 text-sm mt-4">{error}</p>
{:else} {:else}
<!-- Pagination controls --> <!-- Pagination controls -->
+3 -2
View File
@@ -5,6 +5,7 @@ interface Props {
} }
const { title = 'BincioActivity', description = 'Your personal activity stats' } = Astro.props; const { title = 'BincioActivity', description = 'Your personal activity stats' } = Astro.props;
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
const baseUrl = import.meta.env.BASE_URL ?? '/';
--- ---
<!doctype html> <!doctype html>
<html lang="en" data-theme="dark"> <html lang="en" data-theme="dark">
@@ -174,7 +175,7 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
</script> </script>
{editUrl && ( {editUrl && (
<script define:vars={{ editUrl }}> <script define:vars={{ editUrl, baseUrl }}>
const modal = document.getElementById('upload-modal'); const modal = document.getElementById('upload-modal');
const drop = document.getElementById('upload-drop'); const drop = document.getElementById('upload-drop');
const input = document.getElementById('upload-input'); const input = document.getElementById('upload-input');
@@ -217,7 +218,7 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
const d = await r.json(); const d = await r.json();
status.textContent = 'Done! Opening activity…'; status.textContent = 'Done! Opening activity…';
status.style.color = '#4ade80'; status.style.color = '#4ade80';
setTimeout(() => { window.location.href = `/activity/${d.id}/`; }, 600); setTimeout(() => { window.location.href = `${baseUrl}activity/${d.id}/`; }, 600);
} catch (e) { } catch (e) {
status.textContent = 'Error: ' + e.message; status.textContent = 'Error: ' + e.message;
status.style.color = '#f87171'; status.style.color = '#f87171';
+1
View File
@@ -12,6 +12,7 @@ export function formatDistance(m: number | null, unit: 'metric' | 'imperial' = '
export function formatDuration(s: number | null): string { export function formatDuration(s: number | null): string {
if (s == null) return '—'; if (s == null) return '—';
s = Math.floor(s);
const h = Math.floor(s / 3600); const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60); const m = Math.floor((s % 3600) / 60);
const sec = s % 60; const sec = s % 60;
+2 -1
View File
@@ -1,8 +1,9 @@
--- ---
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import AthleteView from '../../components/AthleteView.svelte'; import AthleteView from '../../components/AthleteView.svelte';
const base = import.meta.env.BASE_URL;
--- ---
<Base title="Athlete — BincioActivity"> <Base title="Athlete — BincioActivity">
<h1 class="text-2xl font-bold text-white mb-6">Athlete</h1> <h1 class="text-2xl font-bold text-white mb-6">Athlete</h1>
<AthleteView client:load /> <AthleteView {base} client:load />
</Base> </Base>
+2 -2
View File
@@ -67,10 +67,10 @@ BASE_DETAIL = {
def test_apply_sidecar_title_and_sport(): def test_apply_sidecar_title_and_sport():
fm = {"title": "Renamed", "sport": "gravel"} fm = {"title": "Renamed", "sport": "running"}
result = apply_sidecar(BASE_DETAIL, fm, "") result = apply_sidecar(BASE_DETAIL, fm, "")
assert result["title"] == "Renamed" assert result["title"] == "Renamed"
assert result["sport"] == "gravel" assert result["sport"] == "running"
# Original must be unchanged # Original must be unchanged
assert BASE_DETAIL["title"] == "Morning Ride" assert BASE_DETAIL["title"] == "Morning Ride"
+20 -1
View File
@@ -1,4 +1,4 @@
from bincio.extract.sport import normalise_sport from bincio.extract.sport import normalise_sport, normalise_sub_sport
def test_cycling_variants(): def test_cycling_variants():
@@ -14,3 +14,22 @@ def test_running_variants():
def test_unknown_falls_back_to_other(): def test_unknown_falls_back_to_other():
assert normalise_sport("yoga") == "other" assert normalise_sport("yoga") == "other"
assert normalise_sport(None) == "other" assert normalise_sport(None) == "other"
def test_sub_sport_strava_camelcase():
assert normalise_sub_sport("MountainBikeRide") == "mountain"
assert normalise_sub_sport("GravelRide") == "gravel"
assert normalise_sub_sport("VirtualRide") == "indoor"
assert normalise_sub_sport("Ride") == "road"
def test_sub_sport_ski_variants():
assert normalise_sub_sport("AlpineSki") == "alpine"
assert normalise_sub_sport("NordicSki") == "nordic"
assert normalise_sub_sport("BackcountrySki") == "nordic"
def test_sub_sport_unknown_returns_none():
assert normalise_sub_sport("yoga") is None
assert normalise_sub_sport(None) is None
assert normalise_sub_sport("generic") is None
+2 -5
View File
@@ -18,16 +18,13 @@ def _dummy_activity(title=None):
def test_id_with_title(): def test_id_with_title():
act = _dummy_activity("Morning Ride") act = _dummy_activity("Morning Ride")
aid = make_activity_id(act) aid = make_activity_id(act)
assert aid.startswith("2024-06-01T") assert aid == "2024-06-01T073012Z-morning-ride"
assert "morning-ride" in aid
def test_id_without_title(): def test_id_without_title():
act = _dummy_activity() act = _dummy_activity()
aid = make_activity_id(act) aid = make_activity_id(act)
assert "2024-06-01T" in aid assert aid == "2024-06-01T073012Z"
# No trailing dash
assert not aid.endswith("-")
def test_slugify(): def test_slugify():