Connect the two tools: cross-nav links and Send to Scope Lock handoff

- Add a header link on each tool pointing to the other.
- Add a Send to Scope Lock button on Workshop clusters that carry a
  candidate solution tag. Writes the item to a shared localStorage
  queue (sde_scope_handoff_v1).
- Scope Lock drains that queue on load and adds each item to the
  Scope Boundary Board as unsorted, deduping by a deterministic id so
  a repeat send never creates a duplicate.
- No backend needed: both tools already share the same browser origin.
- Verified with a jsdom test covering send, drain, reload, and
  re-send-after-reset.
- Update README and CHANGELOG.
This commit is contained in:
2026-09-04 14:44:16 -07:00
parent 50424acad1
commit 6baf908cf9
4 changed files with 77 additions and 2 deletions

View File

@@ -2,6 +2,10 @@
All notable changes to this project are logged here, newest first. This file starts from the point the toolkit was handed off and set up in this repo. All notable changes to this project are logged here, newest first. This file starts from the point the toolkit was handed off and set up in this repo.
## 2026-09-04 (2)
- Added: the two tools now work together. Each tool's header has a link to the other tool. On the Field Problem Workshop, a cluster tagged with a candidate solution (for example "Tracking MVP (pilot)") gets a "Send to Scope Lock" button. Clicking it adds that cluster as a real, unsorted item on the Scope Lock Meeting Suite's Scope Boundary Board. Both tools already share the same browser origin, so this uses a small shared `localStorage` queue; no backend or database was needed. The item lands on Scope Lock's board the next time that tool is opened or refreshed, and re-sending the same cluster will not create a duplicate. Verified with a jsdom test that simulates both tools sharing one browser's storage.
## 2026-09-04 ## 2026-09-04
- Fixed: Portainer stack deploy failed with `env file /data/compose/44/.env not found`. Cause: `docker-compose.yml` used `env_file: - .env`, which requires a real file on disk; Portainer's UI-entered environment variables satisfy `${VAR}` substitution in the compose file but do not create that file. Changed `docker-compose.yml` to reference each setting as `${VAR:-default}` under `environment:` instead, which works both for a real local `.env` (auto-loaded by Compose for substitution) and for values entered directly in Portainer. Corrected the wrong explanation of this in `PORTAINER_DEPLOY.md`. - Fixed: Portainer stack deploy failed with `env file /data/compose/44/.env not found`. Cause: `docker-compose.yml` used `env_file: - .env`, which requires a real file on disk; Portainer's UI-entered environment variables satisfy `${VAR}` substitution in the compose file but do not create that file. Changed `docker-compose.yml` to reference each setting as `${VAR:-default}` under `environment:` instead, which works both for a real local `.env` (auto-loaded by Compose for substitution) and for values entered directly in Portainer. Corrected the wrong explanation of this in `PORTAINER_DEPLOY.md`.

View File

@@ -99,6 +99,18 @@ The two tools store data in different ways.
1. Click Export in the tool to create a durable record. 1. Click Export in the tool to create a durable record.
2. Save the exported file to your computer or to a shared drive. 2. Save the exported file to your computer or to a shared drive.
## How the tools work together
Each tool's header has a link to the other tool.
The Field Problem Workshop can send a cluster to the Scope Lock Meeting Suite.
1. Tag a cluster with a candidate solution, for example "Tracking MVP (pilot)".
2. Click "Send to Scope Lock" on that cluster.
3. Open the Scope Lock Meeting Suite. The cluster appears as a new, unsorted item on the Scope Boundary Board.
The send action stores the item in the browser's local storage. Both tools must run from the same server address for this to work. Sending the same cluster twice does not create a duplicate item.
## Reset a tool ## Reset a tool
Each tool has a Reset control. Each tool has a Reset control.

View File

