Files
sde-meeting-toolkit/tools/field-problem-workshop.html
Matt Mabrey 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

977 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project SDE | Field Problem Workshop v5</title>
<style>
:root{
--bg:#f4f4f4; --layer:#ffffff; --layer-hover:#e8e8e8;
--border:#e0e0e0; --border-strong:#8d8d8d;
--text:#161616; --text-secondary:#525252; --text-helper:#6f6f6f;
--interactive:#0f62fe; --interactive-hover:#0353e9;
--danger:#da1e28; --success:#24a148; --warning-bg:#fdf6dd; --warning:#8e6a00;
--purple:#8a3ffc; --teal:#007d79; --focus:#0f62fe;
}
*{box-sizing:border-box; margin:0; padding:0;}
body{font-family:'IBM Plex Sans','Segoe UI',system-ui,-apple-system,sans-serif; background:var(--bg); color:var(--text); font-size:16px; line-height:1.4;}
button{font-family:inherit; cursor:pointer;}
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible{outline:2px solid var(--focus); outline-offset:1px;}
header{background:var(--text); color:#fff; padding:0 24px; display:flex; align-items:center; gap:12px; height:56px;}
header .brand{font-size:15px;}
header .brand strong{font-weight:600;}
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; 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);}
nav{background:var(--layer); border-bottom:1px solid var(--border); padding:0 24px; display:flex;}
nav button{background:none; border:none; border-bottom:3px solid transparent; padding:14px 18px; font-size:15px; color:var(--text-secondary);}
nav button:hover{color:var(--text); background:var(--layer-hover);}
nav button.active{border-bottom-color:var(--interactive); color:var(--text); font-weight:600;}
nav button .count{display:inline-block; margin-left:7px; background:var(--border); border-radius:12px; padding:1px 8px; font-size:12px; color:var(--text);}
nav .flow{align-self:center; color:var(--border-strong); font-size:13px; padding:0 2px;}
main{padding:24px;}
.panel{display:none;}
.panel.active{display:block;}
.panel-head{margin-bottom:18px;}
.panel-head h1{font-size:25px; font-weight:400;}
.panel-head p{color:var(--text-secondary); font-size:14.5px; margin-top:4px; max-width:980px;}
.btn{background:var(--interactive); color:#fff; border:none; padding:10px 18px; font-size:14px;}
.btn:hover{background:var(--interactive-hover);}
.btn:disabled{background:var(--border); color:var(--text-helper); cursor:not-allowed;}
.btn.ghost{background:transparent; color:var(--interactive); border:1px solid var(--interactive);}
.btn.ghost:hover{background:#edf5ff;}
.btn.ghost:disabled{background:transparent; color:var(--text-helper); border-color:var(--border-strong); cursor:not-allowed;}
.btn.small{padding:6px 12px; font-size:13px;}
.btn.ai{background:transparent; color:var(--purple); border:1px solid var(--purple);}
.btn.ai:hover{background:#f6f2ff;}
.btn.ai:disabled{color:var(--text-helper); border-color:var(--border-strong); background:transparent;}
.add-row{display:flex; gap:8px; flex-wrap:wrap;}
.add-row input, .add-row select{border:none; border-bottom:1px solid var(--border-strong); background:var(--layer); padding:10px 12px; font-size:15px; font-family:inherit;}
.add-row input{flex:1; min-width:280px; max-width:640px;}
.type-chip{font-size:11px; font-weight:600; padding:2px 8px; border-radius:12px;}
.ty-Problem{background:var(--border); color:var(--text);}
.ty-Question{background:#edf5ff; color:var(--interactive);}
.ty-Idea{background:#f6f2ff; color:var(--purple);}
.sug-chip{font-size:11px; font-weight:600; padding:2px 8px; border-radius:12px; background:#f6f2ff; color:var(--purple);}
.src-tag{font-size:11.5px; color:var(--text-helper);}
/* Ghost suggestions inside clusters */
.ghost-block{margin-top:12px; padding:10px 12px; background:var(--bg); border:1px dashed var(--border-strong);}
.ghost-label{font-size:11px; letter-spacing:.4px; text-transform:uppercase; color:var(--text-helper); margin-bottom:6px;}
.ghost-row{display:flex; align-items:center; gap:12px; padding:5px 0;}
.ghost-row .gtext{flex:1; font-size:13px; color:var(--text-secondary); font-style:italic;}
.cl-add{display:flex; gap:8px; margin-top:12px; align-items:center;}
.cl-add input{flex:1; border:none; border-bottom:1px solid var(--border-strong); background:var(--bg); padding:8px 10px; font-size:13.5px; font-family:inherit;}
.cl-add select{border:none; border-bottom:1px solid var(--border-strong); background:var(--bg); padding:7px 8px; font-size:12.5px; font-family:inherit;}
/* Map */
.tray{background:var(--layer); border:1px solid var(--border); border-left:4px solid var(--danger); padding:14px 16px; margin:16px 0;}
.tray h2{font-size:15px; font-weight:600; margin-bottom:8px;}
.tray-row{display:flex; align-items:center; gap:10px; padding:6px 0; border-bottom:1px solid var(--border);}
.tray-row:last-child{border-bottom:none;}
.tray-row .ttext{flex:1; font-size:14px;}
select.assign, .cl-head select, .member select{border:none; border-bottom:1px solid var(--border-strong); background:var(--bg); padding:6px 8px; font-size:12.5px; font-family:inherit; max-width:250px;}
.cluster{background:var(--layer); border:1px solid var(--border); border-left:4px solid var(--interactive); margin-bottom:12px;}
.cluster.promoted{border-left-color:var(--success);}
.cl-head{display:flex; align-items:center; gap:12px; padding:11px 16px; cursor:pointer;}
.cl-head:hover{background:var(--layer-hover);}
.caret{width:20px; font-size:13px; color:var(--text-secondary); text-align:center; flex-shrink:0;}
.cl-name{font-size:16px; font-weight:600;}
.cl-count{font-size:13px; color:var(--text-secondary);}
.rank-chip{font-size:11.5px; font-weight:600; background:var(--interactive); color:#fff; border-radius:12px; padding:2px 9px;}
.cl-head .spacer{flex:1;}
.dot-ctl{display:flex; align-items:center; gap:6px;}
.dot-btn{width:32px; height:32px; border:1px solid var(--border-strong); background:var(--layer); font-size:16px; color:var(--text-secondary);}
.dot-btn:hover{background:var(--layer-hover); color:var(--text);}
.dot-n{width:30px; text-align:center; font-size:16px; font-weight:600;}
.promoted-flag{font-size:12px; color:var(--success); font-weight:600; white-space:nowrap;}
.cl-body{border-top:1px solid var(--border); padding:14px 16px;}
.cl-root label{font-size:11px; letter-spacing:.4px; text-transform:uppercase; color:var(--text-helper); display:block; margin-bottom:5px;}
.cl-root textarea{width:100%; border:none; background:var(--bg); padding:9px 11px; font-size:14.5px; font-weight:500; font-family:inherit; resize:vertical; min-height:48px; color:var(--text);}
.members{margin-top:10px;}
.member{display:flex; align-items:center; gap:10px; padding:6px 0 6px 26px; border-bottom:1px solid var(--border); position:relative;}
.member:last-child{border-bottom:none;}
.member::before{content:''; position:absolute; left:9px; top:0; bottom:50%; width:12px; border-left:1px solid var(--border-strong); border-bottom:1px solid var(--border-strong);}
.member:not(:last-child)::after{content:''; position:absolute; left:9px; top:50%; bottom:0; border-left:1px solid var(--border-strong);}
.member .mtext{flex:1; font-size:13.5px;}
.row-del{background:none; border:none; color:var(--text-helper); font-size:15px;}
.row-del:hover{color:var(--danger);}
.pedit{background:none; border:none; color:var(--text-helper); font-size:14px;}
.pedit:hover{color:var(--interactive);}
.medit{width:100%; border:none; background:var(--bg); padding:7px 9px; font-size:13.5px; font-family:inherit; resize:vertical;}
/* Breadcrumbs */
.bc{background:var(--layer); border:1px solid var(--border); margin-bottom:16px; padding:26px 30px; min-height:70vh;}
.bc.example{border-left:4px solid var(--teal);}
.bc-top{display:grid; grid-template-columns:repeat(3, 1fr); gap:18px; margin-bottom:16px;}
.bc-out{display:grid; grid-template-columns:repeat(4, 1fr); gap:18px; background:var(--bg); padding:16px; border:1px solid var(--border);}
.bc-field h3{font-size:10.5px; letter-spacing:.5px; text-transform:uppercase; color:var(--text-helper); margin-bottom:5px; display:flex; align-items:center; gap:6px;}
.stepnum{width:17px; height:17px; border-radius:50%; background:var(--text); color:#fff; font-size:10.5px; display:inline-flex; align-items:center; justify-content:center; flex-shrink:0;}
.bc-field textarea{width:100%; border:none; background:var(--bg); padding:10px 12px; font-size:14px; font-family:inherit; resize:vertical; min-height:130px; color:var(--text);}
.bc-out .bc-field textarea{background:var(--layer); min-height:90px;}
.bc-out-head{grid-column:1/-1; font-size:10.5px; letter-spacing:.5px; text-transform:uppercase; color:var(--text-helper); display:flex; align-items:center; gap:6px;}
.bc-related{margin:0 0 12px; font-size:13px;}
.bc-related summary{cursor:pointer; color:var(--interactive);}
.bc-related li{margin-left:22px; padding:2px 0; color:var(--text-secondary);}
.bc-foot{display:flex; align-items:center; gap:14px; margin-top:12px; flex-wrap:wrap;}
.bc-layout{display:grid; grid-template-columns:250px 1fr; gap:16px; align-items:start;}
.bc-nav{display:block; background:var(--layer); border:1px solid var(--border); position:sticky; top:12px; max-height:80vh; overflow-y:auto; overflow-x:hidden;}
.bc-nav-item{display:flex; align-items:flex-start; gap:9px; padding:10px 12px; border-bottom:1px solid var(--border); cursor:pointer; min-width:0;}
.bc-nav-item:last-child{border-bottom:none;}
.bc-nav-item:hover{background:var(--layer-hover);}
.bc-nav-item.active{background:var(--layer-hover); border-left:3px solid var(--interactive); padding-left:9px;}
.bc-nav-text{flex:1; min-width:0; font-size:12.5px; line-height:1.35; color:var(--text-secondary); overflow-wrap:break-word;}
.bc-nav-item.active .bc-nav-text{color:var(--text); font-weight:600;}
.bc-dot{width:8px; height:8px; border-radius:50%; flex-shrink:0; background:var(--border-strong);}
.bc-dot.complete{background:var(--success);}
.bc-dot.partial{background:var(--warning);}
.bc-detail{min-width:0;}
@media (max-width:900px){ .bc-layout{grid-template-columns:1fr;} .bc-nav{position:static; max-height:none;} }
.gate{display:flex; gap:12px; font-size:12.5px; flex-wrap:wrap;}
.gate span{color:var(--text-helper);}
.gate span.ok{color:var(--success); font-weight:600;}
.gate-hint{font-size:12.5px; color:var(--warning); background:var(--warning-bg); padding:5px 10px;}
.example-tag{font-size:10px; letter-spacing:.4px; text-transform:uppercase; color:var(--teal); border:1px solid var(--teal); padding:0 6px; border-radius:9px;}
.type-tag{display:inline-block; font-size:11.5px; font-weight:600; padding:3px 10px; border-radius:12px;}
.tt-outcome{background:#defbe6; color:#0e6027;}
.tt-adoption{background:#edf5ff; color:var(--interactive);}
.type-toggle{border:none; background:none; padding:0;}
.summary-bar{background:var(--layer); border:1px solid var(--border); padding:12px 18px; margin-bottom:16px; display:flex; gap:26px; font-size:13.5px; flex-wrap:wrap;}
.summary-bar b{font-size:19px; font-weight:600; display:block;}
.summary-bar .warn b{color:var(--danger);}
.note-bar{background:var(--warning-bg); border:1px solid var(--warning); color:var(--warning); font-size:13.5px; padding:10px 14px; margin-bottom:16px; max-width:980px;}
.empty{background:var(--layer); border:1px dashed var(--border-strong); padding:30px; text-align:center; color:var(--text-helper); font-size:15px;}
/* Actions */
.act-row{background:var(--layer); border:1px solid var(--border); margin-bottom:6px; display:flex; align-items:center; gap:12px; padding:10px 14px;}
.act-row.done{opacity:.5;}
.act-row.done .act-text{text-decoration:line-through;}
.act-check{width:22px; height:22px; flex-shrink:0; border:1px solid var(--border-strong); background:var(--layer); font-size:13px;}
.act-check.on{background:var(--success); border-color:var(--success); color:#fff;}
.act-text{flex:1; font-size:14px;}
.act-row input[type=date], .act-row select{border:none; border-bottom:1px solid var(--border-strong); background:var(--bg); padding:6px 8px; font-size:13px; font-family:inherit;}
.toast{position:fixed; bottom:24px; left:50%; transform:translateX(-50%); background:var(--text); color:#fff; padding:12px 22px; font-size:14px; opacity:0; pointer-events:none; transition:opacity .2s; z-index:50;}
.toast.show{opacity:1;}
#jsonFile{display:none;}
.btn.danger{background:var(--danger); color:#fff; border:none;}
.btn.danger:hover{background:#b3121d;}
.confirm-backdrop{position:fixed; inset:0; background:rgba(22,22,22,.5); display:flex; align-items:center; justify-content:center; z-index:70; padding:20px;}
.confirm-card{background:var(--layer); border:1px solid var(--border); border-left:4px solid var(--danger); padding:22px 26px; max-width:420px; width:100%;}
.confirm-card h3{font-size:16px; font-weight:600; margin-bottom:8px;}
.confirm-card p{font-size:14px; color:var(--text-secondary); line-height:1.5; margin-bottom:20px;}
.confirm-actions{display:flex; justify-content:flex-end; gap:10px;}
@media (max-width:1200px){ .bc-top{grid-template-columns:1fr;} .bc-out{grid-template-columns:1fr 1fr;} }
@media (prefers-reduced-motion: reduce){ *{transition:none !important;} }
</style>
</head>
<body>
<header>
<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>
<button class="hdr-btn primary" id="btnExport">Export summary</button>
<input type="file" id="jsonFile" accept=".json,application/json">
</header>
<nav>
<button class="active" data-panel="map">1. Problem Map <span class="count warn" id="cntUncl">0</span></button>
<span class="flow"></span>
<button data-panel="bc">2. Breadcrumbs <span class="count" id="cntBc">0</span></button>
<span class="flow"></span>
<button data-panel="act">3. Actions <span class="count" id="cntAct">0</span></button>
</nav>
<main>
<!-- ===== 1 PROBLEM MAP ===== -->
<section class="panel active" id="panel-map">
<div class="panel-head">
<h1>Problem Map</h1>
<p>Capture at the top; new items land in the red tray until attached to a root problem. Each cluster is a root problem and the issues under it are the evidence. Vote with dots directly on the cluster headers, then promote the winners to Breadcrumbs.</p>
</div>
<div class="add-row">
<input type="text" id="newProb" placeholder="State what goes wrong and what it costs" aria-label="New item">
<select id="newProbWho" aria-label="Raised by"></select>
<select id="newProbCluster" aria-label="Cluster"></select>
<button class="btn" id="btnAddProb">Add</button>
</div>
<div class="tray" id="unclTray"></div>
<div id="clusterList"></div>
<div class="add-row" style="margin-top:14px;">
<input type="text" id="newCluster" placeholder="Add a root problem cluster (short name)" aria-label="New cluster">
<button class="btn ghost" id="btnAddCluster">Add cluster</button>
</div>
</section>
<!-- ===== 2 BREADCRUMBS ===== -->
<section class="panel" id="panel-bc">
<div class="panel-head">
<h1>Breadcrumbs</h1>
<p>Root problem to value driver to capability to measurable outcome. A completed breadcrumb IS a pilot success criterion; the export builds the criteria table from every complete card. AI draft fills the fields from construction execution methodology (EVM, CII AWP, Last Planner, first-time quality); the room reviews and edits.</p>
</div>
<div class="summary-bar" id="bcSummary"></div>
<div class="bc-layout">
<div class="bc-nav" id="bcNav"></div>
<div class="bc-detail" id="bcDetail"></div>
</div>
<div class="add-row"><button class="btn ghost" id="btnAddBc">Add a blank breadcrumb</button></div>
</section>
<!-- ===== 3 ACTIONS ===== -->
<section class="panel" id="panel-act">
<div class="panel-head">
<h1>Actions</h1>
<p>Every open item gets an owner and a date. Scan adds an action for each incomplete breadcrumb, each cluster missing a root statement, and any unclustered issues.</p>
</div>
<div class="add-row" style="margin-bottom:16px;">
<button class="btn" id="btnScan">Scan for incomplete items</button>
</div>
<div id="actList"></div>
<div class="add-row" style="margin-top:14px;">
<input type="text" id="newAct" placeholder="Add an action" aria-label="New action">
<button class="btn ghost" id="btnAddAct">Add</button>
</div>
</section>
</main>
<div class="toast" id="toast"></div>
<script>
'use strict';
const PEOPLE = ['Nick','Tim','Jason','Brendan','Lee','Cody','Clinton','Wyman','Cameron','Ryan','Team'];
const TYPES = ['Problem','Question','Idea'];
function P(id,t,who,cluster,opts){ return Object.assign({id, t, who, cluster: cluster||'', type:'Problem', sugg:false}, opts||{}); }
function C(id,name,root,ws){ return {id, name, root, ws: ws||'', open:false, dots:0, promoted:false}; }
function B(o){ return Object.assign({id:uid(), example:false, type:'Outcome', prob:'', related:[], driver:'', cap:'', metric:'', method:'', baseline:'', target:''}, o||{}); }
const SUGGESTIONS = [
{cluster:'c1', t:'There is no shared definition of what counts as complete for each asset stage, so reported percent complete means different things by team'},
{cluster:'c1', t:'Percent complete is count based, not effort based: a 2 hour device and a 2 day panel move the number the same amount'},
{cluster:'c1', t:'Turnover readiness by system is not visible, so incomplete predecessor work is discovered at commissioning'},
{cluster:'c2', t:'The field gets no feedback that their updates were used, so the discipline to enter data decays'},
{cluster:'c2', t:'As-built and redline data is not captured at completion, so closeout becomes a reconstruction project'},
{cluster:'c2', t:'Each project invents its own tracker (Excel, paper, whiteboard), so nothing rolls up across projects'},
{cluster:'c3', t:'Permits, LOTO, and energization requirements are not confirmed before work is released, causing same day stand downs'},
{cluster:'c3', t:'Work areas are not confirmed released by the GC or other trades before crews are sent'},
{cluster:'c3', t:'There is no defined readiness check before work is released; each foreman decides on their own what to verify'},
{cluster:'c4', t:'Crews do not see a lookahead: the plan lives with the PM or GF, so the next assignment travels verbally'},
{cluster:'c4', t:'Commitments made at standup are not tracked, so we never know what percent of promised work actually completed'},
{cluster:'c4', t:'There is no visibility to which crews or skills free up when, so reassignment is reactive'},
{cluster:'c5', t:'Extra work and T&M are not captured at the time they are performed, so recoverable cost is lost'},
{cluster:'c5', t:'There is no single owner of the change log; pending changes live in email threads'},
{cluster:'c5', t:'Scope splits at contract boundaries are not documented anywhere the field can check'},
{cluster:'c6', t:'There is no traceable record of who installed or verified a termination, so failure investigations start from zero'},
{cluster:'c6', t:'The field cannot tell whether the drawing in hand is the current revision without calling the office'},
{cluster:'c6', t:'QA hold points are not enforced by the process; skipping a gate has no visible consequence until rework'},
{cluster:'c7', t:'Estimating does not receive field actuals in a usable form, so estimates cannot learn from performance'}
];
const DEFAULT_STATE = {
clusters:[
C('c1','Progress Visibility','We cannot answer where a project stands: no trustworthy, phase level percent complete exists','Tracking MVP (pilot)'),
C('c2','Data Freshness & Entry Burden','Field data is captured late, on paper, or more than once, so the record is stale and expensive to maintain',''),
C('c3','Constraint Readiness','Work is released before it is ready: material, tools, and information are not confirmed available before crews start','IWP Tool Suite'),
C('c4','Planning & Resources','Crews and planners lack visibility to backlog, priorities, and resource availability, producing idle time and churn',''),
C('c5','Scope Control','Scope changes and gaps move faster than our change control: work happens without approval or falls out entirely','AWP Process RFP'),
C('c6','Quality & Spec Conformance','The field cannot reliably build to the approved design: specs, revisions, and QA gates do not reach the point of work','AWP Process RFP'),
C('c7','Knowledge & Integration','What we learn does not travel: knowledge stays with individuals and external platforms stay closed to us','')
],
problems:[
P('p01','No one can reliably answer "where are we at / what percent complete" on a project','Jason','c1'),
P('p05','Percent complete is needed by phase (engineering, BIM, tray/conduit), not just one overall number','Jason','c1'),
P('p27','Work started but not finished. (ie field hits constraint and forgets to return to the scope)','Cody','c1'),
P('p17','Constraints (ie trade stacking) not being captured in real time. No visibility to GF / Super loosing the ability to better communicate at stand up meetings','Nick','c1'),
P('p34','Install at risk - BIM has no visibility to what has been installed without being captured in the model.','Team','c1'),
P('p02','Field data goes stale because updates are not entered when the work completes','Tim','c2'),
P('p37','Data is being captured in the field on paper and brought back to the office to be converted to digital (ie QA check lists, Time sheets, daily log)','Team','c2'),
P('p08','Crews walk back to the trailer to turn in daily logs and forms','Tim','c2'),
P('p09','Getting assets, IO, and cable lists into a workable relational database takes heavy manual entry','Jason','c2'),
P('p10','Double entry between our tools and the GC platform on every project','Jason','c2'),
P('p36','cable pull list has duplicate or missing cable','Team','c2'),
P('p28','material is not available when the scope is executed.','Nick','c3'),
P('p29','Equipment / tools are not available when the scope is being executed (ladders, lift)','Team','c3'),
P('p06','We overspend on material because nobody knows where material is','Nick','c3'),
P('p18','working with incomplete data sets (ie installing with no BIM)','Cody','c3'),
P('p22','poor visibility to predecessors / requirements for the work being done. (ie if a peice of equipment needs to be commissioned but it requires PTP backbone to be operational on the other side of the building))','Tim','c3'),
P('p04','Idle time: people stand around waiting for their next assignment','Tim','c4'),
P('p14','Foreman dont have good visibility to work backlog causing Idle time.','Nick','c4'),
P('p16','Bim starts work on priority 1, then told to stop and work on priority 3, this repeats','Cody','c4'),
P('p19','Resource availability is not availible to project teams (ie if a PM needs a tech, where can they get one?)','Nick','c4'),
P('p42','Progress halts due to loss of resource (ie pto, terminated)','Clinton','c4'),
P('p30','Scope takes longer than expected (could this be mitigated by including the field team in estimates)','Team','c4'),
P('p03','scope gap - scope performed without approved change order.','Clinton','c5'),
P('p40','scope gap - in scope not performed as it was lost in pre-contruction activities (ie dropped from the io list)','Clinton','c5'),
P('p41','Scope Gap - who terminates the back bone fiber?','Clinton','c5'),
P('p35','Field team taking direction from external sources causing rework as it was not approved through the proper channels.','Team','c5'),
P('p33','When field team deviates from plan failure to communicate that back to design (ie redlines during the course of work rather than at the end)','Team','c5'),
P('p07','Field rework happens because crews do not have the latest drawings','Nick','c6'),
P('p15','Rework caused by construction tracking not having the correct data','Cody','c6'),
P('p23','Failure in sequencing, missing the QA gate (ie terminating a cable that is TSP when it should have been a 4 Conductor)','Cody','c6'),
P('p24','wrong type of conduit installed due to environmental requirements ( ie Class 1 div 2 area had EMT installed) failure mode: out of spec','Clinton','c6'),
P('p20','installed equipment doesnt match submittals','Brendan','c6'),
P('p21','install material used was does not match whats approved in the submittal','Nick','c6'),
P('p25','Specs not accessible to field installation teams','Clinton','c6'),
P('p31','visability to chain of custody of project scope, drawings, design intent (who do I ask when constructability issue arises)','Team','c6'),
P('p32','field teams fail to follow the designed workflow/ install.','Team','c6'),
P('p13','Punch list / issue follow-through is not measured; time to close is unknown','Nick','c7'),
P('p43','Tribal knowledge is lost after someone leaves or is on PTO','Clinton','c7'),
P('p38','Lessons learned - Construction methods could be more efficient with one team and this is not shared with the other.','Team','c7'),
P('p39','What are all of the ways we are reinventing the wheel?','Team','c7',{type:'Question'}),
P('p26','Suggestion improvement: create info graphics and other simplified ways of communicating project specs','Clinton','c7',{type:'Idea'}),
P('p11','GC platform sync/access requests get denied; we have never reached the people who actually own the decision','Tim','c7'),
P('p12','Platform changes GC to GC, so crews face retraining on a new tool nearly every project','Brendan','c7')
],
breadcrumbs:[
B({id:'b1', example:true, type:'Outcome',
prob:'Crews walk back to the trailer to turn in daily logs and forms',
driver:'Minimize non productive field time; keep craft hours on the work face',
cap:'Mobile field entry from the work area (phone / tablet, offline capable)',
metric:'% of daily logs submitted from the work area on the same day',
method:'System timestamps on log submission vs work date',
baseline:'Field poll of minutes per day spent on paperwork and walking, taken before pilot', target:''}),
B({id:'b2', type:'Outcome',
prob:'No one can reliably answer "where are we at / what percent complete" on a project',
driver:'man power / work planning',
cap:'construction tracking / resource planning',
metric:'do we have a tool, tool has been implemented and adopted, ~>80% visibility to the project progress',
method:'', baseline:'', target:''}),
B({id:'b3', type:'Outcome', prob:'Field data goes stale because updates are not entered when the work completes'}),
B({id:'b4', type:'Outcome', prob:'scope gap - scope performed without approved change order.'}),
B({id:'b5', type:'Outcome',
prob:'Double entry between our tools and the GC platform on every project',
driver:'Reduced duplicate entry',
cap:'Single system of entry with automated propagation',
metric:'Number of systems requiring manual entry of the same record',
method:'Trace 10 representative records end to end',
baseline:'Count on a current project before pilot', target:'1 internal system of entry'}),
B({id:'b6', type:'Adoption',
prob:'Supporting adoption measure for the pilot',
driver:'Field usage',
cap:'Pilot tool in daily field use',
metric:'% of assigned field users active weekly',
method:'Platform usage analytics',
baseline:'n/a, starts at zero', target:''})
],
actions:[
{id:'a1', t:'Review cluster assignments and root problem statements as a team; move issues that landed in the wrong cluster', owner:'', due:'', done:false},
{id:'a2', t:'Complete breadcrumb: Field data goes stale because updates are not entered when the work completes', owner:'', due:'', done:false},
{id:'a3', t:'Complete breadcrumb: scope gap - scope performed without approved change order.', owner:'', due:'', done:false},
{id:'a4', t:'Rework the percent complete breadcrumb: current metric is an adoption statement, needs a real outcome metric with method and baseline', owner:'', due:'', done:false},
{id:'a5', t:'Assign workstreams to the three unmapped clusters (Data Freshness, Planning & Resources, Knowledge & Integration)', owner:'', due:'', done:false}
]
};
let S = JSON.parse(JSON.stringify(DEFAULT_STATE));
function save(){ /* in-memory; use Save JSON to persist */ }
function uid(){ return 'x' + Math.random().toString(36).slice(2,9); }
function esc(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function opts(list, sel){ return list.map(v=>`<option${v===sel?' selected':''}>${esc(v)}</option>`).join(''); }
function shortT(t, n){ t = String(t); return t.length > n ? t.slice(0, n-1) + '…' : t; }
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'));
document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
document.getElementById('panel-' + b.dataset.panel).classList.add('active');
renderAll();
});
});
/* ===== 1 PROBLEM MAP ===== */
document.getElementById('newProbWho').innerHTML = opts(PEOPLE,'Team');
const editingItems = new Set();
function clusterOpts(sel){
return `<option value=""${!sel?' selected':''}>Unclustered</option>` +
S.clusters.map(c=>`<option value="${c.id}"${sel===c.id?' selected':''}>${esc(c.name)}</option>`).join('');
}
function renderMap(){
const uncl = S.problems.filter(p=>!p.cluster || !clusterById(p.cluster));
document.getElementById('cntUncl').textContent = uncl.length;
const capSel = document.getElementById('newProbCluster');
const prevSel = capSel.value;
capSel.innerHTML = clusterOpts(clusterById(prevSel) ? prevSel : '');
const tray = document.getElementById('unclTray');
tray.innerHTML = `<h2>Unclustered (${uncl.length})</h2>` + (uncl.length
? uncl.map(p=>`<div class="tray-row">
<span class="type-chip ty-${p.type}">${p.type}</span>
<div class="ttext">${esc(p.t)}</div>
<select class="assign" data-assign="${p.id}">${clusterOpts('')}</select>
<button class="row-del" data-pdel="${p.id}" title="Remove">✕</button>
</div>`).join('')
: '<div style="font-size:13.5px; color:var(--text-helper);">Everything is attached to a root problem.</div>');
const ranked = [...S.clusters].sort((a,b)=>b.dots-a.dots).filter(c=>c.dots>0).slice(0,3).map(c=>c.id);
const list = document.getElementById('clusterList');
list.innerHTML = S.clusters.map(c=>{
const mem = membersOf(c.id);
const rank = ranked.indexOf(c.id);
const existing = new Set(S.problems.map(p=>p.t));
const ghosts = SUGGESTIONS.map((s,i)=>({...s, idx:i})).filter(s=>s.cluster===c.id && !existing.has(s.t));
const ghostHtml = ghosts.length ? `<div class="ghost-block">
<div class="ghost-label">Also consider (from theme analysis):</div>
${ghosts.map(s=>`<div class="ghost-row">
<div class="gtext">${esc(s.t)}</div>
<button class="btn small ghost" data-sadd="${s.idx}">+ Add</button>
</div>`).join('')}
</div>` : '';
const body = !c.open ? '' : `<div class="cl-body">
<div class="cl-root">
<label>Root problem statement</label>
<textarea data-croot="${c.id}" placeholder="Name the underlying problem these issues share">${esc(c.root)}</textarea>
</div>
<div class="members">
${mem.length ? mem.map(p=>{
if(editingItems.has(p.id)){
return `<div class="member">
<textarea class="medit" data-etext="${p.id}">${esc(p.t)}</textarea>
<button class="btn small" data-esave="${p.id}">Save</button>
<button class="btn small ghost" data-ecancel="${p.id}">Cancel</button>
</div>`;
}
return `<div class="member">
<span class="type-chip ty-${p.type}">${p.type}</span>
<div class="mtext">${esc(p.t)} ${p.sugg?'<span class="sug-chip">Suggested</span>':''} <span class="src-tag">(${p.sugg?'theme analysis':esc(p.who)})</span></div>
<button class="pedit" data-pedit="${p.id}" title="Edit"></button>
<select data-assign="${p.id}" title="Move to">${clusterOpts(c.id)}</select>
<button class="row-del" data-pdel="${p.id}" title="Remove"></button>
</div>`;
}).join('') : '<div style="font-size:13px; color:var(--text-helper); padding:6px 0 0 26px;">No issues attached yet.</div>'}
</div>
${ghostHtml}
<div class="cl-add">
<input type="text" data-caddinput="${c.id}" placeholder="Add an issue directly to this cluster" aria-label="Add issue to ${esc(c.name)}">
<select data-caddwho="${c.id}">${opts(PEOPLE,'Team')}</select>
<button class="btn small ghost" data-cadd="${c.id}">Add</button>
</div>
</div>`;
return `<div class="cluster${c.promoted?' promoted':''}">
<div class="cl-head" data-toggle="${c.id}">
<span class="caret">${c.open?'▼':'▶'}</span>
${rank>-1?`<span class="rank-chip">#${rank+1}</span>`:''}
<span class="cl-name">${esc(c.name)}</span>
<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>
<button class="dot-btn" data-cdot="${c.id}" data-d="1">+</button>
</div>
<button class="row-del" data-cdel="${c.id}" title="Remove cluster (issues return to Unclustered)">✕</button>
</div>
${body}
</div>`;
}).join('');
}
document.getElementById('panel-map').addEventListener('click', e=>{
const t = e.target;
if(t.dataset.sadd !== undefined){
const s = SUGGESTIONS[+t.dataset.sadd];
if(s && !S.problems.some(p=>p.t===s.t)){
const cl = clusterById(s.cluster);
S.problems.push(P(uid(), s.t, 'Team', cl ? s.cluster : '', {sugg:true}));
save(); renderMap(); toast(cl ? 'Added to ' + cl.name : 'Added (unclustered)');
}
return;
}
if(t.dataset.cdot){ const c=clusterById(t.dataset.cdot); if(c){ c.dots=Math.max(0,c.dots+(+t.dataset.d)); save(); renderMap(); } return; }
if(t.dataset.cprom){
const c = clusterById(t.dataset.cprom);
if(c){
c.promoted = true;
const nb = B({prob:(c.root||c.name), related:activeMembers(c.id).map(p=>p.t)});
S.breadcrumbs.push(nb);
bcSelectedId = nb.id;
save(); renderAll(); toast('Promoted with ' + activeMembers(c.id).length + ' related issues');
}
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){
confirmModal({
title: 'Remove cluster?',
message: `Remove cluster "${c.name}"? Its issues return to Unclustered.`,
confirmLabel: 'Remove cluster'
}, ()=>{
S.problems.forEach(p=>{ if(p.cluster===c.id) p.cluster=''; });
S.clusters = S.clusters.filter(x=>x.id!==c.id);
save(); renderMap();
});
}
return;
}
if(t.dataset.cadd){
const inp = document.querySelector(`input[data-caddinput="${t.dataset.cadd}"]`);
const who = document.querySelector(`select[data-caddwho="${t.dataset.cadd}"]`);
const v = inp ? inp.value.trim() : '';
if(v){
S.problems.push(P(uid(), v, who ? who.value : 'Team', t.dataset.cadd));
save(); renderMap(); toast('Added to ' + (clusterById(t.dataset.cadd)||{name:'cluster'}).name);
}
return;
}
if(t.dataset.pedit){ editingItems.add(t.dataset.pedit); renderMap(); return; }
if(t.dataset.ecancel){ editingItems.delete(t.dataset.ecancel); renderMap(); return; }
if(t.dataset.esave){
const p = S.problems.find(x=>x.id===t.dataset.esave);
const ta = document.querySelector(`textarea[data-etext="${t.dataset.esave}"]`);
if(p && ta && ta.value.trim()){ p.t = ta.value.trim(); editingItems.delete(p.id); save(); renderMap(); toast('Updated'); }
else toast('Text cannot be empty');
return;
}
if(t.dataset.pdel){
const pid = t.dataset.pdel;
confirmModal({
title: 'Remove issue?',
message: 'Remove this issue from the problem map? This cannot be undone.',
confirmLabel: 'Remove issue'
}, ()=>{
S.problems = S.problems.filter(p=>p.id !== pid);
editingItems.delete(pid);
save(); renderMap();
});
return;
}
const head = t.closest('[data-toggle]');
if(head && t.tagName!=='SELECT' && t.tagName!=='OPTION' && !t.dataset.cdot && !t.dataset.cprom && !t.dataset.cdel){
const c = clusterById(head.dataset.toggle);
if(c){ c.open = !c.open; save(); renderMap(); }
}
});
document.getElementById('panel-map').addEventListener('keydown', e=>{
if(e.key==='Enter' && e.target.dataset && e.target.dataset.caddinput){
e.preventDefault();
const btn = document.querySelector(`button[data-cadd="${e.target.dataset.caddinput}"]`);
if(btn) btn.click();
}
});
document.getElementById('panel-map').addEventListener('change', e=>{
const t = e.target;
if(t.dataset.assign){
const p = S.problems.find(x=>x.id===t.dataset.assign);
if(p){ p.cluster = t.value; save(); renderMap(); }
}
});
document.getElementById('panel-map').addEventListener('input', e=>{
if(e.target.dataset.croot){
const c = clusterById(e.target.dataset.croot);
if(c){ c.root = e.target.value; save(); }
}
});
document.getElementById('btnAddProb').addEventListener('click', ()=>{
const inp = document.getElementById('newProb');
const v = inp.value.trim();
if(!v) return;
const cid = document.getElementById('newProbCluster').value;
S.problems.push(P(uid(), v, document.getElementById('newProbWho').value, clusterById(cid) ? cid : ''));
inp.value=''; save(); renderMap();
toast(clusterById(cid) ? 'Added to ' + clusterById(cid).name : 'Captured. Attach it to a root problem.');
});
document.getElementById('newProb').addEventListener('keydown', e=>{ if(e.key==='Enter') document.getElementById('btnAddProb').click(); });
document.getElementById('btnAddCluster').addEventListener('click', ()=>{
const inp = document.getElementById('newCluster');
const v = inp.value.trim();
if(!v) return;
const c = C(uid(), v, '', ''); c.open = true;
S.clusters.push(c);
inp.value=''; save(); renderMap();
});
document.getElementById('newCluster').addEventListener('keydown', e=>{ if(e.key==='Enter') document.getElementById('btnAddCluster').click(); });
/* ===== 2 BREADCRUMBS ===== */
const ADOPTION_WORDS = /implement|adopt|roll ?out|have a tool|tool exists|usage|active users?|users? active/i;
const REQ = ['driver','cap','metric','method','baseline'];
function bcComplete(b){ return REQ.every(f=>String(b[f]||'').trim().length>0); }
function bcStatus(b){ if(bcComplete(b)) return 'complete'; if(REQ.some(f=>String(b[f]||'').trim())) return 'partial'; return 'blank'; }
let bcSelectedId = null;
function footHTML(b){
const adoptionFlag = b.type==='Outcome' && ADOPTION_WORDS.test(b.metric||'');
const gate = REQ.map(f=>{
const ok = String(b[f]||'').trim().length>0;
return `<span class="${ok?'ok':''}">${ok?'✓':'○'} ${f==='cap'?'capability':f}</span>`;
}).join('');
return `<button class="type-toggle" data-tt="${b.id}" title="Toggle Outcome / Adoption"><span class="type-tag ${b.type==='Outcome'?'tt-outcome':'tt-adoption'}">${b.type}</span></button>
<div class="gate">${gate}</div>
${adoptionFlag ? '<span class="gate-hint">Metric reads as adoption. Name the outcome the tool should move, or tag this card Adoption.</span>' : ''}
<button class="btn small ai" data-aibc="${b.id}">✨ AI draft fields</button>
<button class="row-del" data-bdel="${b.id}" title="Delete this breadcrumb" style="margin-left:auto; padding:4px 8px; font-size:13px;">Delete</button>`;
}
function bcSummaryHTML(){
const nOut = S.breadcrumbs.filter(b=>b.type==='Outcome').length;
const nDone = S.breadcrumbs.filter(bcComplete).length;
const nInc = S.breadcrumbs.length - nDone;
return `<div><b>${nDone}</b>Complete (= criteria)</div>
<div class="${nInc?'warn':''}"><b>${nInc}</b>Incomplete</div>
<div><b>${nOut}</b>Outcome</div><div><b>${S.breadcrumbs.length-nOut}</b>Adoption</div>`;
}
function bcCardHTML(b){
const rel = (b.related && b.related.length)
? `<details class="bc-related"><summary>Related issues carried from the cluster (${b.related.length})</summary><ul>${b.related.map(r=>`<li>${esc(r)}</li>`).join('')}</ul></details>` : '';
return `<div class="bc${b.example?' example':''}">
<div class="bc-top">
<div class="bc-field">
<h3><span class="stepnum">1</span>Root problem statement ${b.example?'<span class="example-tag">Worked example</span>':''}</h3>
<textarea data-bid="${b.id}" data-f="prob" placeholder="What goes wrong and what it costs">${esc(b.prob)}</textarea>
</div>
<div class="bc-field">
<h3><span class="stepnum">2</span>Value driver</h3>
<textarea data-bid="${b.id}" data-f="driver" placeholder="The end we are trying to achieve, not the tool">${esc(b.driver)}</textarea>
</div>
<div class="bc-field">
<h3><span class="stepnum">3</span>Solution capability</h3>
<textarea data-bid="${b.id}" data-f="cap" placeholder="The means: the capability that serves the driver">${esc(b.cap)}</textarea>
</div>
</div>
${rel}
<div class="bc-out">
<div class="bc-out-head"><span class="stepnum">4</span>Measurable outcome (a complete card is a pilot success criterion)</div>
<div class="bc-field"><h3>Metric</h3><textarea data-bid="${b.id}" data-f="metric" placeholder="What number moves, in what direction">${esc(b.metric)}</textarea></div>
<div class="bc-field"><h3>Method</h3><textarea data-bid="${b.id}" data-f="method" placeholder="Data source + comparison + cadence">${esc(b.method)}</textarea></div>
<div class="bc-field"><h3>Baseline</h3><textarea data-bid="${b.id}" data-f="baseline" placeholder="A number, or the pre-pilot study that produces one">${esc(b.baseline)}</textarea></div>
<div class="bc-field"><h3>Target (optional, set after baseline)</h3><textarea data-bid="${b.id}" data-f="target" placeholder="Set after baseline exists">${esc(b.target)}</textarea></div>
</div>
<div class="bc-foot" data-foot="${b.id}">${footHTML(b)}</div>
</div>`;
}
function bcNavRowHTML(b){
const label = shortT(b.prob, 60) || '(blank breadcrumb)';
return `<div class="bc-nav-item${b.id===bcSelectedId?' active':''}" data-bcnav="${b.id}">
<span class="bc-dot ${bcStatus(b)}"></span>
<span class="bc-nav-text">${esc(label)}</span>
</div>`;
}
function renderBc(){
document.getElementById('cntBc').textContent = S.breadcrumbs.length;
document.getElementById('bcSummary').innerHTML = bcSummaryHTML();
const nav = document.getElementById('bcNav');
const detail = document.getElementById('bcDetail');
if(!S.breadcrumbs.length){
nav.innerHTML = '';
detail.innerHTML = '<div class="empty">Promote root problems from the map, or add a blank breadcrumb.</div>';
bcSelectedId = null;
return;
}
if(!S.breadcrumbs.some(b=>b.id===bcSelectedId)) bcSelectedId = S.breadcrumbs[0].id;
nav.innerHTML = S.breadcrumbs.map(bcNavRowHTML).join('');
const selected = S.breadcrumbs.find(b=>b.id===bcSelectedId);
detail.innerHTML = bcCardHTML(selected);
}
document.getElementById('bcNav').addEventListener('click', e=>{
const row = e.target.closest('[data-bcnav]');
if(row){ bcSelectedId = row.dataset.bcnav; renderBc(); }
});
document.getElementById('bcDetail').addEventListener('input', e=>{
if(e.target.dataset.bid){
const b = S.breadcrumbs.find(x=>x.id===e.target.dataset.bid);
if(b){
b[e.target.dataset.f] = e.target.value; save();
const foot = document.querySelector(`[data-foot="${b.id}"]`);
if(foot) foot.innerHTML = footHTML(b);
const navRow = document.querySelector(`[data-bcnav="${b.id}"]`);
if(navRow) navRow.outerHTML = bcNavRowHTML(b);
document.getElementById('bcSummary').innerHTML = bcSummaryHTML();
}
}
});
document.getElementById('bcDetail').addEventListener('click', e=>{
const tt = e.target.closest('[data-tt]');
if(tt){
const b = S.breadcrumbs.find(x=>x.id===tt.dataset.tt);
if(b){ b.type = b.type==='Outcome'?'Adoption':'Outcome'; save(); renderBc(); }
return;
}
if(e.target.dataset.aibc){ aiDraftBreadcrumb(e.target.dataset.aibc, e.target); return; }
if(e.target.dataset.bdel){
const bid = e.target.dataset.bdel;
confirmModal({
title: 'Remove breadcrumb?',
message: 'Remove this breadcrumb? Its fields will be lost unless you have already saved or exported this session.',
confirmLabel: 'Remove breadcrumb'
}, ()=>{
S.breadcrumbs = S.breadcrumbs.filter(b=>b.id!==bid);
save(); renderBc();
});
}
});
document.getElementById('btnAddBc').addEventListener('click', ()=>{
const nb = B({});
S.breadcrumbs.push(nb);
bcSelectedId = nb.id;
save(); renderBc();
});
/* ===== AI: draft breadcrumb fields from methodology ===== */
async function callClaude(prompt){
const response = await fetch("/api/claude", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 1000, messages: [{ role: "user", content: prompt }] })
});
if(!response.ok) throw new Error('API ' + response.status);
const data = await response.json();
return (data.content || []).filter(i=>i.type==='text').map(i=>i.text).join('\n');
}
function parseJson(text){
const clean = String(text).replace(/```json|```/g,'').trim();
const start = Math.min(...['[','{'].map(ch=>{ const i=clean.indexOf(ch); return i===-1?Infinity:i; }));
return JSON.parse(start===Infinity ? clean : clean.slice(start));
}
const METHOD_KB = `Domain playbook (anchor methodology -> standard metrics/methods/baselines):
1 PROGRESS VISIBILITY - Earned Value Mgmt, rules of credit. Metrics: claiming accuracy (reported % vs field-verified % within tolerance, by phase; method: monthly spot audit of 30+ sampled assets; baseline: first audit); forecast reliability (forecast vs actual completion; baseline: last project's reports). Never use "% of assets with a status" (adoption in disguise).
2 DATA FRESHNESS / ENTRY BURDEN - lean data flow, single source of truth. Metrics: entry latency (% of updates within 24h of completion; method: system timestamp vs completion date, weekly; baseline: audit paper-to-digital lag on current project); touch count (systems the same record is entered into; method: trace 10 records end to end; baseline: pre-pilot trace); non-productive data minutes/person/day (method+baseline: structured field poll pre-pilot, repeat midpoint).
3 CONSTRAINT READINESS / WORK RELEASE - CII AWP IR 272-2, constraint-free IWP release (drawings, material, tools, labor, access, predecessors, permits, quality docs, scaffolding). Metrics: % releases issued constraint-free (method: release-gate checklist audit weekly, later constraint stop = miss; baseline: audit last month of work starts from daily logs); stand-down count/week (method: foreman log with fixed reason list; baseline: 2-week logging exercise); tool time % (method: activity sampling study; baseline: pre-pilot study; propose only when stakes justify cost).
4 PLANNING / RESOURCES / IDLE - Last Planner System. Metrics: PPC (% weekly commitments completed as promised; method: log at weekly planning, score next week, publish trend; baseline: first 4 weeks of logging); reasons-for-variance distribution (fixed-list coding of misses); idle hours/crew-week (timecard or foreman reason codes; baseline: 2-week coding).
5 SCOPE CONTROL / CHANGE - PMBOK change control, CII change research. Metrics: unauthorized-work hours/dollars at time of performance (method: monthly reconciliation of field records vs CO log; baseline: run reconciliation on current project); change capture latency days (field record date vs log date); change cycle time (log timestamps; baseline: last project's log).
6 QUALITY / REWORK - first-time quality, CII rework research (2-6% direct cost where measured). Metrics: rework % of craft hours (method: dedicated rework cost code + reason coding, monthly; baseline: create the code, first month is baseline); first-pass yield at QC gates; out-of-rev incidents (QC walkdown coding).
7 KNOWLEDGE / FEEDBACK - CII lessons-learned, estimate feedback. Metrics: closeout compliance % within 60 days (method: closeout checklist audit; baseline: audit last 4-6 projects); % estimates referencing prior actuals (estimating file audit); punch closure median days (issue log timestamps; if untimestamped, that gap is the finding).
GATES: driver = business end in cost/schedule/quality/safety/predictability terms, never a tool. capability = function not product, no vendor names. metric = outcome ratio/rate, NEVER "implemented/adopted/rolled out/active users/have a tool". method = data source + comparison + cadence. baseline = a number or the named pre-pilot study that produces one (cheapest defensible: poll < record trace < log audit < 2-week coding < sampling study). Set targets after baseline, not before. Each field under ~30 words except method/baseline.`;
async function aiDraftBreadcrumb(bid, btn){
const b = S.breadcrumbs.find(x=>x.id===bid);
if(!b) return;
if(!String(b.prob||'').trim()){ toast('Write the problem statement first'); return; }
const hasContent = REQ.some(f=>String(b[f]||'').trim());
if(hasContent && !confirm('Replace the current field contents with an AI draft grounded in construction execution methodology?')) return;
btn.disabled = true; btn.textContent = 'Thinking…';
try{
const rel = (b.related && b.related.length) ? `\nRelated issues in this cluster:\n${b.related.map(r=>'- '+r).join('\n')}` : '';
const prompt = `You populate breadcrumb trails for construction field problems at an industrial I&C contractor, using best-in-class execution methodology.\n\n${METHOD_KB}\n\nProblem statement:\n${b.prob}${rel}\n\nClassify the problem into one domain above, then draft the five fields per that domain's standard metrics, methods, and baselines, obeying every GATE. Respond ONLY with a JSON object, no prose, no markdown fences:\n{"domain":"...","driver":"...","cap":"...","metric":"...","method":"...","baseline":"..."}`;
const out = await callClaude(prompt);
const obj = parseJson(out);
if(!REQ.every(f=>obj[f])) throw new Error('incomplete draft');
if(ADOPTION_WORDS.test(obj.metric)) throw new Error('draft failed adoption gate; try again');
REQ.forEach(f=>{ b[f] = String(obj[f]); });
save(); renderBc();
toast('Drafted from ' + (obj.domain ? String(obj.domain) : 'methodology') + '. Review as a team.');
}catch(err){
console.error('AI breadcrumb error:', err);
toast('AI draft unavailable: ' + (err.message||'error'));
btn.disabled = false; btn.textContent = '✨ AI draft fields';
}
}
/* ===== 3 ACTIONS ===== */
function renderAct(){
const wrap = document.getElementById('actList');
document.getElementById('cntAct').textContent = S.actions.filter(a=>!a.done).length;
if(!S.actions.length){ wrap.innerHTML = '<div class="empty">No actions. Use Scan or add one manually.</div>'; return; }
wrap.innerHTML = S.actions.map(a=>`
<div class="act-row${a.done?' done':''}">
<button class="act-check${a.done?' on':''}" data-done="${a.id}">${a.done?'✓':''}</button>
<div class="act-text">${esc(a.t)}</div>
<select data-aowner="${a.id}"><option value="">Owner…</option>${opts(PEOPLE, a.owner)}</select>
<input type="date" data-adue="${a.id}" value="${esc(a.due)}">
<button class="row-del" data-adel="${a.id}" title="Remove">✕</button>
</div>`).join('');
}
document.getElementById('actList').addEventListener('click', e=>{
if(e.target.dataset.done){ const a=S.actions.find(x=>x.id===e.target.dataset.done); if(a){a.done=!a.done; save(); renderAct();} }
if(e.target.dataset.adel){
const aid = e.target.dataset.adel;
confirmModal({
title: 'Remove action?',
message: 'Remove this action item? This cannot be undone.',
confirmLabel: 'Remove action'
}, ()=>{ S.actions=S.actions.filter(a=>a.id!==aid); save(); renderAct(); });
}
});
document.getElementById('actList').addEventListener('change', e=>{
const t = e.target;
if(t.dataset.aowner){ const a=S.actions.find(x=>x.id===t.dataset.aowner); if(a){a.owner=t.value; save();} }
if(t.dataset.adue){ const a=S.actions.find(x=>x.id===t.dataset.adue); if(a){a.due=t.value; save();} }
});
document.getElementById('btnAddAct').addEventListener('click', ()=>{
const inp = document.getElementById('newAct');
const v = inp.value.trim();
if(!v) return;
S.actions.push({id:uid(), t:v, owner:'', due:'', done:false});
inp.value=''; save(); renderAct();
});
document.getElementById('newAct').addEventListener('keydown', e=>{ if(e.key==='Enter') document.getElementById('btnAddAct').click(); });
document.getElementById('btnScan').addEventListener('click', ()=>{
let added = 0;
function addOnce(t){ if(!S.actions.some(a=>a.t===t)){ S.actions.push({id:uid(), t, owner:'', due:'', done:false}); added++; } }
S.breadcrumbs.forEach(b=>{ if(!bcComplete(b)) addOnce('Complete breadcrumb: ' + (shortT(b.prob,90) || '(blank breadcrumb)')); });
S.clusters.forEach(c=>{ if(!String(c.root||'').trim()) addOnce('Write root problem statement for cluster: ' + c.name); });
const uncl = S.problems.filter(p=>!p.cluster || !clusterById(p.cluster)).length;
S.actions = S.actions.filter(a=>!/^Cluster assignment: \d+ issues unclustered$/.test(a.t) || a.done);
if(uncl) addOnce(`Cluster assignment: ${uncl} issues unclustered`);
save(); renderAct();
toast(added ? added + ' action(s) added' : 'Nothing incomplete found');
});
/* ===== SAVE / LOAD / EXPORT / RESET ===== */
document.getElementById('btnSave').addEventListener('click', ()=>{
const blob = new Blob([JSON.stringify(S, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `SDE_Field_Problem_Workshop_state_${new Date().toISOString().slice(0,10)}.json`;
a.click(); URL.revokeObjectURL(a.href);
toast('State saved to JSON');
});
document.getElementById('btnLoad').addEventListener('click', ()=>document.getElementById('jsonFile').click());
document.getElementById('jsonFile').addEventListener('change', e=>{
const f = e.target.files[0];
if(!f) return;
const r = new FileReader();
r.onload = ()=>{
try{
const data = JSON.parse(r.result);
if(!Array.isArray(data.problems) || !Array.isArray(data.clusters)) throw new Error('bad shape');
S = Object.assign(JSON.parse(JSON.stringify(DEFAULT_STATE)), data);
save(); renderAll(); toast('State loaded');
}catch(err){ toast('Could not load: not a valid workshop JSON'); }
e.target.value = '';
};
r.readAsText(f);
});
document.getElementById('btnExport').addEventListener('click', ()=>{
const d = new Date();
const stamp = d.toISOString().slice(0,10);
const clean = v => String(v||'').replace(/\|/g,'/').replace(/\n/g,' ');
let md = `# Project SDE Field Problem Workshop Summary (v5)\n\nDate: ${d.toLocaleDateString()}\n\n`;
md += `## 1. Root Problem Map (ranked by dots)\n\n`;
[...S.clusters].sort((a,b)=>b.dots-a.dots).forEach((c,i)=>{
const mem = membersOf(c.id);
md += `### ${i+1}. ${c.name} (${c.dots} dots${c.ws?', workstream: '+c.ws:', workstream: TBD'})\n`;
md += `Root problem: ${clean(c.root)||'TBD'}\n\nRelated issues:\n`;
md += mem.length ? mem.map(p=>`- ${p.sugg?'[suggested] ':''}${p.type!=='Problem'?'['+p.type+'] ':''}${clean(p.t)} (${p.sugg?'theme analysis':p.who})`).join('\n') + '\n\n' : '- None attached\n\n';
});
const uncl = S.problems.filter(p=>!p.cluster || !clusterById(p.cluster));
if(uncl.length) md += `### Unclustered (${uncl.length})\n` + uncl.map(p=>`- ${clean(p.t)} (${p.who})`).join('\n') + '\n\n';
md += `## 2. Breadcrumb Trails\n\n`;
S.breadcrumbs.forEach((b,i)=>{
md += `### ${i+1}. [${b.type}] ${clean(b.prob) || '(no problem statement)'}${b.example?' (worked example)':''} ${bcComplete(b)?'':'[INCOMPLETE]'}\n`;
md += `- Value driver: ${clean(b.driver)||'TBD'}\n- Solution capability: ${clean(b.cap)||'TBD'}\n`;
md += `- Metric: ${clean(b.metric)||'TBD'}\n- Method: ${clean(b.method)||'TBD'}\n- Baseline: ${clean(b.baseline)||'TBD'}\n- Target: ${clean(b.target)||'set after baseline'}\n`;
if(b.related && b.related.length) md += `- Evidence (${b.related.length} related issues): ` + b.related.map(clean).join('; ') + '\n';
md += '\n';
});
const done = S.breadcrumbs.filter(bcComplete);
md += `## 3. Pilot Success Criteria (generated from complete breadcrumbs)\n\n`;
if(done.length){
md += `| Type | Criterion | Metric | Method | Target | Baseline |\n|---|---|---|---|---|---|\n`;
done.forEach(b=>{ md += `| ${b.type} | ${clean(b.driver)} | ${clean(b.metric)} | ${clean(b.method)} | ${clean(b.target)} | ${clean(b.baseline)} |\n`; });
const nOut = done.filter(b=>b.type==='Outcome').length;
md += `\nMix: ${nOut} outcome, ${done.length-nOut} adoption.\n\n`;
} else md += 'No complete breadcrumbs yet.\n\n';
md += `## 4. Actions\n\n`;
if(S.actions.length){
md += `| Done | Action | Owner | Due |\n|---|---|---|---|\n`;
S.actions.forEach(a=>{ md += `| ${a.done?'✓':''} | ${clean(a.t)} | ${a.owner||'TBD'} | ${a.due||'TBD'} |\n`; });
const noOwner = S.actions.filter(a=>!a.done && !a.owner).length;
if(noOwner) md += `\n${noOwner} open action(s) have no owner.\n`;
} else md += 'None.\n';
const blob = new Blob([md], {type:'text/markdown'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `SDE_Field_Problem_Workshop_${stamp}.md`;
a.click(); URL.revokeObjectURL(a.href);
toast('Summary downloaded');
});
document.getElementById('btnReset').addEventListener('click', ()=>{
confirmModal({
title: 'Reset to baseline?',
message: 'Reset to the clustered July 21 baseline? Anything entered since will be lost unless saved to JSON.',
confirmLabel: 'Reset'
}, ()=>{
S = JSON.parse(JSON.stringify(DEFAULT_STATE));
save(); renderAll(); toast('Reset to baseline');
});
});
let toastTimer;
function toast(msg){
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(()=>t.classList.remove('show'), 2200);
}
function confirmModal(opts, onConfirm){
const backdrop = document.createElement('div');
backdrop.className = 'confirm-backdrop';
backdrop.innerHTML = `<div class="confirm-card" role="alertdialog" aria-modal="true" aria-labelledby="confirmTitle">
<h3 id="confirmTitle">${esc(opts.title || 'Remove this?')}</h3>
<p>${esc(opts.message || 'This cannot be undone.')}</p>
<div class="confirm-actions">
<button class="btn ghost" data-cc="cancel">Cancel</button>
<button class="btn danger" data-cc="ok">${esc(opts.confirmLabel || 'Delete')}</button>
</div>
</div>`;
document.body.appendChild(backdrop);
function close(){ document.removeEventListener('keydown', onKey); backdrop.remove(); }
function onKey(e){ if(e.key==='Escape') close(); }
document.addEventListener('keydown', onKey);
backdrop.addEventListener('click', e=>{ if(e.target===backdrop) close(); });
backdrop.querySelector('[data-cc="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-cc="ok"]').addEventListener('click', ()=>{ close(); onConfirm(); });
backdrop.querySelector('[data-cc="ok"]').focus();
}
function renderAll(){ renderMap(); renderBc(); renderAct(); }
renderAll();
</script>
</body>
</html>