fix low level issues
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user