diff --git a/dashboard/js/core.js b/dashboard/js/core.js index c85e2df..8e38ac2 100644 --- a/dashboard/js/core.js +++ b/dashboard/js/core.js @@ -24,7 +24,7 @@ const DELIVERY_EVIDENCE_FILTERS=['all','comparable','spend_only','git_only','una let deliveryRange=localStorage.getItem('tm_delivery_range')||'30'; if(!DELIVERY_RANGES.includes(deliveryRange)){deliveryRange='30';localStorage.setItem('tm_delivery_range',deliveryRange);} let deliveryProject=localStorage.getItem('tm_delivery_project')||''; -let deliveryPayload=null,deliveryRequest=0,deliverySelectedDay='',deliveryLastLoadedAt=0; +let deliveryPayload=null,deliveryRequest=0,deliveryLoading=false,deliverySelectedDay='',deliveryLastLoadedAt=0; let deliverySort={key:'covered_cost',direction:'desc'}; let deliveryModelSort={key:'covered_cost',direction:'desc'}; let deliveryEvidenceFilter='all'; @@ -130,7 +130,7 @@ const countWord=(n,one,many=one+'s')=>Number(n)===1?one:many; const speedFmt=n=>{n=Number(n)||0;if(n<=0)return'--';if(n>=100)return n.toFixed(0);if(n>=10)return n.toFixed(1);return n.toFixed(2);}; const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); // Work page state is declared early: the initial hash route can run before later definitions. -let WORK=null,workRequest=0,workLastLoadedAt=0,workMonths='6',workRuntime='',workProject=''; +let WORK=null,workRequest=0,workLoading=false,workLastLoadedAt=0,workMonths='6',workRuntime='',workProject=''; let workSupported=null; // Declared before the first route runs: a deep link schedules its scroll during startup. let pendingSettingsScroll=null; diff --git a/dashboard/js/live.js b/dashboard/js/live.js index 32b5c54..b57b527 100644 --- a/dashboard/js/live.js +++ b/dashboard/js/live.js @@ -143,8 +143,8 @@ function applyLiveState(state){ if($('view-capabilities').classList.contains('on'))renderCapabilities(LATEST.xsession?.capabilities); if($('view-efficiency').classList.contains('on'))renderActiveEfficiency(); if(['view-daily','view-efficiency','view-git','view-work'].some(id=>$(id).classList.contains('on'))&&Date.now()-goalsLoadedAt>30000)loadGoals(true); - if($('view-git').classList.contains('on')&&Date.now()-deliveryLastLoadedAt>60000)loadDelivery(); - if($('view-work').classList.contains('on')&&Date.now()-workLastLoadedAt>15000)loadWork(); + if($('view-git').classList.contains('on')&&!deliveryLoading&&Date.now()-deliveryLastLoadedAt>60000)loadDelivery(); + if($('view-work').classList.contains('on')&&!workLoading&&Date.now()-workLastLoadedAt>15000)loadWork(); if($('view-settings').classList.contains('on'))renderSettings(LATEST.xsession); if($('view-learn').classList.contains('on'))renderLearn(LATEST); if($('welcome-dialog').open)renderWelcome(LATEST); diff --git a/dashboard/js/work-insights.js b/dashboard/js/work-insights.js index 4cc6295..932a50a 100644 --- a/dashboard/js/work-insights.js +++ b/dashboard/js/work-insights.js @@ -215,6 +215,7 @@ function renderWork(payload){ } async function loadWork(){ const request=++workRequest,query=new URLSearchParams({months:workMonths}); + workLoading=true; if(workRuntime)query.set('runtime',workRuntime);if(workProject)query.set('project',workProject); try{ const response=await fetch(`/work?${query}`,{cache:'no-store'}); @@ -228,7 +229,7 @@ async function loadWork(){ if(request!==workRequest)return; $('w-state').dataset.state='storage_error';$('w-state').textContent='Error'; $('w-status-title').textContent='Work insights are unavailable';$('w-status-detail').textContent='Token Meter will try again shortly.'; - } + }finally{if(request===workRequest)workLoading=false;} } async function postWorkAction(path,payload){ const token=agentActionToken(); @@ -354,6 +355,7 @@ async function applyWorkSessionFilter(params){ } async function loadDelivery(){ const request=++deliveryRequest,query=new URLSearchParams({range:deliveryRange});if(deliveryProject)query.set('project',deliveryProject); + deliveryLoading=true; try{ const response=await fetch(`/git-delivery?${query}`,{cache:'no-store'});if(!response.ok)throw new Error('Git data unavailable'); const payload=await response.json();if(request!==deliveryRequest)return; @@ -370,7 +372,7 @@ async function loadDelivery(){ $('d-shape').innerHTML='
Git evidence unavailable.
'; $('d-evidence-spend').textContent='--';setDeliveryEvidenceRatio('Git evidence unavailable.','Git evidence unavailable. Ratios require overlapping spend and Git evidence.');$('d-evidence-freshness').textContent='Git scan unavailable.'; ['comparable','spend','git','unavailable'].forEach(key=>{$(`d-evidence-count-${key}`).textContent='--';}); - } + }finally{if(request===deliveryRequest)deliveryLoading=false;} } $('d-coverage').addEventListener('click',()=>setDeliveryEvidencePanel($('d-evidence-panel').hidden)); $('d-evidence-close').addEventListener('click',()=>setDeliveryEvidencePanel(false)); diff --git a/tests/test_dashboard_filter_refresh.py b/tests/test_dashboard_filter_refresh.py new file mode 100644 index 0000000..892aa15 --- /dev/null +++ b/tests/test_dashboard_filter_refresh.py @@ -0,0 +1,38 @@ +import json +import shutil +import subprocess +import unittest +from pathlib import Path + + +@unittest.skipUnless(shutil.which("node"), "Node.js is required") +class DashboardFilterRefreshTests(unittest.TestCase): + def test_slow_filter_requests_survive_live_refresh_and_new_selections_win(self): + root = Path(__file__).resolve().parents[1] + script = r""" +const fs=require('fs'),root=process.argv[1]; +const source=fs.readFileSync(root+'/dashboard/js/work-insights.js','utf8'); +const live=fs.readFileSync(root+'/dashboard/js/live.js','utf8'); +const part=(s,a,b)=>s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a))); +let workRequest=0,workLastLoadedAt=0,workMonths='6',workRuntime='',workProject=''; +let deliveryRequest=0,deliveryLastLoadedAt=0,deliveryRange='30',deliveryProject='',deliveryLoadRetries=0,deliveryRetryTimer=0; +let workLoading=false,deliveryLoading=false,active='work',renders=[],pending=[]; +let RUNTIME_CATALOG={},defaultSessionBudget=0,LATEST=null,pinned=null,currentPanel='',sessionScope='',goalsLoadedAt=Date.now(),workSupported=true; +const no=()=>{},adoptFreshDiscounts=no,applyWorkSupport=no,renderMenubarSettings=no,renderCurrentSessions=no,maybeAutoShowWelcome=no; +const nodes={};function $(id){return nodes[id]||(nodes[id]={classList:{contains:()=>id==='view-'+active},open:false});} +function fetch(url){return new Promise(resolve=>pending.push({url,resolve}));} +function renderWork(p){renders.push('work:'+p.value);} function renderDelivery(p){renders.push('git:'+p.value);} +function answer(index,value){pending[index].resolve({ok:true,json:async()=>({ok:true,value,coverage:{last_checked:true}})});} +const setTimeout=no,clearTimeout=no; +eval(part(source,'async function loadWork(){','async function postWorkAction(')+part(source,'async function loadDelivery(){',"$('d-coverage').addEventListener")+part(live,'function applyLiveState(state){','async function refreshLiveState(){')+` +(async()=>{ + active='work';let old=loadWork();workMonths='1d';let chosen=loadWork();applyLiveState({});applyLiveState({}); + let workCount=pending.length;answer(0,'old');answer(1,'chosen');await Promise.all([old,chosen]); + active='git';let before=pending.length;let oldGit=loadDelivery();deliveryRange='7';let chosenGit=loadDelivery();applyLiveState({});applyLiveState({}); + let gitCount=pending.length-before;answer(before,'old');answer(before+1,'chosen');await Promise.all([oldGit,chosenGit]); + console.log(JSON.stringify({workCount,gitCount,renders})); +})().catch(e=>{console.error(e);process.exitCode=1;});`); +""" + result = subprocess.run(["node", "-e", script, str(root)], text=True, capture_output=True, check=True) + self.assertEqual(json.loads(result.stdout), {"workCount": 2, "gitCount": 2, + "renders": ["work:chosen", "git:chosen"]})