From bcd4cd37596c1f76483bb45b1d1e0af83c00f3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AD=8F=E9=81=87=E5=8D=BF?= <358614401@qq.com> Date: Sat, 17 Oct 2026 14:10:27 +0800 Subject: [PATCH] feat: render health pages from server summaries --- qa/dashboard-performance.test.cjs | 12 +-- qa/dashboard.test.cjs | 20 ++--- qa/health-interaction.test.cjs | 11 +-- qa/health-server-pagination.test.cjs | 79 +++++++++++++++++++ qa/health-server-summary.test.cjs | 33 ++++++++ .../web_assets/static/js/app.js | 72 ++++++----------- .../web_assets/static/js/health-ui.js | 58 ++++++++------ .../web_assets/static/js/health-views.js | 53 +++++++++++-- .../web_assets/templates/index.html | 6 +- 9 files changed, 241 insertions(+), 103 deletions(-) create mode 100644 qa/health-server-pagination.test.cjs create mode 100644 qa/health-server-summary.test.cjs diff --git a/qa/dashboard-performance.test.cjs b/qa/dashboard-performance.test.cjs index 52e98ce..355a5d7 100644 --- a/qa/dashboard-performance.test.cjs +++ b/qa/dashboard-performance.test.cjs @@ -8,22 +8,24 @@ function fixture(){ const records=Array.from({length:3000},(_,i)=>({timestamp:`2026-10-16T09:${String(i%60).padStart(2,'0')}:00+08:00`,stress_score:30})); const responses={summary:[{date:'2026-10-16',steps:6000,active_kcal:500,distance_m:4000}], 'heart-rate':[{bpm:62}],sleep:[{end_at:'2026-10-16T08:00:00+08:00',duration_minutes:450}],spo2:[{spo2_pct:97}],stress:records}; - const copied=[]; + const copied=[],requests=[]; const context=vm.createContext({document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){}}, - localStorage:{getItem(){return '';},setItem(){}},performance,console,setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){}, + AbortController,localStorage:{getItem(){return '';},setItem(){}},performance,console,setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){}, navigator:{clipboard:{writeText(text){copied.push(text);return Promise.resolve();}}}, - fetch:async url=>({status:200,ok:true,text:async()=>JSON.stringify({status:'ok',data:responses[url.split('?')[0].split('/').pop()]||[]})})}); + fetch:async url=>{requests.push(url);return {status:200,ok:true,text:async()=>JSON.stringify({status:'ok',data:url.startsWith('/proxy/api/overview?')?{steps:6000,active_kcal:500,distance_m:4000,resting_mean_bpm:62,mean_daily_asleep_minutes:450,mean_spo2_pct:97,mean_stress:30,activity_days:1,sleep_days:1,resting_count:1,spo2_count:1}:responses[url.split('?')[0].split('/').pop()]||[]})};}}); vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),context); vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/app.js','utf8'),context); vm.runInContext('showToast=()=>{}; renderCount=0; originalRender=renderJsonViewer; renderJsonViewer=(data)=>{renderCount++;originalRender(data);};',context); - return {context,get,copied}; + return {context,get,copied,requests}; } test('overview paints one small response instead of five full datasets',async()=>{ const ui=fixture();await vm.runInContext('loadOverview()',ui.context); assert.equal(vm.runInContext('renderCount',ui.context),1); assert.equal(ui.get('statStress').textContent,'30'); assert.equal(ui.get('statSteps').textContent,(6000).toLocaleString()); - assert.equal(vm.runInContext('state.lastRequestInfo.path.split("?")[0]',ui.context),'/api/summary'); + assert.equal(vm.runInContext('state.lastRequestInfo.path.split("?")[0]',ui.context),'/api/overview'); + assert.deepEqual(ui.requests,['/proxy/api/overview?start_date=2026-09-18&end_date=2026-10-17']); + assert.ok(JSON.stringify(vm.runInContext('state.lastResponse',ui.context)).length<500); }); test('silent and superseded requests do not overwrite inspector state',async()=>{ const ui=fixture();ui.get('inspectorBody').innerHTML='keep-current'; diff --git a/qa/dashboard.test.cjs b/qa/dashboard.test.cjs index e9e9385..79e5338 100644 --- a/qa/dashboard.test.cjs +++ b/qa/dashboard.test.cjs @@ -14,21 +14,17 @@ function dashboard(responses) { const context = vm.createContext({ localStorage: {getItem() {return '';}, setItem() {}}, document: {getElementById: get, querySelectorAll() {return [];}, addEventListener() {}}, - responses, console, setTimeout() {}, clearTimeout() {}, setInterval() {}, clearInterval() {}, + responses, console, AbortController, setTimeout() {}, clearTimeout() {}, setInterval() {}, clearInterval() {}, }); vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),context); vm.runInContext(fs.readFileSync(file, 'utf8'), context); - vm.runInContext("showToast = () => {}; api = async (method, url) => responses[url.split('?')[0]] || {status:'ok',data:[]};", context); + vm.runInContext("showToast = () => {}; api = async (method, url) => responses[url.split('?')[0]] || {status:'ok',data:{steps:null,active_kcal:null,distance_m:null,resting_mean_bpm:null,mean_daily_asleep_minutes:null,mean_spo2_pct:null,mean_stress:null,activity_days:0,sleep_days:0,resting_count:0,spo2_count:0}};", context); return {context, get}; } test('overview aggregates the selected range instead of taking the latest day', async () => { const ui = dashboard({ - '/api/summary': {status:'ok',data:[{date:'2026-10-16',steps:4063,active_kcal:250,distance_m:2385},{date:'2026-10-15',steps:4691,active_kcal:596,distance_m:2979}]}, - '/api/heart-rate': {status:'ok',data:[{timestamp:'2026-10-16T09:00:00+08:00',bpm:62}]}, - '/api/sleep': {status:'ok',data:[{end_at:'2026-10-16T08:54:00+08:00',duration_minutes:517},{end_at:'2026-10-15T07:55:00+08:00',duration_minutes:474}]}, - '/api/spo2': {status:'ok',data:[{timestamp:'2026-10-16T09:00:00+08:00',spo2_pct:98}]}, - '/api/stress': {status:'ok',data:[{timestamp:'2026-10-16T09:00:00+08:00',stress_score:0}]}, + '/api/overview': {status:'ok',data:{steps:8754,active_kcal:846,distance_m:5364,resting_mean_bpm:62,mean_daily_asleep_minutes:495.5,mean_spo2_pct:98,mean_stress:0,activity_days:2,sleep_days:2,resting_count:1,spo2_count:1}}, }); await vm.runInContext('loadOverview()',ui.context); assert.equal(ui.get('statSteps').textContent, (8754).toLocaleString()); @@ -40,12 +36,9 @@ test('overview aggregates the selected range instead of taking the latest day', assert.equal(String(ui.get('statStress').textContent),'0'); }); -test('range averages include all samples and group naps by wake date without filling missing days', async () => { +test('overview displays server range averages and wake-day coverage without filling missing days', async () => { const ui=dashboard({ - '/api/heart-rate':{status:'ok',data:[{bpm:60},{bpm:70}]}, - '/api/spo2':{status:'ok',data:[{spo2_pct:96},{spo2_pct:98}]}, - '/api/stress':{status:'ok',data:[{stress_score:0},{stress_score:60}]}, - '/api/sleep':{status:'ok',data:[{end_at:'2026-10-15T07:55:00+08:00',duration_minutes:474},{end_at:'2026-10-16T08:54:00+08:00',duration_minutes:517},{end_at:'2026-10-16T14:00:00+08:00',duration_minutes:60,is_nap:true}]}, + '/api/overview':{status:'ok',data:{steps:null,active_kcal:null,distance_m:null,resting_mean_bpm:65,mean_spo2_pct:97,mean_stress:30,mean_daily_asleep_minutes:525.5,activity_days:0,sleep_days:2,resting_count:2,spo2_count:2}}, }); ui.get('startDate').value='2026-10-01'; await vm.runInContext('loadOverview()',ui.context); @@ -63,7 +56,8 @@ test('30 day preset is inclusive and refreshes the overview without manual reloa assert.equal(ui.get('startDate').value,'2026-09-17'); assert.equal(ui.get('endDate').value,'2026-10-16'); const calls=vm.runInContext('calls',ui.context); - assert.equal(calls.length,5); + assert.equal(calls.length,1); + assert.equal(calls[0],'/api/overview?start_date=2026-09-17&end_date=2026-10-16'); assert.ok(calls.every(path=>!path.includes('limit=1'))); await vm.runInContext("setDatePreset('yesterday')",ui.context); assert.equal(ui.get('startDate').value,'2026-10-15'); diff --git a/qa/health-interaction.test.cjs b/qa/health-interaction.test.cjs index 2445b18..0ed3fdb 100644 --- a/qa/health-interaction.test.cjs +++ b/qa/health-interaction.test.cjs @@ -3,17 +3,18 @@ const assert=require('node:assert/strict'); const vm=require('node:vm'); const fs=require('node:fs'); const HealthViews=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js'); +function summary(kind='sleep',rows=[]){return {status:'ok',kind,statistics:{count:rows.length,mean_daily_asleep_minutes:400,mean_daily_deep_minutes:null,nap_count:0},coverage:rows.length,stride:1,points:[{date:'2026-10-16',values:[null,null,null,400/60],missing:false}],page:{number:1,size:10,total:rows.length,rows}};} function fixture(){ const nodes=new Map(),saved={};let callback; const get=id=>{if(!nodes.has(id))nodes.set(id,{value:({startDate:'2026-10-15',endDate:'2026-10-17',napsSelect:'true'})[id]||'',textContent:'',innerHTML:'',checked:false,dataset:{},setAttribute(k,v){this[k]=v;},classList:{toggle(){},add(){},remove(){}}});return nodes.get(id);}; - const c=vm.createContext({HealthViews,AbortController,console,performance,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(name,fn){callback=fn;},documentElement:{setAttribute(){}}},localStorage:{getItem(k){return saved[k]||'';},setItem(k,v){saved[k]=v;}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){}}); + const c=vm.createContext({HealthViews,summary,AbortController,console,performance,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(name,fn){callback=fn;},documentElement:{setAttribute(){}}},localStorage:{getItem(k){return saved[k]||'';},setItem(k,v){saved[k]=v;}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){}}); vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/health-ui.js','utf8'),c); vm.runInContext(fs.readFileSync('src/mi_fitness_mcp/web_assets/static/js/app.js','utf8'),c); vm.runInContext('showToast=()=>{};',c); return{c,get,saved}; } test('切换到睡眠页自动查询并清空上个接口,修改日期会重新加载',async()=>{ - const f=fixture();vm.runInContext('calls=[];api=async(method,path)=>{calls.push(path);return {status:"ok",data:[]};};state.lastResponse={old:true};',f.c); + const f=fixture();vm.runInContext('calls=[];api=async(method,path)=>{calls.push(path);return summary();};state.lastResponse={old:true};',f.c); await vm.runInContext("switchTab('sleep')",f.c); assert.equal(vm.runInContext('state.lastResponse',f.c),null); assert.equal(vm.runInContext('calls.length',f.c),1); @@ -28,15 +29,15 @@ test('后返回的旧日期请求不会覆盖新日期结果或控制台',async( f.get('startDate').value='2026-10-16'; const second=vm.runInContext('querySleep()',f.c); assert.equal(waits[0].view.isCurrent(),false); - waits[1].resolve({status:'ok',data:[{end_at:'2026-10-16T08:00:00+08:00',time_asleep_minutes:400}]});await second; - waits[0].resolve({status:'ok',data:[{end_at:'2026-10-15T08:00:00+08:00',time_asleep_minutes:100}]});await first; + waits[1].resolve(summary('sleep',[{end_at:'2026-10-16T08:00:00+08:00',time_asleep_minutes:400}]));await second; + waits[0].resolve(summary('sleep',[{end_at:'2026-10-15T08:00:00+08:00',time_asleep_minutes:100}]));await first; assert.equal(vm.runInContext('state.healthViews.sleep.model.records[0].time_asleep_minutes',f.c),400); }); test('失败与无数据分别显示,控制台开关会记忆选择',async()=>{ const f=fixture();vm.runInContext('state.activeTab="spo2";api=async()=>null;',f.c); await vm.runInContext('querySpo2()',f.c); assert.match(f.get('healthView-spo2').innerHTML,/查询失败/); - vm.runInContext('api=async()=>({status:"ok",data:[]});',f.c); + vm.runInContext('api=async()=>summary("spo2");',f.c); await vm.runInContext('querySpo2()',f.c); assert.match(f.get('healthView-spo2').innerHTML,/未保存/); vm.runInContext('setInspectorCollapsed(true)',f.c); diff --git a/qa/health-server-pagination.test.cjs b/qa/health-server-pagination.test.cjs new file mode 100644 index 0000000..ca64951 --- /dev/null +++ b/qa/health-server-pagination.test.cjs @@ -0,0 +1,79 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const vm=require('node:vm'); +const fs=require('node:fs'); +const HealthViews=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js'); +function summary(kind='stress',number=1,total=21){return {status:'ok',kind,range:{start:'2026-10-15',end:'2026-10-17'},statistics:{count:total,primary_mean:33,primary_min:10,primary_max:60},coverage:1,stride:1,points:[{date:'2026-10-16',values:[33],missing:false}],page:{number,size:10,total,rows:[{timestamp:'2026-10-16T00:00:00Z',stress_score:number}]}};} +function fixture(){ + const nodes=new Map(),links=[],requests=[],calendars=[]; + const get=id=>{if(!nodes.has(id))nodes.set(id,{value:({startDate:'2026-10-15',endDate:'2026-10-17',napsSelect:'true'})[id]||'',textContent:'',innerHTML:'',checked:false,setAttribute(){},classList:{toggle(){},add(){},remove(){}},querySelector(){return get(id+'-table');}});return nodes.get(id);}; + const context=vm.createContext({HealthViews,AbortController,console,performance,URL,document:{getElementById:get,querySelectorAll(){return [];},addEventListener(){},createElement(){const a={click(){links.push(this.href);},remove(){}};return a;},body:{appendChild(){}}},localStorage:{getItem(){return '';},setItem(){}},setTimeout(){},setInterval(){},clearTimeout(){},clearInterval(){},fetch:url=>new Promise(resolve=>{if(url.startsWith('/api/calendar'))calendars.push({url,resolve});else requests.push({url,resolve});})}); + for(const name of ['health-ui','app'])vm.runInContext(fs.readFileSync(`src/mi_fitness_mcp/web_assets/static/js/${name}.js`,'utf8'),context); + vm.runInContext('showToast=()=>{};state.activeTab="stress";',context); + const reply=(i,p)=>requests[i].resolve({status:200,ok:true,text:async()=>JSON.stringify(p)}); + return {context,get,links,requests,calendars,reply,run:code=>vm.runInContext(code,context)}; +} +test('test_pagination_fetches_only_page_and_preserves_chart',async()=>{ + const f=fixture(),initial=f.run('loadHealthView("stress")');assert.match(f.requests[0].url,/^\/proxy\/api\/views\/stress\?/);f.reply(0,summary());await initial; + const model=f.run('state.healthViews.stress.model'),html=f.get('healthView-stress').innerHTML; + const second=f.run('healthPage("stress",2)');assert.match(f.requests[1].url,/\/views\/stress\/records\?.*page=2/); + const p=summary('stress',2);delete p.statistics;delete p.points;f.reply(1,p);await second; + assert.equal(f.run('state.healthViews.stress.model'),model);assert.equal(model.cards[0].raw,33);assert.equal(f.get('healthView-stress').innerHTML,html);assert.match(f.get('healthView-stress-table').innerHTML,/2 \/ 3/); + f.run('exportHealthRecords("stress")');assert.match(f.links[0],/^\/proxy\/api\/views\/stress\/export\?/);assert.doesNotMatch(f.links[0],/page=/); +}); +test('test_late_page_and_overview_responses_do_not_overwrite_new_range',async()=>{ + const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial; + const old=f.run('healthPage("stress",2)');f.get('startDate').value='2026-10-16';const fresh=f.run('loadHealthView("stress")');f.reply(2,summary('stress',1,5));await fresh; + const html=f.get('healthView-stress').innerHTML,inspector=f.get('inspectorBody').innerHTML; + f.reply(1,summary('stress',2));await old;f.calendars[0].resolve({ok:true,json:async()=>({years:{old:true}})});await new Promise(resolve=>setImmediate(resolve)); + assert.equal(f.get('healthView-stress').innerHTML,html);assert.equal(f.get('inspectorBody').innerHTML,inspector);assert.equal(f.run('state.healthViews.stress.model.total'),5); + f.run('state.activeTab="overview"');const overview=f.run('loadOverview()');f.get('endDate').value='2026-10-16';f.reply(3,{status:'ok',data:{steps:999}});await overview;assert.equal(f.get('statSteps').textContent,'--');assert.equal(f.get('inspectorBody').innerHTML.includes('999'),false); +}); +test('switching type and filters rejects pending page results, latest page wins',async()=>{ + const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial; + const a=f.run('healthPage("stress",2)'),b=f.run('healthPage("stress",3)');f.reply(2,summary('stress',3));await b;f.reply(1,summary('stress',2));await a;assert.equal(f.run('state.healthViews.stress.page'),3); + const pending=f.run('healthPage("stress",2)');f.get('stressLevel').value='mild';f.run('state.activeTab="sleep"');f.reply(3,summary('stress',2));await pending;assert.equal(f.run('state.healthViews.stress.page'),3); +}); +test('shrinking totals re-fetch the last valid page instead of showing false empty',async()=>{ + const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial; + const pending=f.run('healthPage("stress",3)');const p=summary('stress',3,11);p.page.rows=[];f.reply(1,p);await new Promise(resolve=>setImmediate(resolve));assert.match(f.requests[2].url,/page=2/);f.reply(2,summary('stress',2,11));await pending;assert.equal(f.run('state.healthViews.stress.page'),2);assert.equal(f.run('state.healthViews.stress.model.total'),11); +}); +test('test_empty_and_failure_remain_distinct',async()=>{ + const f=fixture();let p=f.run('loadHealthView("stress")');f.reply(0,{status:'error'});await p;assert.match(f.get('healthView-stress').innerHTML,/查询失败/); + p=f.run('loadHealthView("stress")');const empty=summary('stress',1,0);empty.page.rows=[];f.reply(1,empty);await p;assert.match(f.get('healthView-stress').innerHTML,/未保存/);assert.doesNotMatch(f.get('healthView-stress').innerHTML,/查询失败/); +}); +test('filters use exact Beijing dates and server selection parameters for all eight views',async()=>{ + const f=fixture();f.get('hrType').value='resting';f.get('hrLimit').value='25';f.get('stressLevel').value='unclassified';f.get('stressLimit').value='12';f.get('latestOnlyCheck').checked=true; + for(const kind of HealthViews.kinds){f.context.kind=kind;f.run('state.activeTab=kind');const pending=f.run('loadHealthView(kind)');const i=f.requests.length-1;const u=new URL(f.requests[i].url,'http://local');assert.equal(u.pathname,'/proxy/api/views/'+kind);assert.equal(u.searchParams.get('start_date'),'2026-10-15');assert.equal(u.searchParams.get('end_date'),'2026-10-17'); + if(kind==='heartrate'){assert.equal(u.searchParams.get('limit'),'25');assert.equal(u.searchParams.get('sample_type'),'resting');}if(kind==='stress'){assert.equal(u.searchParams.get('pressure_level'),'unclassified');assert.equal(u.searchParams.get('limit'),'12');}if(kind==='body')assert.equal(u.searchParams.get('latest_only'),'true'); + f.reply(i,summary(kind));await pending;} +}); + +for(const change of ['date','type','filter','limit'])test(`pending pages and calendars reject changed ${change} selection`,async()=>{ + const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial; + const page=f.run('healthPage("stress",2)'),model=f.run('state.healthViews.stress.model'); + if(change==='date')f.get('endDate').value='2026-10-16'; + if(change==='type')f.run('state.activeTab="sleep"'); + if(change==='filter')f.get('stressLevel').value='mild'; + if(change==='limit')f.get('stressLimit').value='5'; + const html=f.get('healthView-stress').innerHTML,inspector=f.get('inspectorBody').innerHTML; + f.reply(1,summary('stress',2));await page;f.calendars[0].resolve({ok:true,json:async()=>({years:{unexpected:true}})});await new Promise(resolve=>setImmediate(resolve)); + assert.equal(f.run('state.healthViews.stress.page'),1);assert.equal(model.range.calendar,undefined);assert.equal(f.get('healthView-stress').innerHTML,html);assert.equal(f.get('inspectorBody').innerHTML,inspector); +}); +test('a current calendar can update date marks after paging without reverting rows',async()=>{ + const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial; + const next=f.run('healthPage("stress",2)');f.reply(1,summary('stress',2));await next; + f.calendars[0].resolve({ok:true,json:async()=>({years:{'2026':{days:[{date:'2026-10-16',isOffDay:true}],papers:['synthetic']}}})});await new Promise(resolve=>setImmediate(resolve)); + assert.equal(f.run('state.healthViews.stress.page'),2);assert.equal(f.run('state.healthViews.stress.model.records[0].stress_score'),2);assert.match(f.get('healthView-stress').innerHTML,/2 \/ 3/);assert.match(f.get('healthView-stress').innerHTML,/stroke="#22a06b"/); +}); +test('a failed page leaves summary intact and allows retrying the same page',async()=>{ + const f=fixture(),initial=f.run('loadHealthView("stress")');f.reply(0,summary());await initial; + const html=f.get('healthView-stress').innerHTML; + let next=f.run('healthPage("stress",2)');f.reply(1,{status:'error'});await next;assert.match(f.get('healthView-stress-table').innerHTML,/明细查询失败/);assert.equal(f.get('healthView-stress').innerHTML,html);assert.equal(f.run('state.healthViews.stress.page'),1); + next=f.run('healthPage("stress",2)');f.reply(2,summary('stress',2));await next;assert.equal(f.run('state.healthViews.stress.page'),2);assert.doesNotMatch(f.get('healthView-stress-table').innerHTML,/查询失败/); +}); +test('out-of-order overview loads preserve new range values and inspector',async()=>{ + const f=fixture();f.run('state.activeTab="overview"');const old=f.run('loadOverview()');f.get('startDate').value='2026-10-16';const current=f.run('loadOverview()'); + f.reply(1,{status:'ok',data:{steps:42}});await current;const inspector=f.get('inspectorBody').innerHTML; + f.reply(0,{status:'ok',data:{steps:999}});await old;assert.equal(f.get('statSteps').textContent,'42');assert.equal(f.get('inspectorBody').innerHTML,inspector);assert.match(f.get('overviewRange').textContent,/2026-10-16 至 2026-10-17/); +}); diff --git a/qa/health-server-summary.test.cjs b/qa/health-server-summary.test.cjs new file mode 100644 index 0000000..86cf4c2 --- /dev/null +++ b/qa/health-server-summary.test.cjs @@ -0,0 +1,33 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const H=require('../src/mi_fitness_mcp/web_assets/static/js/health-views.js'); +const range={start:'2026-10-01',end:'2026-10-30'}; +function payload(kind,statistics,total=90000){return {status:'ok',kind,statistics,coverage:23,stride:1,points:Array.from({length:30},(_,i)=>({date:`2026-10-${String(i+1).padStart(2,'0')}`,values:[40],missing:false})),page:{number:1,size:10,total,rows:Array.from({length:10},()=>({timestamp:'2026-10-15T00:00:00Z',stress_score:1,bpm:1,weight_kg:1,steps:1}))}};} +test('test_server_summary_drives_cards_without_raw_records',()=>{ + const m=H.fromSummary('stress',payload('stress',{count:90000,primary_mean:47.5,primary_min:0,primary_max:120}),range); + assert.equal(m.cards[0].raw,47.5);assert.equal(m.cards[1].raw,0);assert.equal(m.cards[2].raw,120);assert.equal(m.cards[3].raw,23); + assert.equal(m.references.find(r=>r.key==='average').value,47.5);assert.equal(m.records.length,10);assert.equal(m.total,90000);assert.equal(m.points.length,30); + assert.match(H.detail(m),/90,000/);assert.match(H.detail(m),/1 \/ 9000/);assert.match(H.render(m),/health-calendar-mark/); +}); +test('all eight kinds map numeric server statistics and latest projections',()=>{ + const cases={activity:[{primary_sum:800,total_active_kcal:50,total_distance_m:2500,mean_daily_steps:400},[800,50,2.5,23]],heartrate:[{passive_mean_bpm:70,resting_mean_bpm:60,primary_min:40,primary_max:180,primary_mean:69},[70,60,40,180]],sleep:[{mean_daily_asleep_minutes:450,mean_daily_deep_minutes:90,nap_count:3},[450,23,90,3]],workouts:[{count:17,primary_sum:600,total_distance_m:9000,total_calories_kcal:500},[17,600,9,500]],body:[{count:18,primary_mean:64,latest:{weight_kg:65,bmi:22,body_fat_pct:19}},[65,22,19,18]],spo2:[{primary_mean:97,primary_min:89,primary_max:100},[97,89,100,23]],abnormal:[{count:20,primary_sum:90,latest:{start_at:'2026-10-15T00:00:00Z'}},[20,90,23,null]]}; + for(const [kind,[stats,expected]] of Object.entries(cases)){ + const m=H.fromSummary(kind,payload(kind,stats),range);assert.deepEqual(m.cards.map(c=>c.raw),expected,kind); + if(['body','spo2','heartrate'].includes(kind))assert.equal(H.referenceLayout(m).min,{body:40,spo2:80,heartrate:40}[kind]); + } +}); +test('server total governs empty chart even when a requested detail page has no rows',()=>{ + const p=payload('stress',{count:90000,primary_mean:null});p.page.rows=[]; + const m=H.fromSummary('stress',p,range);assert.match(H.render(m),/r.key==='average')); + p.page.total=0;p.statistics.count=0;assert.doesNotMatch(H.render(H.fromSummary('stress',p,range)),/{ + const calendar={'2026':{days:[{date:'2026-10-01',isOffDay:true},{date:'2026-10-10',isOffDay:false}],papers:['synthetic']}}; + const p=payload('heartrate',{primary_mean:65,resting_mean_bpm:60,passive_mean_bpm:70,primary_min:40,primary_max:75}); + p.points=p.points.map(point=>({...point,values:[70,60,null]})); + const m=H.fromSummary('heartrate',p,{...range,calendar}),html=H.render(m); + assert.equal(m.points,p.points);assert.equal(H.referenceLayout(m).min,40);assert.equal(H.referenceLayout(m).max,70);assert.deepEqual(H.referenceLayout(m).outside.map(r=>r.value),[170]); + const marks=[...html.matchAll(/]+/g)].map(x=>x[0]);assert.equal(marks.length,30);assert.ok(marks.every(x=>x.includes('y1="207" y2="207"')));assert.ok(marks[0].includes('stroke="#22a06b"'));assert.ok(marks[9].includes('stroke="#e78325"')); +}); diff --git a/src/mi_fitness_mcp/web_assets/static/js/app.js b/src/mi_fitness_mcp/web_assets/static/js/app.js index 9293477..0330b1e 100644 --- a/src/mi_fitness_mcp/web_assets/static/js/app.js +++ b/src/mi_fitness_mcp/web_assets/static/js/app.js @@ -372,55 +372,33 @@ function sleepDays(rows) { } async function loadOverview() { - const requestId = ++state.overviewRequestId; + const requestId = ++state.overviewRequestId, start=getSd(), end=getEd(); + if(state.overviewController)state.overviewController.abort(); + const controller=new AbortController();state.overviewController=controller; + const isCurrent=()=>requestId===state.overviewRequestId&&state.activeTab==='overview'&&getSd()===start&&getEd()===end; showToast('正在加载仪表盘概览…', '', 'info', 1500); - ['statSteps', 'statCalories', 'statDistance', 'statHr', 'statSleep', 'statSpo2', 'statStress'].forEach(id => { - $(id).textContent = '--'; - }); - const currentView = {isCurrent: () => requestId === state.overviewRequestId && state.activeTab === 'overview'}; - const summaryPromise = api('GET', getRangeQuery('/api/summary'), null, currentView); - const hrPromise = api('GET', getRangeQuery('/api/heart-rate') + '&sample_type=resting', null, {inspect:false}); - const sleepPromise = api('GET', getRangeQuery('/api/sleep') + '&include_naps=true', null, {inspect:false}); - const spo2Promise = api('GET', getRangeQuery('/api/spo2'), null, {inspect:false}); - const stressPromise = api('GET', getRangeQuery('/api/stress'), null, {inspect:false}); - - const responses = await Promise.all([ - summaryPromise, hrPromise, sleepPromise, spo2Promise, stressPromise - ]); - if (requestId !== state.overviewRequestId) return; - const [summary, hr, sleep, spo2, stress] = responses.map(responseRows); - $('overviewRange').textContent = `${getSd()} 至 ${getEd()};缺失日期不按 0 计入均值`; - $('statActivityCoverage').textContent = `已记录 ${new Set(summary.map(row => row.date)).size} 天`; - const days = sleepDays(sleep); - $('statSleepCoverage').textContent = `记录 ${days.size} 天 · 含小睡`; - $('statHrCoverage').textContent = `样本 ${numericValues(hr, 'bpm').length} 条`; - $('statSpo2Coverage').textContent = `样本 ${numericValues(spo2, 'spo2_pct').length} 条`; - - if (summary && Array.isArray(summary) && summary.length > 0) { - $('statSteps').textContent = displayNumber(aggregateValue(summary, 'steps'), value => value.toLocaleString()); - $('statCalories').textContent = displayNumber(aggregateValue(summary, 'active_kcal'), Math.round); - $('statDistance').textContent = displayNumber(aggregateValue(summary, 'distance_m'), value => (value / 1000).toFixed(2)); + ['statSteps', 'statCalories', 'statDistance', 'statHr', 'statSleep', 'statSpo2', 'statStress', 'statActivityCoverage', 'statSleepCoverage', 'statHrCoverage', 'statSpo2Coverage'].forEach(id => {$(id).textContent = '--';}); + if(!start||!end||start>end){clearInspector();$('overviewRange').textContent='请选择有效的开始和结束日期。';return;} + const result=await api('GET',getRangeQuery('/api/overview'),null,{isCurrent,signal:controller.signal}); + if(!isCurrent())return; + if(!result||result.status!=='ok'||!result.data||Array.isArray(result.data)){ + $('overviewRange').textContent='概览查询失败,请重试或查看控制台。'; + showToast('数据未加载','请查看接口错误;未用 0 替代失败数据','warning');return; } - - if (hr && Array.isArray(hr) && hr.length > 0) { - $('statHr').textContent = displayNumber(aggregateValue(hr, 'bpm', true), value => String(Math.round(value * 10) / 10)); - } - - if (sleep && Array.isArray(sleep) && sleep.length > 0) { - const minutes = days.size ? [...days.values()].reduce((sum, value) => sum + value, 0) / days.size : null; - $('statSleep').textContent = displayNumber(minutes, value => (value / 60).toFixed(1)); - } - - if (spo2 && Array.isArray(spo2) && spo2.length > 0) { - $('statSpo2').textContent = displayNumber(aggregateValue(spo2, 'spo2_pct', true), value => `${Math.round(value * 10) / 10}%`); - } - - if (stress && Array.isArray(stress) && stress.length > 0) { - $('statStress').textContent = displayNumber(aggregateValue(stress, 'stress_score', true), value => String(Math.round(value * 10) / 10)); - } - - const failed = responses.some(response => !response || (!Array.isArray(response) && response.status !== 'ok')); - showToast(failed ? '部分数据未加载' : '仪表盘已刷新', failed ? '请查看接口错误;未用 0 替代失败数据' : '已按所选日期汇总', failed ? 'warning' : 'success'); + const data=result.data; + $('overviewRange').textContent = `${start} 至 ${end};缺失日期不按 0 计入均值`; + $('statActivityCoverage').textContent = `已记录 ${data.activity_days??0} 天`; + $('statSleepCoverage').textContent = `记录 ${data.sleep_days??0} 天 · 含小睡`; + $('statHrCoverage').textContent = `样本 ${data.resting_count??0} 条`; + $('statSpo2Coverage').textContent = `样本 ${data.spo2_count??0} 条`; + $('statSteps').textContent = displayNumber(data.steps,value=>value.toLocaleString()); + $('statCalories').textContent = displayNumber(data.active_kcal,Math.round); + $('statDistance').textContent = displayNumber(data.distance_m,value=>(value/1000).toFixed(2)); + $('statHr').textContent = displayNumber(data.resting_mean_bpm,value=>String(Math.round(value*10)/10)); + $('statSleep').textContent = displayNumber(data.mean_daily_asleep_minutes,value=>(value/60).toFixed(1)); + $('statSpo2').textContent = displayNumber(data.mean_spo2_pct,value=>`${Math.round(value*10)/10}%`); + $('statStress').textContent = displayNumber(data.mean_stress,value=>String(Math.round(value*10)/10)); + showToast('仪表盘已刷新','已按所选日期汇总','success'); } // Health Data Queries diff --git a/src/mi_fitness_mcp/web_assets/static/js/health-ui.js b/src/mi_fitness_mcp/web_assets/static/js/health-ui.js index 42b1e12..ef3c893 100644 --- a/src/mi_fitness_mcp/web_assets/static/js/health-ui.js +++ b/src/mi_fitness_mcp/web_assets/static/js/health-ui.js @@ -3,6 +3,8 @@ function invalidateHealthQuery(){ ++state.healthRequestId; if(state.healthController)state.healthController.abort(); state.healthController=null; + if(state.healthPageController)state.healthPageController.abort(); + state.healthPageController=null; } function clearInspector(){ ++state.inspectorRequestId; @@ -18,18 +20,14 @@ function setInspectorCollapsed(collapsed){ localStorage.setItem('mi_fitness_inspector_collapsed',String(collapsed)); } function toggleInspector(){setInspectorCollapsed(!$('inspectorPanel').hidden);} -function healthPath(kind){ - const endpoints={activity:'/api/summary',heartrate:'/api/heart-rate',sleep:'/api/sleep',workouts:'/api/workouts',body:'/api/body-measurements',spo2:'/api/spo2',stress:'/api/stress',abnormal:'/api/abnormal-heart-beat'}; - let query=getRangeQuery(endpoints[kind]); - // 接口按源日期筛选;时间戳类型向两侧各取一天,再按北京时间精确归属。 - if(kind!=='activity'&&getSd()&&getEd()){ - const shift=(date,n)=>new Date(Date.parse(date)+n*86400000).toISOString().slice(0,10); - query=endpoints[kind]+'?start_date='+shift(getSd(),-1)+'&end_date='+shift(getEd(),1); - } +function healthPath(kind,endpoint=''){ + let query=getRangeQuery('/api/views/'+kind+endpoint); + const selection=healthSelection(kind); if(kind==='heartrate')query+=getSelQuery('hrType','sample_type'); if(kind==='sleep')query+='&include_naps='+$('napsSelect').value; - if(kind==='body')query+='&latest_only=false'; - + if(kind==='body')query+='&latest_only='+selection.latestOnly; + if(kind==='stress'&&selection.pressureLevel)query+='&pressure_level='+encodeURIComponent(selection.pressureLevel); + if(selection.limit!==null)query+='&limit='+selection.limit; return query; } function healthSelection(kind){ @@ -69,12 +67,12 @@ async function loadHealthView(kind){ const isCurrent=()=>id===state.healthRequestId&&state.activeTab===kind&&getSd()===start&&getEd()===end&&healthPath(kind)===path&&JSON.stringify(healthSelection(kind))===selectionKey; const result=await api('GET',path,null,{isCurrent,signal:controller.signal}); if(!isCurrent())return; - if(!result||result.status!=='ok'||!Array.isArray(result.data)){ + if(!result||result.status!=='ok'||!result.statistics||!Array.isArray(result.points)||!result.page||!Array.isArray(result.page.rows)){ container.innerHTML='';return; } try{ - const model=HealthViews.build(kind,result.data,{start,end,...selection}); - state.healthViews[kind]={model,page:1}; + const model=HealthViews.fromSummary(kind,result,{start,end,...selection}); + state.healthViews[kind]={model,page:model.page,isCurrent,path}; container.innerHTML=HealthViews.render(model,1); // 日历晚到只更新当前查询,健康记录不等待外部日历下载。 calendarRequest.then(calendar=>{ @@ -87,17 +85,33 @@ async function loadHealthView(kind){ container.innerHTML=''; } } -function healthPage(kind,page){ - const saved=state.healthViews[kind];if(!saved)return; - saved.page=Math.min(Math.max(1,Math.ceil(saved.model.records.length/10)),Math.max(1,page)); - $('healthView-'+kind).querySelector('.health-table-page').innerHTML=HealthViews.detail(saved.model,saved.page); +async function healthPage(kind,page){ + const saved=state.healthViews[kind];if(!saved||!saved.isCurrent())return; + page=Math.min(1000000,Math.max(1,Math.min(Math.ceil(saved.model.total/10)||1,page))); + if(state.healthPageController)state.healthPageController.abort(); + const controller=new AbortController();state.healthPageController=controller; + const requestId=state.healthPageRequestId=(state.healthPageRequestId||0)+1; + const isCurrent=()=>requestId===state.healthPageRequestId&&state.healthViews[kind]===saved&&saved.isCurrent(); + // 同步期间总数可能减少;退到有效末页,不把越界页误报为无数据。 + while(isCurrent()){ + const result=await api('GET',healthPath(kind,'/records')+'&page='+page,null,{isCurrent,signal:controller.signal}); + if(!isCurrent())return; + const target=$('healthView-'+kind).querySelector('.health-table-page'); + if(!result||result.status!=='ok'||!result.page||!Array.isArray(result.page.rows)){ + target.innerHTML=''+HealthViews.detail(saved.model,saved.page);return; + } + const last=Math.max(1,Math.ceil(result.page.total/10)); + if(page>last){page=last;continue;} + saved.page=result.page.number;saved.model.page=saved.page; + saved.model.records=result.page.rows;saved.model.total=result.page.total; + target.innerHTML=HealthViews.detail(saved.model,saved.page);return; + } } function exportHealthRecords(kind){ - const saved=state.healthViews[kind];if(!saved)return; - const blob=new Blob(['\uFEFF',HealthViews.csv(saved.model)],{type:'text/csv;charset=utf-8'}); - const url=URL.createObjectURL(blob),link=document.createElement('a'); - link.href=url;link.download=`${saved.model.title}_${saved.model.range.start}_${saved.model.range.end}.csv`; - document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),1000); + const saved=state.healthViews[kind];if(!saved||!saved.isCurrent())return; + const link=document.createElement('a'); + link.href='/proxy'+healthPath(kind,'/export');link.download=''; + document.body.appendChild(link);link.click();link.remove(); } function initHealthViews(){ setInspectorCollapsed(localStorage.getItem('mi_fitness_inspector_collapsed')!=='false'); diff --git a/src/mi_fitness_mcp/web_assets/static/js/health-views.js b/src/mi_fitness_mcp/web_assets/static/js/health-views.js index 5e82cca..cb24142 100644 --- a/src/mi_fitness_mcp/web_assets/static/js/health-views.js +++ b/src/mi_fitness_mcp/web_assets/static/js/health-views.js @@ -1,4 +1,4 @@ -/* 健康数据展示:统计全部返回记录,图表和表格限制渲染数量。 */ +/* 健康数据展示:页面消费服务端摘要和当前页,build 保留为基线辅助。 */ (function(root,factory){ const api=factory(); if(typeof module==='object'&&module.exports)module.exports=api; @@ -40,7 +40,7 @@ return {off:override?override.isOffDay:weekday===0||weekday===6,provisional:!known}; } function calendarLegend(m){ - if(!m.records.length)return ''; + if(!recordTotal(m))return ''; if(m.stride>1)return '
多日汇总不标注单日工作属性;缩小到 120 天以内可查看每日标记。
'; const missing=[...new Set(m.points.filter(p=>calendarDay(p.date,m.range.calendar).provisional).map(p=>p.date.slice(0,4)))]; return `
工作日非工作日
${missing.length?esc(missing.join('、'))+' 年调休安排未更新,暂按周一至周五/周末区分。':'按中国大陆节假日及调休安排标注。'}
`; @@ -125,6 +125,43 @@ } return {kind,...c,cards,legend,points,records:rows,range:{...range},coverage,stride,labels,cell,references,referenceNote}; } + function fromSummary(kind,payload,range){ + const c=config[kind];if(!c)throw new Error('不支持的查询类型'); + const s=payload.statistics,coverage=payload.coverage,latest=s.latest||{}; + const distance=finite(s.total_distance_m)?s.total_distance_m/1000:null; + let cards,legend=[c.trend]; + if(kind==='activity')cards=[card('期间总步数',s.primary_sum,'步'),card('活动消耗',s.total_active_kcal,'kcal'),card('累计距离',distance,'km'),card('记录覆盖',coverage,'天')]; + else if(kind==='sleep'){ + cards=[card('日均实睡',s.mean_daily_asleep_minutes,'','仅计算有记录的日期,含筛选后小睡',minutes),card('记录覆盖',coverage,'天'),card('日均深睡',s.mean_daily_deep_minutes,'','仅计算有深睡记录的日期',minutes),card('小睡记录',s.nap_count,'次')]; + legend=['深睡','浅睡','REM','未分期']; + }else if(kind==='heartrate'){ + cards=[card('被动心率均值',s.passive_mean_bpm,'bpm'),card('静息心率均值',s.resting_mean_bpm,'bpm'),card('最低心率',s.primary_min,'bpm'),card('最高心率',s.primary_max,'bpm')]; + legend=['被动采样','静息','其他类型']; + }else if(kind==='workouts')cards=[card('运动次数',s.count,'次'),card('运动时长',s.primary_sum,'分钟'),card('累计距离',distance,'km'),card('运动热量',s.total_calories_kcal,'kcal')]; + else if(kind==='body')cards=[card('最近体重',latest.weight_kg,'kg'),card('最近 BMI',latest.bmi),card('最近体脂率',latest.body_fat_pct,'%'),card('测量记录',s.count,'次',s.count<2?'不足两次测量,无法判断体重变化趋势':'')]; + else if(kind==='abnormal')cards=[card('已保存事件',s.count,'次'),card('累计时长',s.primary_sum,'秒'),card('有记录日期',coverage,'天'),card('最近事件',null,'','',()=>s.count?stamp(latest.start_at):'未保存')]; + else cards=[card(`平均${kind==='spo2'?'血氧':'压力'}`,s.primary_mean,c.unit),card('最低值',s.primary_min,c.unit),card('最高值',s.primary_max,c.unit),card('记录覆盖',coverage,'天')]; + const references=[]; + const reference=(key,label,value,color)=>{if(finite(value))references.push({key,label,value,color});}; + let referenceNote=''; + if(kind==='activity'){ + reference('average','日均',s.mean_daily_steps,'#d97706');reference('fixed','参考',7000,'#22a06b');referenceNote='平均步数按有步数记录的日期计算。'; + }else if(kind==='heartrate'){ + reference('average','总平均',s.primary_mean,'#d97706');reference('resting-average','静息平均',s.resting_mean_bpm,'#9467cf');reference('fixed','参考',170,'#db5267');referenceNote='总平均包含全部筛选样本,静息平均仅计算静息记录。'; + }else if(kind==='sleep'){ + reference('average','日均实睡',finite(s.mean_daily_asleep_minutes)?s.mean_daily_asleep_minutes/60:null,'#d97706');reference('fixed','参考',8,'#22a06b');referenceNote='平均线按有实睡记录的醒来日计算,包含筛选后的小睡。'; + }else if(kind==='body'){ + reference('average','平均',s.primary_mean,'#d97706');reference('fixed','参考',65,'#22a06b');referenceNote='平均线使用全部筛选体重测量记录;柱状图保留每日最近一次测量。'; + }else if(kind==='spo2'){ + reference('average','平均',s.primary_mean,'#d97706');referenceNote='平均线按全部筛选血氧样本计算。'; + }else if(kind==='stress'){ + reference('average','平均',s.primary_mean,'#d97706');pressureLevels.slice(0,3).forEach(level=>reference('stress-'+level.max,level.label+'上界',level.max,level.color));referenceNote='平均线按全部筛选压力样本计算;0 或超出 1–100 的数值保留为未分类。'; + } + const labels=Object.fromEntries(c.columns.map(([label,key])=>[key,label])); + const cell=(r,key)=>key==='_pressureBand'?pressureBand(r.stress_score).label:r[key]; + return {kind,...c,cards,legend,points:payload.points,records:payload.page.rows,total:payload.page.total,page:payload.page.number,range:{...range},coverage,stride:payload.stride,labels,cell,references,referenceNote}; + } + const recordTotal=m=>m.total??m.records.length; function referenceLayout(m){ const stacked=m.kind==='sleep',min=({body:40,spo2:80,heartrate:40})[m.kind]??0;let dataMax=min; for(const point of m.points)dataMax=Math.max(dataMax,stacked?point.values.reduce((s,v)=>s+(v??0),0):Math.max(...point.values.map(v=>v??0))); @@ -138,7 +175,7 @@ return {min,max,inside,outside:m.references.filter(r=>r.value>max||r.value${outside.map(r=>`${esc(r.label)} ${esc(num(r.value))}${esc(m.unit)} ${r.value`).join('')}超出当前纵轴`; } @@ -146,7 +183,7 @@ return m.kind==='stress'?`
${pressureLevels.map(level=>`${level.label} ${level.min}–${level.max}`).join('')}
`:''; } function chart(m){ - if(!m.records.length)return '
NAS 在所选期间未保存此类记录。未保存不表示正常或没有发生。
'; + if(!recordTotal(m))return '
NAS 在所选期间未保存此类记录。未保存不表示正常或没有发生。
'; const height=158, step=880/m.points.length, stacked=m.kind==='sleep'; const layout=referenceLayout(m),max=layout.max,min=layout.min;let content='',below=false; for(let i=0;i<=4;i++){const y=14+height-i*height/4;content+=`${esc(num(min+(max-min)*i/4))}`;} @@ -176,9 +213,9 @@ return `${content}${calendarLegend(m)}${below?'
↓ 表示低于纵轴下限;明细和统计保留原值。
':''}`; } function detail(m,page=1){ - const pages=Math.max(1,Math.ceil(m.records.length/10));page=Math.min(pages,Math.max(1,page)); - const slice=m.records.slice((page-1)*10,page*10); - return `
${m.columns.map(([label])=>``).join('')}${slice.map(r=>`${m.columns.map(([,key,format])=>{const value=m.cell(r,key);return ``;}).join('')}`).join('')}
${esc(label)}
${esc(format?format(value):finite(value)?num(value,2):value)}
${!slice.length?'
NAS 在所选期间未保存此类记录
':''}
共 ${m.records.length.toLocaleString('zh-CN')} 条 · 每页 10 条
${page} / ${pages}
`; + const total=recordTotal(m),pages=Math.max(1,Math.ceil(total/10));page=Math.min(pages,Math.max(1,m.total===undefined?page:m.page)); + const slice=m.total===undefined?m.records.slice((page-1)*10,page*10):m.records; + return `
${m.columns.map(([label])=>``).join('')}${slice.map(r=>`${m.columns.map(([,key,format])=>{const value=m.cell(r,key);return ``;}).join('')}`).join('')}
${esc(label)}
${esc(format?format(value):finite(value)?num(value,2):value)}
${!total?'
NAS 在所选期间未保存此类记录
':''}
共 ${total.toLocaleString('zh-CN')} 条 · 每页 10 条
${page} / ${pages}
`; } function render(m,page=1){ const title=`${m.range.start} 至 ${m.range.end} · 北京时间`; @@ -192,5 +229,5 @@ const header=k=>m.kind==='stress'&&k==='level'?'源等级(原值)':k==='_pressureBand'?'压力等级(当前分界)':m.labels[k]||k; return [keys.map(k=>quote(header(k))).join(','),...m.records.map(r=>keys.map(k=>quote(k==='_pressureBand'?m.cell(r,k):r[k])).join(','))].join('\r\n'); } - return {kinds,build,render,detail,csv,day,stamp,pressureBand,referenceLayout,calendarDay}; + return {kinds,build,fromSummary,render,detail,csv,day,stamp,pressureBand,referenceLayout,calendarDay}; }); diff --git a/src/mi_fitness_mcp/web_assets/templates/index.html b/src/mi_fitness_mcp/web_assets/templates/index.html index d285dfc..9a416e2 100644 --- a/src/mi_fitness_mcp/web_assets/templates/index.html +++ b/src/mi_fitness_mcp/web_assets/templates/index.html @@ -641,9 +641,9 @@
- - - + + +