From 24c7fc2c6ab95e7105e67fed19c66d8be808acdd Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Thu, 1 Oct 2026 18:43:33 +1000 Subject: [PATCH] Add route heat colouring and detailed running analytics --- app/analytics.py | 165 ++++++++++++++++++ app/models.py | 1 + app/routes.py | 6 +- app/services.py | 85 ++++++++- app/static/activity.js | 91 ++++++++-- app/static/style.css | 3 + app/templates/activity.html | 46 ++++- docs/APP_PROGRESS.md | 21 +++ ...195a7_preserve_recorded_running_metrics.py | 32 ++++ tests/test_analytics.py | 74 ++++++++ tests/test_app.py | 33 ++++ 11 files changed, 526 insertions(+), 31 deletions(-) create mode 100644 app/analytics.py create mode 100644 migrations/versions/bdbac89195a7_preserve_recorded_running_metrics.py create mode 100644 tests/test_analytics.py diff --git a/app/analytics.py b/app/analytics.py new file mode 100644 index 0000000..84eaade --- /dev/null +++ b/app/analytics.py @@ -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} diff --git a/app/models.py b/app/models.py index f02ecf3..2a2bce3 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/routes.py b/app/routes.py index acdfaa7..f50ba55 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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/") 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//data") diff --git a/app/services.py b/app/services.py index 7f36f2d..c19b699 100644 --- a/app/services.py +++ b/app/services.py @@ -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} diff --git a/app/static/activity.js b/app/static/activity.js index 01e194c..7fd1986 100644 --- a/app/static/activity.js +++ b/app/static/activity.js @@ -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: '© OpenStreetMap'}).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:`➤`, 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) { - 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(); diff --git a/app/static/style.css b/app/static/style.css index 38ede9d..5127664 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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}} diff --git a/app/templates/activity.html b/app/templates/activity.html index e7a199b..f4de249 100644 --- a/app/templates/activity.html +++ b/app/templates/activity.html @@ -2,13 +2,47 @@ {% block title %}{{ activity.title }} · Stride{% endblock %} {% block breadcrumb %}ACTIVITY / {{ activity.sport|upper }}{% endblock %} {% block head %}{% endblock %} +{% macro metric(label, value, unit='', note='') %} +
{{ label }}{{ value }}{{ unit }}{% if note %}

{{ note }}

{% endif %}
+{% endmacro %} {% block content %} ← {{ 'Your activities' if owner_view else activity.owner.display_name }} -
{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}

{{ activity.title }}

By {{ activity.owner.display_name }} · {{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}

{{ activity.visibility|title }}
-
TIMER TIME
{{ activity.duration_s|duration }}
AVERAGE PACE
{{ activity.pace }}/km
AVERAGE HEART RATE
{{ activity.avg_hr|int if activity.avg_hr else '—' }}bpm
-

The route.

{{ 'Full recorded route' }}

Loading activity…

-

Pace

min/km

Heart rate

bpm
-

Laps & splits

{{ activity.laps|length }} recorded laps
{% for lap in activity.laps %}{% else %}{% endfor %}
LAPDISTANCETIMEPACE /KMHR
{{ loop.index }}{{ '%.2f'|format(lap.distance / 1000) }} km{{ lap.duration|duration }}{{ lap.pace|duration if lap.pace else '—' }}{{ lap.heart_rate|int if lap.heart_rate else '—' }}
No laps recorded.
-{% if owner_view %}
YOUR RUN, YOUR CHOICE

Sharing settings

Accepted friends can see the full route, date, statistics and charts of shared activities. Choose Only me to keep this activity private.

{% endif %}
+
{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}

{{ activity.title }}

By {{ activity.owner.display_name }}·{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}·{{ '{:,}'.format(analysis.summary.samples) }} recorded samples

{{ activity.visibility|title }}
+ +
+ +
TIMER TIME
{{ activity.duration_s|duration }}

Elapsed {{ activity.metrics.get('total_elapsed_time')|duration if activity.metrics.get('total_elapsed_time') else '—' }}

+
AVERAGE PACE
{{ activity.pace }}/km

GAP estimate {{ pace_text(analysis.summary.gap) }}/km

+
AVERAGE HEART RATE
{{ activity.avg_hr|int if activity.avg_hr else '—' }}bpm

Peak {{ analysis.summary.max_hr|int if analysis.summary.max_hr else '—' }} bpm

+
+
+
FOLLOW YOUR FOOTSTEPS

Your run, in colour.

+

Loading your route…

+
Slower
FasterS Start · F Finish · Grey = no measurement
+

+
+
+
A CLOSER LOOK

The numbers behind the run.

Hover over a chart to locate that part of the route.

+
+ {{ 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') }} +
+
+

Pace & grade-adjusted pace

min/km · lower is faster
+

Heart rate

Recorded · bpm
+

Elevation & gradient

metres / % grade
+

Cadence & step length

steps/min / metres
+

Running power

Recorded · watts
+

Running dynamics

vertical movement / contact time
+
+
+
HILLS CHANGE THE EFFORT

Heart rate by terrain

Measured HR · not adjusted

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.

{% for t in analysis.terrain %}
{{ t.label }}{{ '%.0f'|format(t.heart_rate) if t.heart_rate else '—' }}bpm
{{ '%.2f'|format(t.distance/1000) }} km · {{ pace_text(t.pace) }}/km
{% endfor %}
+

Laps & splits

{{ activity.laps|length }} recorded laps
{% for lap in analysis.laps %}{% else %}{% endfor %}
LAPDISTANCETIMEPACEGAP EST.HR
{{ loop.index }}{{ '%.2f'|format(lap.distance / 1000) }} km{{ lap.duration|duration }}{{ pace_text(lap.pace) }}{{ pace_text(lap.gap) }}{{ lap.heart_rate|int if lap.heart_rate else '—' }}
No laps recorded.
+{% if owner_view %}
YOUR RUN, YOUR CHOICE

Sharing settings

Accepted friends see the full route and measurements of shared activities. Choose Only me to keep this activity private.

{% endif %}
+
About these numbers Recorded data vs. estimates +

GAP is an estimate, not a Garmin or Strava value. We use the Minetti running energy-cost model 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.

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.

Heart rate stays measured. 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.

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.

{% endblock %} {% block scripts %}{% endblock %} diff --git a/docs/APP_PROGRESS.md b/docs/APP_PROGRESS.md index 416f141..e9759ac 100644 --- a/docs/APP_PROGRESS.md +++ b/docs/APP_PROGRESS.md @@ -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). diff --git a/migrations/versions/bdbac89195a7_preserve_recorded_running_metrics.py b/migrations/versions/bdbac89195a7_preserve_recorded_running_metrics.py new file mode 100644 index 0000000..8ddf6d3 --- /dev/null +++ b/migrations/versions/bdbac89195a7_preserve_recorded_running_metrics.py @@ -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 ### diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..ebaa7c8 --- /dev/null +++ b/tests/test_analytics.py @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py index 67c08a0..d28f4e5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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