Add matched training signals, daily recovery and performance analysis

This commit is contained in:
Peter Stockings
2026-10-03 13:22:32 +10:00
parent e6e2f22479
commit 418e03583a
37 changed files with 1380 additions and 47 deletions
+11 -7
View File
@@ -2,8 +2,9 @@
(() => {
'use strict';
const colors = ['#f48459', '#8ccbb4', '#9cb8e8', '#d2ace8', '#e0c375', '#75cad6'];
const unitLabel = unit => unit==='time'?'min:sec':unit;
const pace = value => { const seconds = Math.round(value); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; };
const number = (value, unit) => value == null ? '—' : unit === '/km' ? pace(value) : new Intl.NumberFormat(undefined, {maximumFractionDigits:2}).format(value);
const number = (value, unit) => value == null ? '—' : ['/km','time'].includes(unit) ? pace(value) : new Intl.NumberFormat(undefined, {maximumFractionDigits:2}).format(value);
function element(tag, className, text) {
const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node;
}
@@ -28,8 +29,8 @@
function render(container, data, onBucket, {compact=false, scatterOnly=false}={}) {
const instances = [], cleanups = [];
container.replaceChildren();
const chart = (canvas, config) => {config.plugins=[...(config.plugins||[]),{id:'strideCanvasBackground',beforeDraw(c){const ctx=c.ctx;ctx.save();ctx.globalCompositeOperation='destination-over';ctx.fillStyle='#202629';ctx.fillRect(0,0,c.width,c.height);ctx.restore();}}];const instance = new Chart(canvas, config); instances.push(instance); return instance; };
const axes = (unit, reverse=false, title=unit) => ({title:{display:true,text:title,color:'#aab5b7'},reverse,ticks:{color:'#aab5b7',maxTicksLimit:6,callback:v=>number(v,unit)},grid:{color:'#354044'}});
const chart = (canvas, config) => {if(data.coverage&&!data.scatter){const plugins=config.options.plugins||(config.options.plugins={});const tooltip=plugins.tooltip||(plugins.tooltip={});const callbacks=tooltip.callbacks||(tooltip.callbacks={});callbacks.afterBody=items=>{const coverage=data.coverage[items[0]?.dataIndex];return coverage?`${coverage.sessions} independent sessions · ${coverage.minutes} usable minutes`:'';};}config.plugins=[...(config.plugins||[]),{id:'strideCanvasBackground',beforeDraw(c){const ctx=c.ctx;ctx.save();ctx.globalCompositeOperation='destination-over';ctx.fillStyle='#202629';ctx.fillRect(0,0,c.width,c.height);ctx.restore();}}];const instance = new Chart(canvas, config); instances.push(instance); return instance; };
const axes = (unit, reverse=false, title=unitLabel(unit)) => ({title:{display:true,text:title,color:'#aab5b7'},reverse,ticks:{color:'#aab5b7',maxTicksLimit:6,callback:v=>number(v,unit)},grid:{color:'#354044'}});
const base = {responsive:true,maintainAspectRatio:false,animation:false,plugins:{legend:{labels:{color:'#c5cecf'}}},scales:{x:{ticks:{color:'#aab5b7',maxTicksLimit:compact?4:7},grid:{display:false}}}};
function frame(title) {
const panel = element('div', 'analysis-chart');
@@ -64,13 +65,16 @@
else {
const groups=data.display==='overlay'?[data.series]:data.series.map(s=>[s]);
groups.forEach(seriesGroup=>{
const canvas=frame(seriesGroup.map(s=>`${s.label} (${s.unit})`).join(' / '));const datasets=[],scales={...base.scales};const units=[...new Set(seriesGroup.map(s=>s.unit))];
const canvas=frame(seriesGroup.map(s=>`${s.label} (${unitLabel(s.unit)})`).join(' / '));const datasets=[],scales={...base.scales};const units=[...new Set(seriesGroup.map(s=>s.unit))];
units.forEach((unit,i)=>{scales[`y${i}`]={...axes(unit),position:i%2?'right':'left',grid:{color:'#354044',drawOnChartArea:i===0}};});
seriesGroup.forEach((series,index)=>{
const color=colors[index%colors.length], axis=`y${units.indexOf(series.unit)}`;const automatic=['bin','weekday','hour','run','all'].includes(data.group)||['distance','duration','elevation','count'].includes(series.key)?'bar':'line';const type=data.type==='auto'?automatic:data.type==='area'?'line':data.type;
const timeAxis=data.group==='run'&&data.timestamps?.length&&['line','area'].includes(data.type);
const values=timeAxis?series.values.map((y,i)=>({x:data.timestamps[i],y})):series.values;
if(timeAxis)scales.x={type:'linear',min:Math.min(...data.timestamps)-(data.timestamps.length===1?43200000:0),max:Math.max(...data.timestamps)+(data.timestamps.length===1?43200000:0),ticks:{color:'#aab5b7',maxTicksLimit:compact?4:7,callback:v=>new Intl.DateTimeFormat(undefined,{timeZone:data.timezone||'UTC',month:'short',day:'numeric'}).format(new Date(v))},grid:{display:false}};
if(series.baseline?.value!=null){const b=series.baseline;datasets.push({label:'Baseline range lower',data:data.labels.map(()=>b.low),borderWidth:0,pointRadius:0,yAxisID:axis,type:'line',baseline:true},{label:'Baseline range',data:data.labels.map(()=>b.high),borderWidth:0,pointRadius:0,fill:'-1',backgroundColor:'#8ccbb41c',yAxisID:axis,type:'line',baseline:true},{label:`${series.label} baseline`,data:data.labels.map(()=>b.value),borderColor:'#8ccbb4',borderDash:[4,4],pointRadius:0,borderWidth:1,yAxisID:axis,type:'line',baseline:true});}
if(data.reference?.field===series.key)datasets.push({label:'Reference',data:data.labels.map(()=>data.reference.value),borderColor:'#d2ace8',borderDash:[3,3],pointRadius:0,yAxisID:axis,type:'line',baseline:true});
datasets.push({label:series.label,data:series.values,type,yAxisID:axis,borderColor:color,backgroundColor:color+(type==='bar'?'bb':'33'),borderWidth:2,pointRadius:c=>series.baseline?.flags[c.dataIndex]?6:2,pointStyle:c=>series.baseline?.flags[c.dataIndex]?'triangle':'circle',tension:.15,spanGaps:false,fill:data.type==='area',unit:series.unit});
if(data.reference?.field===series.key)datasets.push({label:'Reference',data:data.labels.map((_,i)=>timeAxis?{x:data.timestamps[i],y:data.reference.value}:data.reference.value),borderColor:'#d2ace8',borderDash:[3,3],pointRadius:0,yAxisID:axis,type:'line',baseline:true});
datasets.push({label:series.label,data:values,type,yAxisID:axis,borderColor:color,backgroundColor:color+(type==='bar'?'bb':'33'),borderWidth:2,pointRadius:c=>series.baseline?.flags[c.dataIndex]?6:2,pointStyle:c=>series.baseline?.flags[c.dataIndex]?'triangle':'circle',tension:.15,spanGaps:false,fill:data.type==='area',unit:series.unit});
if(series.previous.length)datasets.push({label:`${series.label} · previous`,data:series.previous,type,yAxisID:axis,borderColor:'#aab5b7',borderDash:[5,5],borderWidth:1,pointRadius:1,backgroundColor:'#aab5b722',previous:true,unit:series.unit});
});
chart(canvas,{type:'line',data:{labels:data.labels,datasets},options:{...base,scales,plugins:{legend:{labels:{color:'#c5cecf',filter:item=>!['Baseline range lower','Baseline range'].includes(item.text)}},tooltip:{filter:c=>!c.dataset.baseline,callbacks:{label:c=>`${c.dataset.label}: ${number(c.parsed.y,c.dataset.unit)} ${c.dataset.unit}`}}},onClick:(_,points,c)=>{const p=points.find(p=>!c.data.datasets[p.datasetIndex].baseline&&!c.data.datasets[p.datasetIndex].previous);if(p)onBucket?.(data.labels[p.index]);}}});
@@ -82,7 +86,7 @@
const finite=values=>values.filter(v=>typeof v==='number'&&Number.isFinite(v));
const colorValues=finite(s.current.map(p=>p.color)), sizeValues=finite(s.current.map(p=>p.size));const extent=values=>values.length?[Math.min(...values),Math.max(...values)]:[0,0];const [cmin,cmax]=extent(colorValues),[smin,smax]=extent(sizeValues);
const scale=(v,min,max)=>v==null?.5:max===min?.5:Math.max(0,Math.min(1,(v-min)/(max-min)));
chart(canvas,{type:'scatter',data:{datasets:[{label:'Current',data:s.current,backgroundColor:c=>s.color?`hsl(${25+160*scale(c.raw?.color,cmin,cmax)} 65% 65%)`:colors[0],pointRadius:c=>s.size?3+6*scale(c.raw?.size,smin,smax):4},...(s.previous.length?[{label:'Previous',data:s.previous,backgroundColor:'#aab5b7',pointRadius:3}]:[])]},options:{...base,scales:{x:axes(s.x[1],false,`${s.x[0]} (${s.x[1]})`),y:axes(s.y[1],false,`${s.y[0]} (${s.y[1]})`)},plugins:{legend:{labels:{color:'#c5cecf'}},tooltip:{callbacks:{label:c=>[c.raw.label,`${number(c.raw.x,s.x[1])} ${s.x[1]} / ${number(c.raw.y,s.y[1])} ${s.y[1]}`,...(s.color?[`${s.color[0]}: ${number(c.raw.color,s.color[1])} ${s.color[1]}`]:[]),...(s.size?[`${s.size[0]}: ${number(c.raw.size,s.size[1])} ${s.size[1]}`]:[])]}}},onClick:(_,points,c)=>{if(points.length)location.assign(`/activities/${c.data.datasets[points[0].datasetIndex].data[points[0].index].id}`);}}});
chart(canvas,{type:'scatter',data:{datasets:[{label:'Current',data:s.current,backgroundColor:c=>s.color?`hsl(${25+160*scale(c.raw?.color,cmin,cmax)} 65% 65%)`:colors[0],pointRadius:c=>s.size?3+6*scale(c.raw?.size,smin,smax):4},...(s.previous.length?[{label:'Previous',data:s.previous,backgroundColor:'#aab5b7',pointRadius:3}]:[])]},options:{...base,scales:{x:axes(s.x[1],false,`${s.x[0]} (${s.x[1]})`),y:axes(s.y[1],false,`${s.y[0]} (${s.y[1]})`)},plugins:{legend:{labels:{color:'#c5cecf'}},tooltip:{callbacks:{label:c=>[c.raw.label,`${number(c.raw.x,s.x[1])} ${s.x[1]} / ${number(c.raw.y,s.y[1])} ${s.y[1]}`,...(s.color?[`${s.color[0]}: ${number(c.raw.color,s.color[1])} ${s.color[1]}`]:[]),...(s.size?[`${s.size[0]}: ${number(c.raw.size,s.size[1])} ${s.size[1]}`]:[])]}}},onClick:(_,points,c)=>{if(points.length){const id=c.data.datasets[points[0].datasetIndex].data[points[0].index].id;if(id)location.assign(`/activities/${id}`);}}}});
if(s.color||s.size)container.append(element('p','field-help',`${s.color?`Color: ${s.color[0]} · ${number(cmin,s.color[1])} (warm) to ${number(cmax,s.color[1])} (cool). `:''}${s.size?`Size: ${s.size[0]} · ${number(smin,s.size[1])} to ${number(smax,s.size[1])}.`:''}`));
}
return ()=>{instances.forEach(c=>c.destroy());cleanups.forEach(f=>f());};
+4 -1
View File
@@ -48,7 +48,10 @@
document.getElementById('new-analysis').onclick=()=>location.assign('/stats');
document.getElementById('matched-effort-form').onsubmit=event=>{
event.preventDefault();const fields=Object.fromEntries(new FormData(event.target));
const text=`${fields.source}\n during last ${fields.weeks} weeks\n where pace between ${fields.pace_low} and ${fields.pace_high} /km\n and elevation_per_km <= ${fields.hills} m/km\n and has heart_rate\n${fields.source==='segments'?' and pace_variability <= 5 %\n and sensor_coverage >= 80 %\n and duration >= 4 min\n':''} summarize weighted_mean(heart_rate) as "Matched-pace HR"\n by week\n compare none\n baseline previous ${fields.baseline} days\n flag above baseline 1.5 sd min ${fields.minimum} runs sustained 2 buckets\n chart line`;
const windows=fields.source==='intervals';
const filters=windows?` reduce by run min ${fields.matched} min\n where pace between ${fields.pace_low} and ${fields.pace_high} /km\n and elapsed >= ${fields.warmup} min\n and grade between -${fields.grade} and ${fields.grade} %\n and grade_variability <= 2 %\n and pace_variability <= 15 %\n and sensor_coverage >= 90 %\n and duration >= 25 s\n and hr_artifact_percent <= 0 %\n`:` where pace between ${fields.pace_low} and ${fields.pace_high} /km\n and elevation_per_km <= ${fields.hills} m/km\n and has heart_rate\n`;
const baseline=fields.use_baseline?` baseline previous ${fields.baseline} days\n flag above baseline 1.5 sd min ${fields.minimum} runs sustained 2 buckets\n`:'';
const text=`${fields.source}\n during last ${fields.weeks} weeks\n${filters} summarize ${windows?'mean':'weighted_mean'}(heart_rate) as "Matched-pace HR"\n by ${fields.use_baseline?'week':'run'}\n sort by date asc\n compare none\n${baseline} chart line`;
location.assign('/stats?'+new URLSearchParams({query:text}));
};
document.querySelectorAll('[data-presentation]').forEach(control=>control.onchange=()=>{
+3 -2
View File
@@ -11,7 +11,7 @@
const url=(bucket,scope='group')=>'/stats?'+new URLSearchParams({analysis:id,query:result.analysis.query,...(bucket?{bucket,scope}:{})})+'#studio-evidence';
const data=result.results;
let dispose=null;
if(!data.count)body.append(el('p','field-help','No runs match this analysis yet. Explore to adjust the filters.'));
if(!data.count)body.append(el('p','field-help','No observations match this analysis yet. Explore to adjust the filters.'));
else if(widget.dataset.view==='chart'&&(data.chart.type!=='none'||data.chart.scatter))dispose=StrideCharts.render(body,data.chart,bucket=>location.assign(url(bucket,'trend')),{compact:true});
else if(widget.dataset.view==='value'||(widget.dataset.view==='chart'&&data.chart.type==='none')){
const values=el('div','live-widget-values');data.cards.forEach(card=>{const item=el('div');item.append(el('span','eyebrow',card.label),el('strong','',`${card.value} ${card.unit}`),el('small','field-help',`${card.coverage} of ${data.count} usable ${data.source}`));values.append(item);});body.append(values);
@@ -19,7 +19,8 @@
const wrap=el('div','activity-table-wrap'),table=el('table','activity-table'),head=el('thead'),tr=el('tr');['Group',data.source,...data.cards.map(c=>`${c.label} · ${c.unit}`)].forEach(t=>tr.append(el('th','',t)));head.append(tr);table.append(head);const tbody=el('tbody');data.groups.slice(-12).forEach(group=>{const row=el('tr'),cell=el('td'),a=el('a','',group.label);a.href=url(group.label);cell.append(a);row.append(cell);[group.count,...group.values].forEach(v=>row.append(el('td','',v)));tbody.append(row);});table.append(tbody);wrap.append(table);body.append(wrap);if(data.groups.length>12)body.append(el('p','field-help','Last 12 displayed groups · Explore for all results.'));
}
StrideCharts.insights(insights,data.insights,bucket=>location.assign(url(bucket)));
widget.querySelector('.widget-status').textContent=`${data.count} matching ${data.source} · ${data.start} – ${data.end} · updated ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`;
const coverage=data.coverage;
widget.querySelector('.widget-status').textContent=`${data.count} qualifying ${data.source==='matched runs'?'runs':data.source}${coverage?` · ${coverage.sessions} independent sessions · ${coverage.minutes} usable minutes`:''} · ${data.start} – ${data.end} · updated ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`;
states.set(id,{result,dispose});
}
async function refresh(){
+9
View File
@@ -26,6 +26,15 @@
['runs', 'Your recorded running activities'],
['laps', 'Recorded lap inputs from compact analysis'],
['segments', 'Five-minute moving segments from full-resolution recordings'],
['intervals', 'Continuous 30-second moving windows, with elapsed offset and terrain'],
['efforts', 'Recorded best-effort windows at 1, 2, 5 and 10 km'],
['days', 'Daily workload and rest days, using local calendar windows'],
['health', 'Private daily recovery observations from Garmin'],
['reduce by run min 3 min', 'Match recording windows first; one observation per qualifying session'],
['long run distance >= 10 km', 'Choose the qualifying long-run threshold for daily continuity'],
['route similar to 1', 'Approximate full-course match to one of your own recorded routes'],
['where elapsed >= 10 min', 'Exclude the first ten elapsed recording minutes'],
['and grade between -2 and 2 %', 'Require approximately flat smoothed terrain'],
['cohort "Easy" where heart_rate <= 145 bpm', 'First-match partitions for a stacked additive summary'],
['compare previous year', 'Same local-date interval in the preceding year'],
['during last 12 weeks', 'Rolling period ending today'],
+2 -1
View File
@@ -30,8 +30,9 @@
tbody.replaceChildren();
result.rows.forEach(row => {
const tr = document.createElement('tr'); const td = document.createElement('td');
const a = document.createElement('a'); a.href = `/activities/${row.id}`; a.textContent = `${row.title} · ${row.date}`;
const a = document.createElement(row.id?'a':'span'); if(row.id)a.href = `/activities/${row.id}`; a.textContent = `${row.title} · ${row.date}`;
td.append(a); tr.append(td);
(row.activity_ids||[]).forEach(id=>{const link=document.createElement('a');link.href=`/activities/${id}`;link.textContent=` · Run #${id}`;td.append(link);});
if (root.dataset.showPrevious === 'true') {
const detail=document.createElement('small');detail.className='previous-run';
detail.append('Previous: ');
+3
View File
@@ -48,3 +48,6 @@ a.record-badge:focus-visible { outline: 2px solid #f3cb78; outline-offset: 3px;
@media(max-width:680px){.app-header{height:auto;min-height:72px;padding:16px 18px;gap:12px;flex-wrap:wrap}.app-header .brand{font-size:25px;gap:8px}.app-header .brand-mark{width:28px;height:28px;font-size:25px}.header-nav{gap:0}.header-nav a{padding:8px;font-size:12px}.header-actions{gap:8px}.header-import{order:1;padding:8px 10px;font-size:11px}.header-import .icon{display:none}.app-header .brand-dot{margin-left:-6px}.account-dropdown-menu{width:220px}.app-shell .page{padding-top:25px}.page-heading h1{font-size:30px}}
@media(max-width:680px){.header-import{display:none}}
/* Keep visually hidden table labels within the scroll container on mobile. */
.activity-table-wrap{position:relative}