Compare commits

..

4 Commits

Author SHA1 Message Date
6baf908cf9 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.
2026-09-04 14:44:16 -07:00
50424acad1 Fix Portainer deploy: use variable substitution instead of env_file 2026-09-04 13:30:21 -07:00
a0eec98839 Rename default branch to main; update docs 2026-09-04 13:18:17 -07:00
26419cb94c Add Portainer deployment guide; update CHANGELOG 2026-09-04 13:11:38 -07:00
6 changed files with 160 additions and 4 deletions

View File

@@ -2,6 +2,18 @@
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
- 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`.
- Changed: renamed the default branch from `master` to `main`, both locally and in `PORTAINER_DEPLOY.md`'s repository reference. The old name was just `git init`'s local fallback default, not a deliberate choice.
- Added: `PORTAINER_DEPLOY.md`, a step-by-step guide for deploying this as its own stack in Portainer, pulled straight from the Gitea repo, including how to set environment variables through Portainer's UI since `.env` is not committed.
- Changed: swapped `ANTHROPIC_API_KEY` from a personal key to a company-owned key.
- Milestone: pushed the repo to Gitea at `https://primegit.primecontrols-dev.com/m.mabrey/sde-meeting-toolkit`.
## 2026-08-24
- Changed: enlarged the Breadcrumbs working area. The selected breadcrumb's editor card now has more padding, a taller minimum height, and taller text fields, so it uses more of the page instead of sitting as a small box.

65
PORTAINER_DEPLOY.md Normal file
View File

@@ -0,0 +1,65 @@
# Deploy to Portainer
This doc sets up the toolkit as its own stack in Portainer, pulled directly from Gitea. It assumes the repo is public or internal-open on your Gitea instance, so Portainer needs no Git credentials to pull it.
Repo: `https://primegit.primecontrols-dev.com/m.mabrey/sde-meeting-toolkit.git`
## Step 1: Add the stack
1. Open Portainer.
2. Go to **Stacks**.
3. Click **Add stack**.
4. Name it `sde-meeting-toolkit`.
5. Set **Build method** to **Repository**.
6. Set **Repository URL** to `https://primegit.primecontrols-dev.com/m.mabrey/sde-meeting-toolkit.git`.
7. Set **Repository reference** to `refs/heads/main`.
8. Set **Compose path** to `docker-compose.yml`. This is the default path; the file sits at the root of the repo.
9. Leave **Authentication** off. The repo does not need credentials to pull.
## Step 2: Set environment variables
`.env` is not in the repo on purpose; it holds secrets and is listed in `.gitignore`. `docker-compose.yml` reads each setting through `${VARIABLE_NAME}` substitution instead of a literal `env_file`, so it works whether the value comes from a real `.env` next to the file (local and Docker Desktop use) or from Portainer's own stack settings (Git-pull use, where no `.env` file exists).
Portainer's stack editor has its own **Environment variables** section, below the repository settings. Add each of these as a name and value pair.
| Name | Value |
|---|---|
| `ANTHROPIC_API_KEY` | The company API key. |
| `APP_USERS` | Comma-separated `user:password` pairs, for example `alice:pass1,bob:pass2`. Leave blank for no login. |
| `APP_USERNAME` | Leave blank unless you use the legacy single-login mode instead of `APP_USERS`. |
| `APP_PASSWORD` | Leave blank unless you use the legacy single-login mode. |
| `RATE_LIMIT_MAX` | `20` |
| `RATE_LIMIT_WINDOW_MS` | `300000` |
| `TOKEN_LIMIT_PER_USER` | `0` (off). Set a positive number only if you decide to turn the quota back on. |
| `TOKEN_USAGE_FILE` | `./data/token-usage.json` |
| `MOCK_AI` | `false` |
Portainer passes these to Compose as substitution values for the `${VARIABLE_NAME}` references in `docker-compose.yml`. No `.env` file needs to exist on the host for this to work.
## Step 3: Deploy
1. Click **Deploy the stack**.
2. Wait for the build to finish. The image is small; the app has no external dependencies to install.
## Step 4: Verify
1. Go to **Containers**, find `sde-meeting-toolkit`, and open **Logs**.
2. Confirm the log reports:
- `ANTHROPIC_API_KEY loaded: AI draft button is live.`
- Either `Login required: N named user(s) configured.` or `No login required: ...`, matching what you set in `APP_USERS`.
- `No per-user token quota: TOKEN_LIMIT_PER_USER is unset or 0 (unlimited, feature on the back burner).`
3. Open the container's mapped address in a browser (port `5173` by default, per `docker-compose.yml`). Confirm the toolkit's landing page loads.
4. If you set `APP_USERS`, confirm the browser asks for a login before the page loads.
5. Try the AI draft button on the Field Problem Workshop tool and confirm it returns a real draft.
## Redeploying after a code change
1. Push the change to the Gitea repo (`git push`) from your machine, same as always.
2. In Portainer, open the `sde-meeting-toolkit` stack.
3. Click **Pull and redeploy** (or the equivalent update action in your Portainer version).
Setting up a Gitea webhook to trigger this automatically is possible later, but is not set up yet. For now, redeploy manually after each push.
## The data folder
`docker-compose.yml` mounts `./data:/app/data` so the per-user token quota file survives a restart, if that feature is ever turned back on. Portainer creates this folder under the stack's own directory on the host. With `TOKEN_LIMIT_PER_USER=0`, nothing is written there today; the mount is harmless and already in place for when it's needed.

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.
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
Each tool has a Reset control.

View File

@@ -12,8 +12,16 @@ services:
restart: unless-stopped
ports:
- "5173:5173"
env_file:
- .env
environment:
- MOCK_AI=${MOCK_AI:-false}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- APP_USERS=${APP_USERS:-}
- APP_USERNAME=${APP_USERNAME:-}
- APP_PASSWORD=${APP_PASSWORD:-}
- RATE_LIMIT_MAX=${RATE_LIMIT_MAX:-20}
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-300000}
- TOKEN_LIMIT_PER_USER=${TOKEN_LIMIT_PER_USER:-0}
- TOKEN_USAGE_FILE=${TOKEN_USAGE_FILE:-./data/token-usage.json}
volumes:
# Persists the per-user token quota file across restarts and rebuilds.
# Without this, everyone's daily usage silently resets on every deploy.

View File

@@ -24,7 +24,7 @@
header .brand span{color:#c6c6c6; font-weight:400;}
header .spacer{flex:1;}
.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.primary{background:var(--interactive); border-color:var(--interactive);}
.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="spacer"></div>
<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="btnSave">Save JSON</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 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=>{
b.addEventListener('click', ()=>{
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="spacer"></span>
${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">
<button class="dot-btn" data-cdot="${c.id}" data-d="-1"></button>
<span class="dot-n">${c.dots}</span>
@@ -508,6 +530,15 @@ document.getElementById('panel-map').addEventListener('click', e=>{
}
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){
const c = clusterById(t.dataset.cdel);
if(c){

View File

@@ -36,7 +36,7 @@
header .meta{font-size:13px; color:#c6c6c6;}
.hdr-btn{
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.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="spacer"></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 primary" id="btnExport">Export meeting summary</button>
</header>
@@ -783,6 +784,33 @@ function confirmModal(opts, onConfirm){
}
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();
</script>
</body>