Add route heat colouring and detailed running analytics

This commit is contained in:
Peter Stockings
2026-10-01 18:43:33 +10:00
parent e37569ad09
commit 24c7fc2c6a
11 changed files with 526 additions and 31 deletions
+165
View File
@@ -0,0 +1,165 @@
"""Recorded metrics and explicitly modelled grade-adjusted pace for running.
Minetti et al., J Appl Physiol 93 (2002), doi:10.1152/japplphysiol.01177.2001.
GAP = elapsed moving time / equivalent flat distance. No heart-rate adjustment.
"""
import bisect
import itertools
import math
import statistics
def numeric(value):
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def running_cost(grade):
return (
155.4 * grade**5 - 30.4 * grade**4 - 43.3 * grade**3 + 46.3 * grade**2 + 19.5 * grade + 3.6
)
def pace_text(seconds):
if not numeric(seconds) or seconds <= 0:
return "—"
seconds = round(seconds)
return f"{seconds // 60}:{seconds % 60:02d}"
def enrich(samples, running=True):
rows = [dict(sample, grade=None, gap=None, pace=None) for sample in samples]
# Break smoothing at missing elevation/distance, distance resets and recording gaps.
chunks, chunk = [], []
for index, row in enumerate(rows):
valid = numeric(row.get("distance")) and numeric(row.get("elevation"))
if chunk:
previous = rows[chunk[-1]]
dt = (
row["time"] - previous["time"]
if numeric(row.get("time")) and numeric(previous.get("time"))
else 1
)
if not valid or row["distance"] < previous["distance"] or not 0 < dt <= 10:
chunks.append(chunk)
chunk = []
if valid:
chunk.append(index)
if chunk:
chunks.append(chunk)
for indices in chunks:
distances = [rows[i]["distance"] for i in indices]
altitude = [
statistics.median(rows[j]["elevation"] for j in indices[max(0, k - 2) : k + 3])
for k in range(len(indices))
]
for k, index in enumerate(indices):
low = max(0, bisect.bisect_left(distances, distances[k] - 30))
high = min(len(indices) - 1, bisect.bisect_left(distances, distances[k] + 30))
distance = distances[high] - distances[low]
if distance >= 20:
grade = (altitude[high] - altitude[low]) / distance
if abs(grade) <= 0.30:
rows[index]["grade"] = grade * 100
for row in rows:
speed = row.get("speed")
if numeric(speed) and 0.5 < speed < 15:
row["pace"] = 1000 / speed
if running and row["grade"] is not None:
row["gap"] = row["pace"] * running_cost(0) / running_cost(row["grade"] / 100)
return rows
def analyse(activity):
rows = enrich(activity.samples or [], activity.sport == "running")
weighted = {
key: [0.0, 0.0]
for key in (
"heart_rate",
"cadence",
"power",
"stride_length",
"vertical_oscillation",
"ground_contact",
)
}
terrains = {
key: {"label": label, "distance": 0.0, "time": 0.0, "hr_sum": 0.0, "hr_time": 0.0}
for key, label in (
("downhill", "Downhill < −2%"),
("flat", "Flat −2% to 2%"),
("uphill", "Uphill > 2%"),
)
}
equivalent, gap_time, covered = 0.0, 0.0, 0.0
laps = [dict(lap, gap=None, equivalent=0.0, gap_time=0.0) for lap in (activity.laps or [])]
ends, cumulative = [], 0.0
for lap in laps:
cumulative += lap.get("distance", 0)
ends.append(cumulative)
for previous, row in itertools.pairwise(rows):
if not numeric(row.get("time")) or not numeric(previous.get("time")):
continue
dt = row["time"] - previous["time"]
if not 0 < dt <= 10:
continue
for key, values in weighted.items():
value = row.get(key)
if numeric(value) and value > 0:
values[0] += value * dt
values[1] += dt
if not numeric(row.get("distance")) or not numeric(previous.get("distance")):
continue
ds = row["distance"] - previous["distance"]
if not 0 < ds <= dt * 15 or row["pace"] is None:
continue
grade = row["grade"]
if grade is not None:
terrain = terrains["uphill" if grade > 2 else "downhill" if grade < -2 else "flat"]
terrain["distance"] += ds
terrain["time"] += dt
if numeric(row.get("heart_rate")) and row["heart_rate"] > 0:
terrain["hr_sum"] += row["heart_rate"] * dt
terrain["hr_time"] += dt
if row["gap"] is not None:
flat_distance = ds * running_cost(grade / 100) / running_cost(0)
equivalent += flat_distance
gap_time += dt
covered += ds
# Split a sample crossing a lap boundary proportionally to distance.
start = previous["distance"]
while start < row["distance"]:
lap_index = bisect.bisect_right(ends, start)
if lap_index >= len(laps):
break
end = min(ends[lap_index], row["distance"])
fraction = (end - start) / ds
laps[lap_index]["equivalent"] += flat_distance * fraction
laps[lap_index]["gap_time"] += dt * fraction
start = end
for lap in laps:
lap["gap"] = 1000 * lap["gap_time"] / lap["equivalent"] if lap["equivalent"] > 0 else None
for terrain in terrains.values():
terrain["heart_rate"] = (
terrain["hr_sum"] / terrain["hr_time"] if terrain["hr_time"] else None
)
terrain["pace"] = (
terrain["time"] * 1000 / terrain["distance"] if terrain["distance"] else None
)
summary = {
key: total / duration if duration else None for key, (total, duration) in weighted.items()
}
summary.update(
{
"gap": 1000 * gap_time / equivalent if equivalent else None,
"gap_coverage": min(100, covered / activity.distance_m * 100)
if activity.distance_m
else 0,
"max_hr": max(
(r["heart_rate"] for r in rows if numeric(r.get("heart_rate"))), default=None
),
"max_power": max((r["power"] for r in rows if numeric(r.get("power"))), default=None),
"samples": len(rows),
}
)
return {"samples": rows, "summary": summary, "terrain": list(terrains.values()), "laps": laps}
+1
View File
@@ -38,6 +38,7 @@ class Activity(db.Model):
duration_s = db.Column(db.Float, nullable=False, default=0)
elevation_m = db.Column(db.Float, nullable=False, default=0)
avg_hr = db.Column(db.Float)
metrics = db.Column(db.JSON, nullable=False, default=dict, server_default="{}")
visibility = db.Column(db.String(12), nullable=False, default="private")
samples = db.Column(db.JSON, nullable=False, default=list)
laps = db.Column(db.JSON, nullable=False, default=list)
+5 -1
View File
@@ -24,6 +24,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
from garmin_sync.service import SyncError, atomic_write
from .analytics import analyse, pace_text
from .extensions import db, limiter
from .models import Activity, Friendship, User
from .services import activity_data, import_cache, import_upload
@@ -295,7 +296,10 @@ def profile(username):
@web.get("/activities/<int:activity_id>")
def activity(activity_id):
row, owner = get_activity(activity_id)
return render_template("activity.html", activity=row, owner_view=owner)
analysis = analyse(row)
return render_template(
"activity.html", activity=row, owner_view=owner, analysis=analysis, pace_text=pace_text
)
@web.get("/activities/<int:activity_id>/data")
+77 -8
View File
@@ -23,9 +23,12 @@ def instant(value):
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
def activity_from_fit(owner_id, source_key, documents, metadata=None):
def activity_from_fit(owner_id, source_key, documents, metadata=None, refresh=False):
metadata = metadata or {}
if db.session.scalar(db.select(Activity).filter_by(owner_id=owner_id, source_key=source_key)):
existing = db.session.scalar(
db.select(Activity).filter_by(owner_id=owner_id, source_key=source_key)
)
if existing and not refresh:
return None
sessions = [s for doc in documents for s in doc.get("session", [])]
if not sessions:
@@ -52,6 +55,26 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
"heart_rate": number(record.get("heart_rate"), None),
"speed": number(record.get("enhanced_speed", record.get("speed")), None),
"elevation": number(record.get("enhanced_altitude", record.get("altitude")), None),
"time": instant(record["timestamp"]).timestamp()
if record.get("timestamp")
else None,
"cadence": (
(number(record.get("cadence")) + number(record.get("fractional_cadence"))) * 2
if sport == "running" and record.get("cadence") is not None
else None
),
"power": number(record.get("power"), None),
"stride_length": (
number(record.get("step_length")) / 1000
if record.get("step_length") is not None
else None
),
"vertical_oscillation": (
number(record.get("vertical_oscillation")) / 10
if record.get("vertical_oscillation") is not None
else None
),
"ground_contact": number(record.get("stance_time"), None),
}
)
laps = []
@@ -67,6 +90,27 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
"pace": duration * 1000 / distance if distance > 0 else None,
}
)
metrics = {
key: number(first.get(key), None)
for key in (
"total_elapsed_time",
"total_moving_time",
"max_heart_rate",
"avg_power",
"max_power",
"total_calories",
"total_descent",
"total_training_effect",
"total_anaerobic_training_effect",
"avg_vertical_oscillation",
"avg_stance_time",
"avg_step_length",
"avg_running_cadence",
)
}
if existing:
existing.samples, existing.laps, existing.metrics = samples, laps, metrics
return existing
row = Activity(
owner_id=owner_id,
source_key=source_key,
@@ -77,6 +121,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
duration_s=sum(number(s.get("total_timer_time")) for s in sessions),
elevation_m=sum(number(s.get("total_ascent")) for s in sessions),
avg_hr=number(metadata.get("averageHR", first.get("avg_heart_rate")), None),
metrics=metrics,
samples=samples,
laps=laps,
)
@@ -84,7 +129,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None):
return row
def import_cache(user, data_root):
def import_cache(user, data_root, refresh=False):
if not user.local_account_key:
return 0
directory = Path(data_root) / user.local_account_key
@@ -95,7 +140,7 @@ def import_cache(user, data_root):
metadata = json.loads((manifest.parent / "activity.json").read_text())
docs = [json.loads(p.read_text()) for p in sorted(manifest.parent.glob("activity-*.json"))]
source_key = f"garmin:{user.local_account_key}:{manifest.parent.name}"
if activity_from_fit(user.id, source_key, docs, metadata):
if activity_from_fit(user.id, source_key, docs, metadata, refresh=refresh):
count += 1
return count
@@ -112,19 +157,43 @@ def import_upload(user, payload):
def activity_data(activity, owner_view=False):
"""Call only after access checks. Owners and accepted friends see the full route."""
from .analytics import analyse
analysis = analyse(activity)
segments, segment = [], []
for sample in activity.samples:
route_segments, route = [], []
for sample in analysis["samples"]:
point = sample.get("point")
if point:
segment.append(point)
route.append(sample)
elif segment:
segments.append(segment)
segment = []
route_segments.append(route)
route = []
if segment:
segments.append(segment)
route_segments.append(route)
step = max(1, math.ceil(len(activity.samples) / 750))
chart = [
{k: sample.get(k) for k in ("distance", "heart_rate", "speed", "elevation")}
for sample in activity.samples[::step]
{
k: sample.get(k)
for k in (
"distance",
"heart_rate",
"speed",
"elevation",
"grade",
"gap",
"pace",
"cadence",
"power",
"stride_length",
"vertical_oscillation",
"ground_contact",
)
}
for sample in analysis["samples"][::step]
]
return {"segments": segments, "chart": chart}
return {"segments": segments, "route_segments": route_segments, "chart": chart}
+75 -16
View File
@@ -1,32 +1,91 @@
"use strict";
const valid = v => typeof v === "number" && Number.isFinite(v);
const paceLabel = v => {const s = Math.round(v); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;};
async function showActivity() {
const container = document.getElementById("route-map");
const status = document.getElementById("map-status");
const container = document.getElementById("route-map"), status = document.getElementById("map-status");
try {
const response = await fetch(container.dataset.url);
if (!response.ok) throw new Error("Activity unavailable");
const data = await response.json();
let locate = () => {};
if (data.segments.length && window.L) {
container.replaceChildren();
const map = L.map(container, {scrollWheelZoom: false});
const map = L.map(container, {scrollWheelZoom: false, preferCanvas: true});
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {maxZoom: 19, attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}).addTo(map);
const route = L.polyline(data.segments, {color: "#345e40", weight: 4, opacity: .95}).addTo(map);
map.fitBounds(route.getBounds(), {padding: [35, 35], maxZoom: 16});
status.textContent = "Scroll to explore your charts. Drag the map to explore the route.";
const outline = L.polyline(data.segments, {color: "#263d32", weight: 8, opacity: .35}).addTo(map);
const fit = () => map.fitBounds(outline.getBounds(), {padding: [40, 40], maxZoom: 16});
fit();
document.getElementById("fit-route").onclick = fit;
const colours = ["#356ac3", "#319dc1", "#37b5a0", "#94bf6e", "#e2c954", "#eea044", "#de6744", "#b83d50"];
const heat = L.layerGroup().addTo(map), arrows = L.layerGroup().addTo(map);
const samples = data.route_segments.flat();
const format = (v, key) => !valid(v) ? "Unavailable" : key === "speed" ? `${(v * 3.6).toFixed(1)} km/h` : key === "grade" ? `${v.toFixed(1)}%` : `${Math.round(v)} bpm`;
const colourRoute = () => {
heat.clearLayers();
const key = document.getElementById("map-metric").value;
const values = samples.map(s => s[key]).filter(valid).sort((a,b) => a-b);
const low = values[Math.floor((values.length-1)*.1)], high = values[Math.floor((values.length-1)*.9)];
document.getElementById("legend-low").textContent = format(low, key);
document.getElementById("legend-high").textContent = format(high, key);
document.getElementById("legend-ramp").style.background = `linear-gradient(to right, ${colours.join(",")})`;
for (const segment of data.route_segments) {
for (let i = 1; i < segment.length; i++) {
const a = segment[i-1], b = segment[i], value = b[key];
const ratio = high > low ? (value-low)/(high-low) : .5;
const colour = valid(value) ? colours[Math.max(0,Math.min(7,Math.floor(ratio*8)))] : "#969c99";
L.polyline([a.point,b.point], {color: colour, weight: 5, opacity: 1}).bindTooltip(format(value,key)).addTo(heat);
}
}
};
colourRoute();
document.getElementById("map-metric").onchange = colourRoute;
const spacing = Math.max(1, Math.floor(samples.length / 16));
let passed = 0;
for (const segment of data.route_segments) {
for (let i = 1; i < segment.length; i++, passed++) {
if (passed % spacing) continue;
const a = map.project(segment[i-1].point, 14), b = map.project(segment[i].point, 14);
if (a.distanceTo(b) < .01) continue;
const angle = Math.atan2(b.y-a.y,b.x-a.x)*180/Math.PI;
L.marker(segment[i].point, {interactive:false, icon:L.divIcon({className:"route-arrow", html:`<span style="transform:rotate(${angle}deg)">➤</span>`, iconSize:[20,20], iconAnchor:[10,10]})}).addTo(arrows);
}
}
document.getElementById("show-directions").onchange = e => e.target.checked ? arrows.addTo(map) : map.removeLayer(arrows);
for (const [row, label, name] of [[samples[0],"S","Start"],[samples.at(-1),"F","Finish"]]) {
L.marker(row.point, {icon:L.divIcon({className:`route-end route-${label}`, html:label, iconSize:[25,25], iconAnchor:label === "S" ? [27,13] : [-2,13]})}).bindTooltip(name).addTo(map);
}
const cursor = L.circleMarker(samples[0].point,{radius:7,color:"#fff",weight:3,fillColor:"#263d32",fillOpacity:1,interactive:false});
locate = distance => {
let closest = null, delta = Infinity;
for (const sample of samples) if (valid(sample.distance) && Math.abs(sample.distance-distance)<delta) {closest=sample;delta=Math.abs(sample.distance-distance);}
if (closest) cursor.setLatLng(closest.point).addTo(map);
};
status.textContent = "Follow the arrows from S to F. Hover over the route for measurements, or over a chart to place a marker.";
} else {
container.querySelector("p").textContent = "No route to display. This activity may have been recorded indoors.";
status.textContent = "Activity statistics are available below.";
container.textContent = "No GPS route recorded for this activity.";
status.textContent = "Recorded measurements are available below.";
document.getElementById("fit-route").disabled = true;
}
if (!window.Chart) return;
const config = (id, key, color, formatter, reverse = false) => {
const points = data.chart.filter(s => s.distance !== null).map(s => ({x: s.distance / 1000, y: formatter(s[key])}));
new Chart(document.getElementById(id), {type: "line", data: {datasets: [{data: points, borderColor: color, backgroundColor: `${color}12`, fill: true, borderWidth: 1.7, pointRadius: 0, pointHitRadius: 12, tension: .2, spanGaps: false}]}, options: {responsive: true, maintainAspectRatio: false, parsing: false, plugins: {legend: {display: false}}, scales: {x: {type: "linear", grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b", callback: v => `${v} km`}}, y: {reverse, grid: {color: "#f0f2eb"}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b"}}}}});
const line = (label,key,colour,unit,axis="y") => ({label,key,borderColor:colour,unit,yAxisID:axis});
const chart = (id, series, reverse=false) => {
const canvas = document.getElementById(id);
if (!series.some(s=>data.chart.some(row=>valid(row[s.key])))) {
const message=document.createElement("p"); message.className="chart-empty";message.textContent="Not recorded by your watch for this activity.";canvas.replaceWith(message);return;
}
const datasets = series.map(s=>({...s,data:data.chart.filter(r=>valid(r.distance)).map(r=>({x:r.distance/1000,y:valid(r[s.key])?r[s.key]:null})),borderWidth:1.6,pointRadius:0,pointHitRadius:8,tension:.15,spanGaps:false}));
const axis = right => ({position:right?"right":"left",reverse:reverse&&!right,grid:{color:"#edf0e9",drawOnChartArea:!right},border:{display:false},ticks:{color:"#7d847b",font:{size:10},callback:v=>reverse&&!right?paceLabel(v):Number(v.toFixed(2))}});
new Chart(canvas,{type:"line",data:{datasets},options:{responsive:true,maintainAspectRatio:false,animation:false,parsing:false,interaction:{mode:"index",intersect:false},onHover:(_event,items,instance)=>{if(items.length)locate(instance.data.datasets[items[0].datasetIndex].data[items[0].index].x*1000);},plugins:{legend:{position:"bottom",labels:{usePointStyle:true,boxWidth:7,font:{size:11}}},tooltip:{callbacks:{title:items=>`${items[0].parsed.x.toFixed(2)} km`,label:item=>`${item.dataset.label}: ${item.dataset.unit==="/km"?paceLabel(item.parsed.y):item.parsed.y.toFixed(1)} ${item.dataset.unit}`}}},scales:{x:{type:"linear",grid:{display:false},border:{display:false},ticks:{maxTicksLimit:7,color:"#7d847b",font:{size:10},callback:v=>`${v} km`}},y:axis(false),...(series.some(s=>s.yAxisID==="y2")?{y2:axis(true)}:{})}}});
};
config("pace-chart", "speed", "#668b47", v => v > .5 ? Math.min(30, 1000 / v / 60) : null, true);
config("hr-chart", "heart_rate", "#bd8063", v => v > 0 ? v : null);
} catch {
container.querySelector("p").textContent = "We couldn’t load this activity. Please refresh and try again.";
status.textContent = "The activity may no longer be available.";
chart("pace-chart",[line("Pace","pace","#38795b","/km"),line("GAP estimate","gap","#bc9050","/km")],true);
chart("hr-chart",[line("Heart rate","heart_rate","#bd685b","bpm")]);
chart("elevation-chart",[line("Elevation","elevation","#668b47","m"),line("Gradient","grade","#ba965a","%","y2")]);
chart("cadence-chart",[line("Cadence","cadence","#5a83ab","spm"),line("Step length","stride_length","#9b83b3","m","y2")]);
chart("power-chart",[line("Power","power","#b48a38","W")]);
chart("dynamics-chart",[line("Vertical oscillation","vertical_oscillation","#699f96","cm"),line("Ground contact","ground_contact","#a57b9c","ms","y2")]);
} catch (error) {
status.textContent = "We couldn’t finish loading this activity. Please refresh and try again.";
console.error("Activity display failed", error);
}
}
showActivity();
+3
View File
@@ -5,3 +5,6 @@
@media(max-width:1180px){.sidebar{width:205px;padding:30px 17px}main{margin-left:205px}.page{padding:30px 25px 20px}.topbar{padding:0 25px}.stat-card{padding:18px 16px}.stat-value{font-size:32px}.time-value{font-size:28px}.stat-label{font-size:8px}.auth-layout{grid-template-columns:1fr 390px;gap:20px}.auth-intro h1{font-size:45px}.section-head{padding:20px}.subtle-label{white-space:normal}.activity-name strong{max-width:215px}}
@media(max-width:950px){.stats-grid{grid-template-columns:repeat(2,1fr)}.overview-grid{grid-template-columns:1fr}.milestone-card{min-height:210px}.mountain{width:45%;left:50%;top:20px}.milestone-card>.eyebrow{position:static;margin-bottom:15px}.auth-layout{grid-template-columns:1fr;max-width:480px;margin:auto;min-height:0}.auth-intro{padding:0}.auth-intro h1{font-size:39px}.auth-intro p,.auth-art{display:none}.detail-bottom,.settings-grid{grid-template-columns:1fr}.connection-card{grid-template-columns:1fr}.detail-charts{grid-template-columns:1fr}.page-heading h1{font-size:32px}.page-heading{align-items:flex-start}.activity-table td{padding:15px 12px}}
@media(max-width:680px){.sidebar{position:relative;width:100%;height:auto;padding:17px 19px;border-right:0;border-bottom:1px solid var(--line);display:block}.brand{font-size:27px}.brand-mark{width:30px;height:30px;font-size:26px}.sidebar-label,.sidebar-note,.sidebar-bottom{display:none}.sidebar nav{display:flex;gap:5px;margin-top:12px;overflow-x:auto}.nav-item{white-space:nowrap;font-size:10px;padding:7px 10px;margin:0}.nav-item span{display:none}main{margin:0}.topbar{height:49px;padding:0 20px}.local-badge{display:none}.page{padding:25px 18px}.page-heading{flex-direction:column;gap:16px}.page-heading h1{font-size:32px}.page-heading p{font-size:12px}.stats-grid{gap:11px}.stat-card{padding:17px}.stat-value{font-size:32px}.time-value{font-size:27px}.stat-value small{font-size:11px}.stat-label{font-size:8px}.stat-card p{font-size:9px}.section-head{padding:19px;align-items:flex-start}.section-head h2{font-size:18px}.section-head .subtle-label{max-width:110px;text-align:right}.chart-wrap{padding:10px 15px 20px;height:200px}.auth-card{padding:24px}.auth-intro h1{font-size:38px}.auth-intro{padding-bottom:10px}.footer{font-size:8px;gap:10px}.footer span:last-child{display:none}#route-map{height:290px}.publishing-panel,.settings-card{padding:22px}.splits-table td,.splits-table th{padding:10px;font-size:10px}}
.activity-nav{display:flex;gap:22px;overflow-x:auto;margin:-8px 0 25px;border-bottom:1px solid var(--line);padding-bottom:13px;font-size:12px;white-space:nowrap}.activity-nav a:hover{color:#67934c}.run-map .section-head{flex-wrap:wrap}.map-controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap;font-size:11px}.map-controls label{display:flex;gap:8px;align-items:center}.map-controls select{padding:8px;border:1px solid var(--line);border-radius:6px;color:var(--ink);background:#f6f7f2}.directions-toggle input{accent-color:var(--green)}.run-map #route-map{height:440px;background:#ecf0e7}.route-legend{display:flex;align-items:center;gap:10px;padding:17px 25px 0;font-size:11px;flex-wrap:wrap}.legend-ramp{height:8px;width:180px;border-radius:8px}.legend-note{margin-left:auto;color:var(--muted);font-size:10px}.route-arrow span{display:block;color:white;font-size:18px;line-height:20px;text-shadow:0 0 3px #263d32,0 1px 2px #263d32}.route-end{background:#263d32;color:white;border:2px solid white;border-radius:50%;text-align:center;font-weight:700;line-height:21px;box-shadow:0 1px 6px #263d3266}.route-F{background:#ac654f}.performance-section{margin-top:34px}.section-title{margin:0 0 20px}.section-title p{margin-top:7px;font-size:12px;color:var(--muted)}.metrics-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;background:var(--line);border:1px solid var(--line);border-radius:14px;overflow:hidden;margin-bottom:22px}.metric-tile{padding:20px 23px;background:#fff}.metric-tile>span{font-size:9px;letter-spacing:1px;color:var(--muted)}.metric-tile strong{display:block;font-weight:500;font-size:29px;letter-spacing:-1px;font-variant-numeric:tabular-nums;margin:5px 0}.metric-tile small,.terrain-grid small{font-size:12px;font-weight:400;margin-left:7px;letter-spacing:0;color:var(--muted)}.metric-tile p{font-size:10px;color:var(--muted)}.analytics-charts .chart-wrap{height:275px}.analytics-charts .section-head{align-items:flex-start}.analytics-charts h2{font-size:17px}.chart-empty{padding:55px 15px;text-align:center;color:var(--muted);font-size:12px}.terrain-panel{margin:25px 0}.section-description{padding:0 25px;color:var(--muted);font-size:12px;max-width:850px}.terrain-grid{display:grid;grid-template-columns:repeat(3,1fr);padding:22px 25px;gap:20px}.terrain-grid article{padding:17px;background:#f6f8f0;border-radius:10px}.terrain-grid article>span{font-size:12px}.terrain-grid strong{display:block;font-size:30px;font-weight:500;margin:7px 0}.terrain-grid article>div{font-size:11px;color:var(--muted)}.terrain-grid article>div span{margin:0 7px}.methodology{margin-top:25px;padding:22px 25px;font-size:12px;color:var(--muted)}.methodology summary{cursor:pointer;color:var(--ink);font-weight:600}.methodology summary span{float:right;font-weight:400;font-size:11px}.methodology p{margin-top:16px;max-width:1000px;line-height:1.8}.methodology a{text-decoration:underline}section[id],details[id]{scroll-margin-top:20px}
@media(max-width:680px){.metrics-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-tile{padding:15px}.metric-tile strong{font-size:25px}.terrain-grid{grid-template-columns:1fr;gap:10px;padding:18px}.terrain-grid article{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.terrain-grid strong{font-size:24px}.run-map #route-map{height:340px}.legend-note{margin-left:0;width:100%}.route-legend{padding:15px 18px 0}.map-controls{gap:10px}.methodology summary span{display:none}.analytics-charts .chart-wrap{height:250px}}
+40 -6
View File
@@ -2,13 +2,47 @@
{% block title %}{{ activity.title }} · Stride{% endblock %}
{% block breadcrumb %}ACTIVITY / {{ activity.sport|upper }}{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
{% macro metric(label, value, unit='', note='') %}
<article class="metric-tile"><span>{{ label }}</span><strong>{{ value }}<small>{{ unit }}</small></strong>{% if note %}<p>{{ note }}</p>{% endif %}</article>
{% endmacro %}
{% block content %}
<a class="back-link" href="{{ url_for('web.dashboard') if owner_view else url_for('web.profile',username=activity.owner.username) }}">← {{ 'Your activities' if owner_view else activity.owner.display_name }}</a>
<div class="page-heading"><div><span class="eyebrow">{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}</span><h1>{{ activity.title }}</h1><p>By <a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a> <span class="dot-separator">·</span> {{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}</p></div><span class="pill {{ activity.visibility }}">{{ activity.visibility|title }}</span></div>
<div class="stats-grid"><article class="stat-card featured"><div class="stat-label">DISTANCE</div><div class="stat-value">{{ '%.2f'|format(activity.distance_km) }}<small>km</small></div></article><article class="stat-card"><div class="stat-label">TIMER TIME</div><div class="stat-value time-value">{{ activity.duration_s|duration }}</div></article><article class="stat-card"><div class="stat-label">AVERAGE PACE</div><div class="stat-value">{{ activity.pace }}<small>/km</small></div></article><article class="stat-card"><div class="stat-label">AVERAGE HEART RATE</div><div class="stat-value">{{ activity.avg_hr|int if activity.avg_hr else '—' }}<small>bpm</small></div></article></div>
<section class="panel map-panel"><div class="section-head"><h2>The route.</h2><span class="subtle-label">{{ 'Full recorded route' }}</span></div><div id="route-map" data-url="{{ url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading activity…</p></div><p id="map-status" class="map-status"></p></section>
<div class="detail-charts"><section class="panel"><div class="section-head"><h2>Pace</h2><span class="subtle-label">min/km</span></div><div class="chart-wrap"><canvas id="pace-chart" role="img" aria-label="Pace over distance"></canvas></div></section><section class="panel"><div class="section-head"><h2>Heart rate</h2><span class="subtle-label">bpm</span></div><div class="chart-wrap"><canvas id="hr-chart" role="img" aria-label="Heart rate over distance"></canvas></div></section></div>
<div class="detail-bottom"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ activity.laps|length }} recorded laps</span></div><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE /KM</th><th>HR</th></tr></thead><tbody>{% for lap in activity.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ lap.pace|duration if lap.pace else '—' }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="5">No laps recorded.</td></tr>{% endfor %}</tbody></table></section>
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends can see the full route, date, statistics and charts of shared activities. Choose Only me to keep this activity private.</p><button class="button primary">Save changes ↗</button></form></section>{% endif %}</div>
<div class="page-heading"><div><span class="eyebrow">{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}</span><h1>{{ activity.title }}</h1><p>By <a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a><span class="dot-separator">·</span>{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}<span class="dot-separator">·</span>{{ '{:,}'.format(analysis.summary.samples) }} recorded samples</p></div><span class="pill {{ activity.visibility }}">{{ activity.visibility|title }}</span></div>
<nav class="activity-nav" aria-label="Activity sections"><a href="#route-section">Route & speed</a><a href="#performance">Performance</a><a href="#terrain">Terrain & effort</a><a href="#splits">Laps & splits</a><a href="#method">About the numbers</a></nav>
<div class="stats-grid">
<article class="stat-card featured"><div class="stat-label">DISTANCE</div><div class="stat-value">{{ '%.2f'|format(activity.distance_km) }}<small>km</small></div><p>Recorded by your watch</p></article>
<article class="stat-card"><div class="stat-label">TIMER TIME</div><div class="stat-value time-value">{{ activity.duration_s|duration }}</div><p>Elapsed {{ activity.metrics.get('total_elapsed_time')|duration if activity.metrics.get('total_elapsed_time') else '—' }}</p></article>
<article class="stat-card"><div class="stat-label">AVERAGE PACE</div><div class="stat-value">{{ activity.pace }}<small>/km</small></div><p>GAP estimate <strong>{{ pace_text(analysis.summary.gap) }}/km</strong></p></article>
<article class="stat-card"><div class="stat-label">AVERAGE HEART RATE</div><div class="stat-value">{{ activity.avg_hr|int if activity.avg_hr else '—' }}<small>bpm</small></div><p>Peak {{ analysis.summary.max_hr|int if analysis.summary.max_hr else '—' }} bpm</p></article>
</div>
<section class="panel map-panel run-map" id="route-section">
<div class="section-head"><div><span class="eyebrow">FOLLOW YOUR FOOTSTEPS</span><h2>Your run, in colour.</h2></div><div class="map-controls"><label>Colour by <select id="map-metric"><option value="speed">Speed</option><option value="heart_rate">Heart rate</option><option value="grade">Gradient</option></select></label><label class="directions-toggle"><input type="checkbox" id="show-directions" checked> Direction arrows</label><button type="button" class="button secondary" id="fit-route">Fit route</button></div></div>
<div id="route-map" data-url="{{ url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading your route…</p></div>
<div class="route-legend"><span id="legend-low">Slower</span><div class="legend-ramp" id="legend-ramp"></div><span id="legend-high">Faster</span><span class="legend-note">S Start · F Finish · Grey = no measurement</span></div>
<p id="map-status" class="map-status" aria-live="polite"></p>
</section>
<section id="performance" class="performance-section">
<div class="section-title"><span class="eyebrow">A CLOSER LOOK</span><h2>The numbers behind the run.</h2><p>Hover over a chart to locate that part of the route.</p></div>
<div class="metrics-strip">
{{ metric('GRADE-ADJUSTED PACE', pace_text(analysis.summary.gap), '/km', 'Model estimate · %.0f%% distance coverage'|format(analysis.summary.gap_coverage)) }}
{{ metric('ELEVATION GAIN', activity.elevation_m|int, 'm', 'Descent %s m'|format(activity.metrics.get('total_descent')|int if activity.metrics.get('total_descent') is not none else '—')) }}
{{ metric('AVERAGE CADENCE', '%.0f'|format(analysis.summary.cadence) if analysis.summary.cadence else '—', 'spm', 'Both feet · sampled average') }}
{{ metric('AVERAGE POWER', activity.metrics.get('avg_power')|int if activity.metrics.get('avg_power') is not none else '—', 'W', 'Recorded running power') }}
{{ metric('ENERGY', activity.metrics.get('total_calories')|int if activity.metrics.get('total_calories') is not none else '—', 'kcal', 'Watch estimate') }}
{{ metric('TRAINING EFFECT', activity.metrics.get('total_training_effect') if activity.metrics.get('total_training_effect') is not none else '—', '', 'Aerobic · watch estimate') }}
</div>
<div class="detail-charts analytics-charts">
<section class="panel"><div class="section-head"><h2>Pace & grade-adjusted pace</h2><span class="subtle-label">min/km · lower is faster</span></div><div class="chart-wrap"><canvas id="pace-chart" role="img" aria-label="Pace and estimated grade adjusted pace over distance"></canvas></div></section>
<section class="panel"><div class="section-head"><h2>Heart rate</h2><span class="subtle-label">Recorded · bpm</span></div><div class="chart-wrap"><canvas id="hr-chart" role="img" aria-label="Recorded heart rate over distance"></canvas></div></section>
<section class="panel"><div class="section-head"><h2>Elevation & gradient</h2><span class="subtle-label">metres / % grade</span></div><div class="chart-wrap"><canvas id="elevation-chart" role="img" aria-label="Elevation and smoothed gradient"></canvas></div></section>
<section class="panel"><div class="section-head"><h2>Cadence & step length</h2><span class="subtle-label">steps/min / metres</span></div><div class="chart-wrap"><canvas id="cadence-chart" role="img" aria-label="Cadence and step length"></canvas></div></section>
<section class="panel"><div class="section-head"><h2>Running power</h2><span class="subtle-label">Recorded · watts</span></div><div class="chart-wrap"><canvas id="power-chart" role="img" aria-label="Running power"></canvas></div></section>
<section class="panel"><div class="section-head"><h2>Running dynamics</h2><span class="subtle-label">vertical movement / contact time</span></div><div class="chart-wrap"><canvas id="dynamics-chart" role="img" aria-label="Vertical oscillation and ground contact time"></canvas></div></section>
</div>
</section>
<section class="panel terrain-panel" id="terrain"><div class="section-head"><div><span class="eyebrow">HILLS CHANGE THE EFFORT</span><h2>Heart rate by terrain</h2></div><span class="subtle-label">Measured HR · not adjusted</span></div><p class="section-description">See how your recorded heart rate and pace vary uphill, downhill and on the flat. Heart rate responds with a delay, so this is context rather than a correction.</p><div class="terrain-grid">{% for t in analysis.terrain %}<article><span>{{ t.label }}</span><strong>{{ '%.0f'|format(t.heart_rate) if t.heart_rate else '—' }}<small>bpm</small></strong><div>{{ '%.2f'|format(t.distance/1000) }} km <span>·</span> {{ pace_text(t.pace) }}/km</div></article>{% endfor %}</div></section>
<div class="detail-bottom" id="splits"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ activity.laps|length }} recorded laps</span></div><div class="activity-table-wrap"><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE</th><th>GAP EST.</th><th>HR</th></tr></thead><tbody>{% for lap in analysis.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ pace_text(lap.pace) }}</td><td>{{ pace_text(lap.gap) }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="6">No laps recorded.</td></tr>{% endfor %}</tbody></table></div></section>
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends see the full route and measurements of shared activities. Choose Only me to keep this activity private.</p><button class="button primary">Save changes ↗</button></form></section>{% endif %}</div>
<details class="panel methodology" id="method"><summary>About these numbers <span>Recorded data vs. estimates +</span></summary><div><p><strong>GAP is an estimate, not a Garmin or Strava value.</strong> We use the <a href="https://doi.org/10.1152/japplphysiol.01177.2001" target="_blank" rel="noopener noreferrer">Minetti running energy-cost model</a> to estimate an equivalent flat pace from the recorded slope. Elevation uses a five-sample median and a roughly 60 m distance window. Grades outside ±30%, missing data, and recording gaps over 10 seconds are excluded. GAP is only calculated for running.</p><p>Average GAP divides time by equivalent flat distance over eligible moving samples. Coverage indicates how much recorded distance contributed; lap GAP uses the same method. Stops and missing data can make its sample coverage differ from timer pace. Wind, surface, heat, fatigue and individual running economy are not modelled.</p><p><strong>Heart rate stays measured.</strong> A universal grade correction would imply precision we don't have. Terrain summaries show time-weighted recorded HR, without trying to remove the effect of hills. Cadence is converted from running cycles/min to steps/min. Missing measurements are shown as unavailable, never zero-filled.</p><p>Map colours use this activity’s 10th–90th percentile range, with extremes saturated. Direction arrows follow recording order; they are not navigation instructions. Chart traces are sampled for display; computations use all available records.</p></div></details>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='activity.js') }}"></script>{% endblock %}
+21
View File
@@ -39,3 +39,24 @@ Updated: 2026-10-01
5. Deletion/export controls, pagination at larger activity counts and moderation.
Current local development choices are not a claim of production readiness.
## Rich activity analysis — 1 October 2026
Activity pages now include speed/heart-rate/gradient route colouring with a relative
10th–90th percentile legend, travel arrows, start/finish markers, and chart-hover
route location. Six charts cover pace/GAP, HR, elevation/grade, cadence/step length,
power, and vertical oscillation/ground contact. Recorded energy, aerobic training
effect and ascent/descent accompany terrain summaries and lap GAP.
GAP uses the Minetti (2002) running energy-cost polynomial
(https://doi.org/10.1152/japplphysiol.01177.2001), five-sample median elevation,
and approximately 60 m grade windows. It integrates time over equivalent flat
distance; missing data, gaps over 10 seconds and slopes outside ±30% are excluded.
It is explicitly a model estimate, not Garmin/Strava GAP. No invented HR correction:
terrain summaries show measured, time-weighted HR and explain its response lag.
Migration bdbac89195a7 adds recorded session metrics. All 18 local cached activities
were refreshed without changing their titles or sharing choices. Future FIT imports
retain timestamps, cadence, power and running dynamics. Refreshing existing cached
imports is available through import_cache(..., refresh=True).
@@ -0,0 +1,32 @@
"""Preserve recorded running metrics
Revision ID: bdbac89195a7
Revises: 342a45f4132a
Create Date: 2026-10-01 18:30:09.945360
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bdbac89195a7'
down_revision = '342a45f4132a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('activity', schema=None) as batch_op:
batch_op.add_column(sa.Column('metrics', sa.JSON(), server_default='{}', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('activity', schema=None) as batch_op:
batch_op.drop_column('metrics')
# ### end Alembic commands ###
+74
View File
@@ -0,0 +1,74 @@
from types import SimpleNamespace
import pytest
from app.analytics import analyse, enrich, running_cost
def run(grade=0):
return SimpleNamespace(
sport="running",
distance_m=600,
samples=[
{
"distance": i * 3,
"time": i,
"elevation": 100 + i * 3 * grade,
"speed": 3,
"heart_rate": 150,
"cadence": 170,
}
for i in range(201)
],
laps=[{"distance": 301}, {"distance": 299}],
)
def test_flat_gap_and_laps_use_distance_weighted_time():
result = analyse(run())
assert result["summary"]["gap"] == pytest.approx(1000 / 3)
assert result["summary"]["gap_coverage"] == 100
assert [lap["gap"] for lap in result["laps"]] == pytest.approx([1000 / 3] * 2)
assert sum(lap["gap_time"] for lap in result["laps"]) == pytest.approx(200)
assert result["terrain"][1]["heart_rate"] == 150
@pytest.mark.parametrize("grade", [-0.1, 0.1])
def test_slope_adjusts_pace_using_cost_ratio(grade):
activity = run(grade)
row = enrich(activity.samples)[100]
assert row["grade"] == pytest.approx(grade * 100)
assert row["gap"] == pytest.approx(1000 / 3 * 3.6 / running_cost(grade))
gap = analyse(activity)["summary"]["gap"]
assert (gap < 1000 / 3) if grade > 0 else (gap > 1000 / 3)
def test_missing_data_and_nonrunning_do_not_invent_gap():
activity = run()
for row in activity.samples:
row["elevation"] = None
assert analyse(activity)["summary"]["gap"] is None
activity = run()
activity.sport = "cycling"
assert analyse(activity)["summary"]["gap"] is None
activity = run()
for row in activity.samples:
row["time"] = None
assert analyse(activity)["summary"]["gap"] is None
def test_recording_gap_excluded_from_time_and_distance():
activity = run()
for row in activity.samples[100:]:
row["time"] += 100
result = analyse(activity)
assert result["summary"]["gap"] == pytest.approx(1000 / 3)
assert result["summary"]["gap_coverage"] == pytest.approx(99.5)
def test_extreme_slopes_and_stationary_records_have_no_gap():
assert analyse(run(0.5))["summary"]["gap"] is None
activity = run()
for row in activity.samples:
row["distance"] = 0
assert analyse(activity)["summary"]["gap"] is None
+33
View File
@@ -223,3 +223,36 @@ def test_real_cache_import_compatibility_without_persisting_user_data(tmp_path):
for row in db.session.scalars(db.select(Activity)):
assert row.distance_m >= 0 and row.duration_s >= 0
assert row.visibility == "private"
def test_refresh_preserves_sharing_and_converts_running_units(app):
from app.services import activity_from_fit
doc = {
"session": [{"sport": "running", "start_time": "2026-09-30T10:00:00Z"}],
"record": [
{
"timestamp": "2026-09-30T10:00:00Z",
"cadence": 80,
"fractional_cadence": 0.5,
"step_length": 1100,
"vertical_oscillation": 85,
"stance_time": 250,
"power": 300,
}
],
}
with app.app_context():
activity = db.session.get(Activity, 1)
activity.title = "My chosen title"
activity.visibility = "friends"
result = activity_from_fit(1, "test:1", [doc], refresh=True)
db.session.commit()
assert result.id == 1
assert result.title == "My chosen title"
assert result.visibility == "friends"
assert result.samples[0]["cadence"] == 161
assert result.samples[0]["stride_length"] == 1.1
assert result.samples[0]["vertical_oscillation"] == 8.5
assert result.samples[0]["ground_contact"] == 250
assert result.samples[0]["power"] == 300