diff --git a/bincio/extract/dem.py b/bincio/extract/dem.py
index 3f89e61..bd9e381 100644
--- a/bincio/extract/dem.py
+++ b/bincio/extract/dem.py
@@ -351,6 +351,13 @@ def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
altitude_source = detail.get("altitude_source", "unknown")
threshold = 1.0 if altitude_source == "barometric" else 3.0
+ # Strip leading no-fix zeros (same logic as metrics._elevation)
+ if elevations and abs(elevations[0]) < 0.5:
+ for i, e in enumerate(elevations):
+ if abs(e) > threshold:
+ elevations = elevations[i:]
+ break
+
# Pre-smooth to suppress noise, then accumulate with low dead-band
smoothed = _moving_average(elevations, _MA_WINDOW_S)
gain, loss = _hysteresis_gain_loss(smoothed, threshold)
diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py
index cc0072a..b9ae67e 100644
--- a/bincio/extract/metrics.py
+++ b/bincio/extract/metrics.py
@@ -371,9 +371,23 @@ def _elevation(
if len(elevations) < 2:
return None, None
threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0)
+
+ # Some devices (e.g. Apple Watch) record exactly 0.0 for the initial samples
+ # while waiting for barometric/GPS lock, then jump to the real altitude.
+ # Detect this by checking for a leading near-zero run followed by a large
+ # jump: skip those zeros and seed the accumulator from the first real value.
+ # Safety: if the activity genuinely stays near sea level, no value in the
+ # series will exceed `threshold`, so `start` stays 0 — unchanged behaviour.
+ start = 0
+ if abs(elevations[0]) < 0.5:
+ for i, e in enumerate(elevations):
+ if abs(e) > threshold:
+ start = i
+ break
+
gain = loss = 0.0
- committed = elevations[0]
- for e in elevations[1:]:
+ committed = elevations[start]
+ for e in elevations[start + 1:]:
diff = e - committed
if abs(diff) >= threshold:
if diff > 0:
diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index 06e7583..5c680c1 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -1900,6 +1900,58 @@ async def recalculate_elevation_hysteresis_endpoint(
raise HTTPException(422, str(e))
+@app.post("/api/admin/users/{handle}/recompute-elevation")
+async def admin_recompute_elevation(
+ handle: str,
+ bincio_session: Optional[str] = Cookie(default=None),
+) -> JSONResponse:
+ """Recompute elevation gain/loss for all activities of a user from stored timeseries.
+
+ Skips activities with altitude_source == 'dem' (already DEM-corrected).
+ Applies the leading-zero no-fix fix and source-aware hysteresis.
+ Returns patched/skipped/error counts.
+ """
+ _require_admin(bincio_session)
+ user_dir = _get_data_dir() / handle
+ if not user_dir.is_dir():
+ raise HTTPException(404, f"No data directory for '{handle}'")
+
+ from bincio.extract.dem import recalculate_elevation_hysteresis
+ from bincio.render.merge import merge_one
+
+ patched = skipped = errors = 0
+ acts_dir = user_dir / "activities"
+ for json_path in sorted(acts_dir.glob("*.json")):
+ if json_path.name.endswith(".timeseries.json"):
+ continue
+ activity_id = json_path.stem
+ try:
+ detail = json.loads(json_path.read_text(encoding="utf-8"))
+ if detail.get("altitude_source") == "dem":
+ skipped += 1
+ continue
+ ts_path = acts_dir / f"{activity_id}.timeseries.json"
+ if not ts_path.exists():
+ skipped += 1
+ continue
+ ts = json.loads(ts_path.read_text(encoding="utf-8"))
+ ele_arr = ts.get("elevation_m") or []
+ if not any(e for e in ele_arr if e is not None):
+ skipped += 1
+ continue
+ recalculate_elevation_hysteresis(user_dir, activity_id)
+ merge_one(user_dir, activity_id)
+ patched += 1
+ except Exception as exc:
+ log.warning("recompute-elevation[%s/%s]: %s", handle, activity_id, exc)
+ errors += 1
+
+ if patched > 0:
+ _trigger_rebuild(handle)
+
+ return JSONResponse({"ok": True, "patched": patched, "skipped": skipped, "errors": errors})
+
+
@app.delete("/api/activity/{activity_id}", response_model=GenericResponse)
async def delete_activity(
activity_id: str,
diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro
index 72e51c5..1cd14e7 100644
--- a/site/src/pages/admin/index.astro
+++ b/site/src/pages/admin/index.astro
@@ -174,6 +174,11 @@ import Base from '../../layouts/Base.astro';
data-handle="${u.handle}"
title="Re-run merge_all and trigger a site rebuild"
>Rebuild
+