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
- **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
+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():
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()
+1 -6
View File
@@ -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)
+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["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)
+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]:
"""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
+4 -4
View File
@@ -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]
+2 -2
View File
@@ -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
+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():
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()
+16 -2
View File
@@ -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"
+11 -1
View File
@@ -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);
}
</script>
<div bind:this={mapEl} class="w-full h-full"></div>
+3 -1
View File
@@ -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 @@
<!-- Records tab -->
{:else if activeTab === 'records'}
<RecordsView {athlete} />
<RecordsView {athlete} {base} />
<!-- Profile tab -->
{:else if activeTab === 'profile'}
+2 -1
View File
@@ -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}/`;
</script>
<!-- Sport tabs -->
+11 -3
View File
@@ -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}
<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}
<!-- Pagination controls -->
+3 -2
View File
@@ -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 ?? '/';
---
<!doctype html>
<html lang="en" data-theme="dark">
@@ -174,7 +175,7 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
</script>
{editUrl && (
<script define:vars={{ editUrl }}>
<script define:vars={{ editUrl, baseUrl }}>
const modal = document.getElementById('upload-modal');
const drop = document.getElementById('upload-drop');
const input = document.getElementById('upload-input');
@@ -217,7 +218,7 @@ const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
const d = await r.json();
status.textContent = 'Done! Opening activity…';
status.style.color = '#4ade80';
setTimeout(() => { window.location.href = `/activity/${d.id}/`; }, 600);
setTimeout(() => { window.location.href = `${baseUrl}activity/${d.id}/`; }, 600);
} catch (e) {
status.textContent = 'Error: ' + e.message;
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 {
if (s == null) return '—';
s = Math.floor(s);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
+2 -1
View File
@@ -1,8 +1,9 @@
---
import Base from '../../layouts/Base.astro';
import AthleteView from '../../components/AthleteView.svelte';
const base = import.meta.env.BASE_URL;
---
<Base title="Athlete — BincioActivity">
<h1 class="text-2xl font-bold text-white mb-6">Athlete</h1>
<AthleteView client:load />
<AthleteView {base} client:load />
</Base>
+2 -2
View File
@@ -67,10 +67,10 @@ BASE_DETAIL = {
def test_apply_sidecar_title_and_sport():
fm = {"title": "Renamed", "sport": "gravel"}
fm = {"title": "Renamed", "sport": "running"}
result = apply_sidecar(BASE_DETAIL, fm, "")
assert result["title"] == "Renamed"
assert result["sport"] == "gravel"
assert result["sport"] == "running"
# Original must be unchanged
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():
@@ -14,3 +14,22 @@ def test_running_variants():
def test_unknown_falls_back_to_other():
assert normalise_sport("yoga") == "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():
act = _dummy_activity("Morning Ride")
aid = make_activity_id(act)
assert aid.startswith("2024-06-01T")
assert "morning-ride" in aid
assert aid == "2024-06-01T073012Z-morning-ride"
def test_id_without_title():
act = _dummy_activity()
aid = make_activity_id(act)
assert "2024-06-01T" in aid
# No trailing dash
assert not aid.endswith("-")
assert aid == "2024-06-01T073012Z"
def test_slugify():