@@ -24,7 +24,7 @@
header .brand span{color:#c6c6c6; font-weight:400;} header .brand span{color:#c6c6c6; font-weight:400;}
header .spacer{flex:1;} header .spacer{flex:1;}
.persist-note{font-size:12px; color:#c6c6c6;} .persist-note{font-size:12px; color:#c6c6c6;}
.hdr-btn{background:transparent; color:#fff; border:1px solid #6f6f6f; padding:7px 13px; font-size:13.5px;} .hdr-btn{background:transparent; color:#fff; border:1px solid #6f6f6f; padding:7px 13px; font-size:13.5px; text-decoration:none; display:inline-block;}
.hdr-btn:hover{background:#353535;} .hdr-btn:hover{background:#353535;}
.hdr-btn.primary{background:var(--interactive); border-color:var(--interactive);} .hdr-btn.primary{background:var(--interactive); border-color:var(--interactive);}
.hdr-btn.primary:hover{background:var(--interactive-hover);} .hdr-btn.primary:hover{background:var(--interactive-hover);}
@@ -181,6 +181,7 @@
<div class="brand"><strong>Project SDE</strong> <span>| Field Problem Workshop v5</span></div> <div class="brand"><strong>Project SDE</strong> <span>| Field Problem Workshop v5</span></div>
<div class="spacer"></div> <div class="spacer"></div>
<span class="persist-note">State lives in this session. Save JSON before closing.</span> <span class="persist-note">State lives in this session. Save JSON before closing.</span>
<a class="hdr-btn" href="/tools/scope-lock-meeting-suite.html">Scope Lock Meeting Suite &rarr;</a>
<button class="hdr-btn" id="btnLoad">Load JSON</button> <button class="hdr-btn" id="btnLoad">Load JSON</button>
<button class="hdr-btn" id="btnSave">Save JSON</button> <button class="hdr-btn" id="btnSave">Save JSON</button>
<button class="hdr-btn" id="btnReset">Reset</button> <button class="hdr-btn" id="btnReset">Reset</button>
@@ -388,6 +389,26 @@ function clusterById(id){ return S.clusters.find(c=>c.id===id); }
function membersOf(cid){ return S.problems.filter(p=>p.cluster===cid); } function membersOf(cid){ return S.problems.filter(p=>p.cluster===cid); }
function activeMembers(cid){ return S.problems.filter(p=>p.cluster===cid && p.type==='Problem'); } function activeMembers(cid){ return S.problems.filter(p=>p.cluster===cid && p.type==='Problem'); }
/* ================= HANDOFF TO SCOPE LOCK ================= */
// Shared localStorage queue that the Scope Lock Meeting Suite reads on load.
// Both tools are served from the same origin, so this works with no backend.
const SCOPE_HANDOFF_KEY = 'sde_scope_handoff_v1';
function sendToScopeLock(c){
let queue = [];
try{
const raw = localStorage.getItem(SCOPE_HANDOFF_KEY);
if(raw) queue = JSON.parse(raw);
if(!Array.isArray(queue)) queue = [];
}catch(e){ queue = []; }
queue.push({
id: 'ws_' + c.id,
t: c.ws,
note: c.root || c.name || '',
sentAt: Date.now()
});
try{ localStorage.setItem(SCOPE_HANDOFF_KEY, JSON.stringify(queue)); }catch(e){}
}
document.querySelectorAll('nav button').forEach(b=>{ document.querySelectorAll('nav button').forEach(b=>{
b.addEventListener('click', ()=>{ b.addEventListener('click', ()=>{
document.querySelectorAll('nav button').forEach(x=>x.classList.remove('active')); document.querySelectorAll('nav button').forEach(x=>x.classList.remove('active'));
@@ -474,6 +495,7 @@ function renderMap(){
<span class="cl-count">${mem.length} issue${mem.length===1?'':'s'}${c.ws?' · '+esc(c.ws):''}</span> <span class="cl-count">${mem.length} issue${mem.length===1?'':'s'}${c.ws?' · '+esc(c.ws):''}</span>
<span class="spacer"></span> <span class="spacer"></span>
${c.promoted?'<span class="promoted-flag">✓ In Breadcrumbs</span>':`<button class="btn small ghost" data-cprom="${c.id}">Promote →</button>`} ${c.promoted?'<span class="promoted-flag">✓ In Breadcrumbs</span>':`<button class="btn small ghost" data-cprom="${c.id}">Promote →</button>`}
${c.ws ? (c.sentToScope ? '<span class="promoted-flag">✓ Sent to Scope Lock</span>' : `<button class="btn small ghost" data-cscope="${c.id}">Send to Scope Lock →</button>`) : ''}
<div class="dot-ctl"> <div class="dot-ctl">
<button class="dot-btn" data-cdot="${c.id}" data-d="-1"></button> <button class="dot-btn" data-cdot="${c.id}" data-d="-1"></button>
<span class="dot-n">${c.dots}</span> <span class="dot-n">${c.dots}</span>
@@ -508,6 +530,15 @@ document.getElementById('panel-map').addEventListener('click', e=>{
} }
return; return;
} }
if(t.dataset.cscope){
const c = clusterById(t.dataset.cscope);
if(c && c.ws && !c.sentToScope){
sendToScopeLock(c);
c.sentToScope = true;
save(); renderMap(); toast('Sent "' + c.ws + '" to Scope Lock');
}
return;
}
if(t.dataset.cdel){ if(t.dataset.cdel){
const c = clusterById(t.dataset.cdel); const c = clusterById(t.dataset.cdel);
if(c){ if(c){

View File

@@ -36,7 +36,7 @@
header .meta{font-size:13px; color:#c6c6c6;} header .meta{font-size:13px; color:#c6c6c6;}
.hdr-btn{ .hdr-btn{
background:transparent; color:#fff; border:1px solid #6f6f6f; background:transparent; color:#fff; border:1px solid #6f6f6f;
padding:7px 14px; font-size:14px; padding:7px 14px; font-size:14px; text-decoration:none; display:inline-block;
} }
.hdr-btn:hover{background:#353535;} .hdr-btn:hover{background:#353535;}
.hdr-btn.primary{background:var(--interactive); border-color:var(--interactive);} .hdr-btn.primary{background:var(--interactive); border-color:var(--interactive);}
@@ -231,6 +231,7 @@
<div class="brand"><strong>Project SDE</strong> <span>| Scope Lock Meeting Suite</span></div> <div class="brand"><strong>Project SDE</strong> <span>| Scope Lock Meeting Suite</span></div>
<div class="spacer"></div> <div class="spacer"></div>
<div class="meta">Micron Pilot | Innovation Team</div> <div class="meta">Micron Pilot | Innovation Team</div>
<a class="hdr-btn" href="/tools/field-problem-workshop.html">Field Problem Workshop &rarr;</a>
<button class="hdr-btn" id="btnReset">Reset all</button> <button class="hdr-btn" id="btnReset">Reset all</button>
<button class="hdr-btn primary" id="btnExport">Export meeting summary</button> <button class="hdr-btn primary" id="btnExport">Export meeting summary</button>
</header> </header>
@@ -783,6 +784,33 @@ function confirmModal(opts, onConfirm){
} }
function renderAll(){ renderScope(); renderRank(); renderRegistry(); renderReady(); } function renderAll(){ renderScope(); renderRank(); renderRegistry(); renderReady(); }
/* ================= HANDOFF FROM FIELD PROBLEM WORKSHOP ================= */
// Shared localStorage queue the Workshop's "Send to Scope Lock" button writes to.
// Both tools are served from the same origin, so this works with no backend.
const SCOPE_HANDOFF_KEY = 'sde_scope_handoff_v1';
function drainScopeHandoff(){
let queue = [];
try{
const raw = localStorage.getItem(SCOPE_HANDOFF_KEY);
if(raw) queue = JSON.parse(raw);
if(!Array.isArray(queue)) queue = [];
}catch(e){ queue = []; }
if(!queue.length) return;
let added = 0;
queue.forEach(item=>{
if(!item || !item.id || !item.t) return;
if(S.scope.some(s=>s.id===item.id)) return; // already added on a previous load
S.scope.push({id:item.id, t:item.t, b:'', lock:false});
added++;
});
try{ localStorage.removeItem(SCOPE_HANDOFF_KEY); }catch(e){}
if(added){
save();
toast(added===1 ? 'Added 1 item from Field Problem Workshop' : 'Added ' + added + ' items from Field Problem Workshop');
}
}
drainScopeHandoff();
renderAll(); renderAll();
</script> </script>
</body> </body>