756 lines
34 KiB
HTML
756 lines
34 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Project SDE | Scope Lock Meeting Suite</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; --gray-tag:#e0e0e0;
|
|
--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 ===== */
|
|
header{
|
|
background:var(--text); color:#fff; padding:0 24px;
|
|
display:flex; align-items:center; gap:24px; height:56px;
|
|
}
|
|
header .brand{font-size:15px; letter-spacing:.16px;}
|
|
header .brand strong{font-weight:600;}
|
|
header .brand span{color:#c6c6c6; font-weight:400;}
|
|
header .spacer{flex:1;}
|
|
header .meta{font-size:13px; color:#c6c6c6;}
|
|
.hdr-btn{
|
|
background:transparent; color:#fff; border:1px solid #6f6f6f;
|
|
padding:7px 14px; font-size:14px;
|
|
}
|
|
.hdr-btn:hover{background:#353535;}
|
|
.hdr-btn.primary{background:var(--interactive); border-color:var(--interactive);}
|
|
.hdr-btn.primary:hover{background:var(--interactive-hover);}
|
|
|
|
/* ===== Tabs ===== */
|
|
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 20px; font-size:15px; color:var(--text-secondary); font-weight:400;
|
|
}
|
|
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:8px; background:var(--gray-tag);
|
|
border-radius:12px; padding:1px 8px; font-size:12px; color:var(--text);
|
|
}
|
|
|
|
main{padding:24px; max-width:1500px; margin:0 auto;}
|
|
.panel{display:none;}
|
|
.panel.active{display:block;}
|
|
.panel-head{margin-bottom:20px;}
|
|
.panel-head h1{font-size:26px; font-weight:400;}
|
|
.panel-head p{color:var(--text-secondary); font-size:15px; margin-top:4px; max-width:900px;}
|
|
|
|
/* ===== Scope Board ===== */
|
|
.tray{
|
|
background:var(--layer); border:1px solid var(--border); border-left:4px solid var(--border-strong);
|
|
padding:16px; margin-bottom:20px;
|
|
}
|
|
.tray h2{font-size:15px; font-weight:600; margin-bottom:4px;}
|
|
.tray .hint{font-size:13px; color:var(--text-helper); margin-bottom:12px;}
|
|
.tray-cards{display:flex; flex-wrap:wrap; gap:10px;}
|
|
.add-row{display:flex; gap:8px; margin-top:14px;}
|
|
.add-row input{
|
|
flex:1; max-width:480px; border:none; border-bottom:1px solid var(--border-strong);
|
|
background:var(--bg); padding:10px 12px; font-size:15px; font-family:inherit;
|
|
}
|
|
.btn{
|
|
background:var(--interactive); color:#fff; border:none; padding:10px 18px; font-size:14px;
|
|
}
|
|
.btn:hover{background:var(--interactive-hover);}
|
|
.btn.ghost{background:transparent; color:var(--interactive); border:1px solid var(--interactive);}
|
|
.btn.ghost:hover{background:#edf5ff;}
|
|
.btn.subtle{background:transparent; color:var(--text-secondary); border:1px solid var(--border-strong); padding:6px 12px; font-size:13px;}
|
|
.btn.subtle:hover{background:var(--layer-hover); color:var(--text);}
|
|
|
|
.board{display:grid; grid-template-columns:repeat(4,1fr); gap:16px;}
|
|
.col{background:var(--layer); border:1px solid var(--border); border-top:4px solid; min-height:220px;}
|
|
.col.mvp{border-top-color:var(--interactive);}
|
|
.col.later{border-top-color:var(--teal);}
|
|
.col.out{border-top-color:var(--danger);}
|
|
.col.park{border-top-color:var(--purple);}
|
|
.col-head{padding:12px 16px; border-bottom:1px solid var(--border); display:flex; align-items:baseline; justify-content:space-between;}
|
|
.col-head h2{font-size:16px; font-weight:600;}
|
|
.col-head .n{font-size:14px; color:var(--text-secondary);}
|
|
.col-body{padding:12px; display:flex; flex-direction:column; gap:10px;}
|
|
.col-sub{font-size:12px; color:var(--text-helper); padding:0 16px 8px;}
|
|
|
|
.card{
|
|
background:var(--bg); border:1px solid var(--border); border-left:3px solid var(--border-strong);
|
|
padding:10px 12px; font-size:14px;
|
|
}
|
|
.card.locked{border-left-color:var(--warning); background:var(--warning-bg);}
|
|
.card .card-title{font-weight:500; margin-bottom:8px; display:flex; align-items:flex-start; gap:6px;}
|
|
.card .lock-tag{
|
|
font-size:10px; letter-spacing:.4px; text-transform:uppercase; color:var(--warning);
|
|
border:1px solid var(--warning); padding:0 5px; border-radius:9px; white-space:nowrap; margin-top:2px;
|
|
}
|
|
.card-actions{display:flex; gap:4px; flex-wrap:wrap;}
|
|
.chip{
|
|
border:1px solid var(--border-strong); background:var(--layer); color:var(--text-secondary);
|
|
font-size:11.5px; padding:3px 8px; letter-spacing:.2px;
|
|
}
|
|
.chip:hover{border-color:var(--text); color:var(--text);}
|
|
.chip.on-mvp{background:var(--interactive); border-color:var(--interactive); color:#fff;}
|
|
.chip.on-later{background:var(--teal); border-color:var(--teal); color:#fff;}
|
|
.chip.on-out{background:var(--danger); border-color:var(--danger); color:#fff;}
|
|
.chip.on-park{background:var(--purple); border-color:var(--purple); color:#fff;}
|
|
.chip.del{border-color:transparent; color:var(--text-helper);}
|
|
.chip.del:hover{color:var(--danger);}
|
|
|
|
/* ===== Ranker ===== */
|
|
.rank-wrap{max-width:900px;}
|
|
.rank-item{
|
|
background:var(--layer); border:1px solid var(--border); margin-bottom:8px;
|
|
display:flex; align-items:center; gap:16px; padding:14px 16px;
|
|
}
|
|
.rank-badge{
|
|
width:40px; height:40px; background:var(--text); color:#fff; font-size:19px; font-weight:600;
|
|
display:flex; align-items:center; justify-content:center; flex-shrink:0;
|
|
}
|
|
.rank-item.top3 .rank-badge{background:var(--interactive);}
|
|
.rank-label{flex:1; font-size:17px;}
|
|
.rank-note{font-size:13px; color:var(--text-helper);}
|
|
.arrow{
|
|
width:40px; height:40px; border:1px solid var(--border-strong); background:var(--layer);
|
|
font-size:17px; color:var(--text-secondary);
|
|
}
|
|
.arrow:hover:not(:disabled){background:var(--layer-hover); color:var(--text);}
|
|
.arrow:disabled{opacity:.25; cursor:default;}
|
|
.cutline{
|
|
border:none; border-top:2px dashed var(--danger); margin:14px 0; position:relative;
|
|
}
|
|
.cutline-label{
|
|
position:absolute; right:0; top:-11px; background:var(--bg); color:var(--danger);
|
|
font-size:12px; font-weight:600; padding:0 8px; letter-spacing:.3px;
|
|
}
|
|
|
|
/* ===== Registry ===== */
|
|
.reg-form{
|
|
background:var(--layer); border:1px solid var(--border); padding:20px; margin-bottom:24px;
|
|
display:grid; grid-template-columns:2fr 1fr 1fr 1fr; gap:16px;
|
|
}
|
|
.reg-form .full{grid-column:1 / -1;}
|
|
.field label{display:block; font-size:12px; color:var(--text-secondary); margin-bottom:6px; letter-spacing:.3px;}
|
|
.field input, .field select, .field textarea{
|
|
width:100%; border:none; border-bottom:1px solid var(--border-strong); background:var(--bg);
|
|
padding:10px 12px; font-size:15px; font-family:inherit; color:var(--text);
|
|
}
|
|
.field textarea{resize:vertical; min-height:44px;}
|
|
.reg-form .form-actions{grid-column:1 / -1; display:flex; gap:12px;}
|
|
|
|
table{width:100%; border-collapse:collapse; background:var(--layer); border:1px solid var(--border);}
|
|
th{
|
|
text-align:left; font-size:13px; font-weight:600; padding:12px 14px;
|
|
background:var(--layer-hover); border-bottom:1px solid var(--border-strong);
|
|
}
|
|
td{padding:12px 14px; border-bottom:1px solid var(--border); font-size:14px; vertical-align:top;}
|
|
tr:last-child td{border-bottom:none;}
|
|
.status-tag{
|
|
display:inline-block; font-size:12px; font-weight:600; padding:3px 10px; border-radius:12px; white-space:nowrap;
|
|
}
|
|
.st-locked{background:#defbe6; color:#0e6027;}
|
|
.st-provisional{background:var(--warning-bg); color:var(--warning);}
|
|
.st-revisit{background:#fff1f1; color:var(--danger);}
|
|
.row-del{background:none; border:none; color:var(--text-helper); font-size:16px;}
|
|
.row-del:hover{color:var(--danger);}
|
|
.empty{
|
|
background:var(--layer); border:1px dashed var(--border-strong); padding:32px; text-align:center;
|
|
color:var(--text-helper); font-size:15px;
|
|
}
|
|
|
|
/* ===== Readiness ===== */
|
|
.ready-wrap{max-width:1100px;}
|
|
.ready-group{margin-bottom:24px;}
|
|
.ready-group h2{font-size:14px; font-weight:600; color:var(--text-secondary); letter-spacing:.4px; text-transform:uppercase; margin-bottom:8px;}
|
|
.ready-item{
|
|
background:var(--layer); border:1px solid var(--border); display:flex; align-items:center;
|
|
gap:16px; padding:12px 16px; margin-bottom:6px;
|
|
}
|
|
.ready-label{flex:1; font-size:15px;}
|
|
.ready-label .why{display:block; font-size:12.5px; color:var(--text-helper); margin-top:2px;}
|
|
.ready-owner{
|
|
width:170px; border:none; border-bottom:1px solid var(--border-strong); background:var(--bg);
|
|
padding:8px 10px; font-size:14px; font-family:inherit;
|
|
}
|
|
.st-btn{
|
|
width:130px; border:1px solid; background:var(--layer); font-size:13px; font-weight:600;
|
|
padding:9px 0; text-align:center;
|
|
}
|
|
.st-btn.s0{border-color:var(--border-strong); color:var(--text-secondary);}
|
|
.st-btn.s1{border-color:var(--interactive); color:var(--interactive); background:#edf5ff;}
|
|
.st-btn.s2{border-color:var(--success); color:#0e6027; background:#defbe6;}
|
|
.st-btn.s3{border-color:var(--danger); color:var(--danger); background:#fff1f1;}
|
|
.ready-summary{
|
|
background:var(--layer); border:1px solid var(--border); padding:14px 18px; margin-bottom:20px;
|
|
display:flex; gap:28px; font-size:14px;
|
|
}
|
|
.ready-summary b{font-size:20px; font-weight:600; display:block;}
|
|
|
|
.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;
|
|
}
|
|
.toast.show{opacity:1;}
|
|
@media (max-width:1100px){ .board{grid-template-columns:repeat(2,1fr);} .reg-form{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>| Scope Lock Meeting Suite</span></div>
|
|
<div class="spacer"></div>
|
|
<div class="meta">Micron Pilot | Innovation Team</div>
|
|
<button class="hdr-btn" id="btnSaveJson">Save JSON</button>
|
|
<button class="hdr-btn" id="btnReset">Reset all</button>
|
|
<button class="hdr-btn primary" id="btnExport">Export meeting summary</button>
|
|
</header>
|
|
|
|
<nav>
|
|
<button class="active" data-panel="board">1. Scope Boundary Board <span class="count" id="cntUnsorted">0</span></button>
|
|
<button data-panel="rank">2. MVP Priority Ranker</button>
|
|
<button data-panel="registry">3. Decision Registry <span class="count" id="cntDecisions">0</span></button>
|
|
<button data-panel="ready">4. Micron Pilot Readiness</button>
|
|
</nav>
|
|
|
|
<main>
|
|
|
|
<!-- ============ PANEL 1: SCOPE BOARD ============ -->
|
|
<section class="panel active" id="panel-board">
|
|
<div class="panel-head">
|
|
<h1>Scope Boundary Board</h1>
|
|
<p>Every item gets a home before the meeting ends. In MVP means the 3 month construction tracking build. Later Phase means committed but sequenced after MVP. Out means not part of Project SDE. Parking Lot means undecided, and each parked item needs an owner and a date in the Decision Registry.</p>
|
|
</div>
|
|
|
|
<div class="tray" id="trayWrap">
|
|
<h2>Unsorted</h2>
|
|
<div class="hint">Work top to bottom. Assign each item with the buttons on its card.</div>
|
|
<div class="tray-cards" id="tray"></div>
|
|
<div class="add-row">
|
|
<input type="text" id="newScopeItem" placeholder="Add a scope item the list is missing" aria-label="New scope item">
|
|
<button class="btn ghost" id="btnAddScope">Add item</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="board">
|
|
<div class="col mvp">
|
|
<div class="col-head"><h2>In MVP</h2><span class="n" id="nMvp">0</span></div>
|
|
<div class="col-sub">Ships in the 3 month build</div>
|
|
<div class="col-body" id="colMvp"></div>
|
|
</div>
|
|
<div class="col later">
|
|
<div class="col-head"><h2>Later Phase</h2><span class="n" id="nLater">0</span></div>
|
|
<div class="col-sub">Committed, sequenced after MVP</div>
|
|
<div class="col-body" id="colLater"></div>
|
|
</div>
|
|
<div class="col out">
|
|
<div class="col-head"><h2>Out</h2><span class="n" id="nOut">0</span></div>
|
|
<div class="col-sub">Not part of Project SDE</div>
|
|
<div class="col-body" id="colOut"></div>
|
|
</div>
|
|
<div class="col park">
|
|
<div class="col-head"><h2>Parking Lot</h2><span class="n" id="nPark">0</span></div>
|
|
<div class="col-sub">Needs owner + date in Registry</div>
|
|
<div class="col-body" id="colPark"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ PANEL 2: RANKER ============ -->
|
|
<section class="panel" id="panel-rank">
|
|
<div class="panel-head">
|
|
<h1>MVP Priority Ranker</h1>
|
|
<p>Forced ranking. If the 3 month timeline slips, items below the cut line are the first to be descoped. Move items with the arrows until the room agrees on the order.</p>
|
|
</div>
|
|
<div class="rank-wrap">
|
|
<div id="rankList"></div>
|
|
<div class="add-row">
|
|
<input type="text" id="newRankItem" placeholder="Add a priority" aria-label="New priority">
|
|
<button class="btn ghost" id="btnAddRank">Add</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ PANEL 3: REGISTRY ============ -->
|
|
<section class="panel" id="panel-registry">
|
|
<div class="panel-head">
|
|
<h1>Decision Registry</h1>
|
|
<p>Log every scope decision as it is made, with the trade off the team accepted. Nothing stays verbal only. Locked means it does not get relitigated without a formal change.</p>
|
|
</div>
|
|
|
|
<div class="reg-form">
|
|
<div class="field full">
|
|
<label>DECISION</label>
|
|
<input type="text" id="dText" placeholder="e.g. Commissioning workflows are out of MVP scope; MVP is construction tracking only">
|
|
</div>
|
|
<div class="field">
|
|
<label>TYPE</label>
|
|
<select id="dType">
|
|
<option>Scope</option><option>Technical</option><option>Process</option><option>Data</option><option>Pilot</option><option>Other</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>OWNER</label>
|
|
<select id="dOwner">
|
|
<option>Nick</option><option>Tim</option><option>Cameron</option><option>Clinton</option>
|
|
<option>Jason</option><option>Wyman</option><option>Ryan</option><option>Brendan</option>
|
|
<option>Lee</option><option>Team</option><option>Other</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>STATUS</label>
|
|
<select id="dStatus">
|
|
<option>Locked</option><option>Provisional</option><option>Revisit</option>
|
|
</select>
|
|
</div>
|
|
<div class="field full">
|
|
<label>RATIONALE</label>
|
|
<input type="text" id="dWhy" placeholder="Why this call was made">
|
|
</div>
|
|
<div class="field full">
|
|
<label>TRADE OFF ACCEPTED</label>
|
|
<input type="text" id="dTrade" placeholder="What we are giving up or risking by deciding this way">
|
|
</div>
|
|
<div class="form-actions">
|
|
<button class="btn" id="btnAddDecision">Log decision</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="regTableWrap"></div>
|
|
</section>
|
|
|
|
<!-- ============ PANEL 4: READINESS ============ -->
|
|
<section class="panel" id="panel-ready">
|
|
<div class="panel-head">
|
|
<h1>Micron Pilot Readiness</h1>
|
|
<p>What must be true at Micron before the pilot can start. Tap the status to cycle it. Anything Blocked at the end of the meeting needs a decision in the Registry.</p>
|
|
</div>
|
|
<div class="ready-wrap">
|
|
<div class="ready-summary">
|
|
<div><b id="rdyReady">0</b>Ready</div>
|
|
<div><b id="rdyProg">0</b>In progress</div>
|
|
<div><b id="rdyBlock">0</b>Blocked</div>
|
|
<div><b id="rdyNot">0</b>Not started</div>
|
|
</div>
|
|
<div id="readyGroups"></div>
|
|
<div class="add-row">
|
|
<input type="text" id="newReadyItem" placeholder="Add a readiness item" aria-label="New readiness item">
|
|
<button class="btn ghost" id="btnAddReady">Add</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</main>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
'use strict';
|
|
/* ================= STATE ================= */
|
|
const KEY = 'sde_scope_lock_v1';
|
|
|
|
const DEFAULT_STATE = {
|
|
scope: [
|
|
// Locked pre-decisions surfaced so the room sees them, not to relitigate them
|
|
{id:'s1', t:'IO list (types + instances, COIN schema)', b:'mvp', lock:true},
|
|
{id:'s2', t:'Cable list', b:'mvp', lock:true},
|
|
{id:'s3', t:'Wire list', b:'mvp', lock:true},
|
|
{id:'s4', t:'Construction status tracking (asset level)', b:'mvp', lock:true},
|
|
{id:'s5', t:'Commissioning / Cx workflows and signoffs', b:'out', lock:true},
|
|
{id:'s6', t:'Blender / 3D visualization app', b:'later', lock:true},
|
|
// Open items, unsorted
|
|
{id:'s7', t:'Mobile field entry (phone / tablet)', b:'', lock:false},
|
|
{id:'s8', t:'Offline mode with sync', b:'', lock:false},
|
|
{id:'s9', t:'Photo attachments on status updates', b:'', lock:false},
|
|
{id:'s10', t:'Progress rollups by system / area', b:'', lock:false},
|
|
{id:'s11', t:'Weighted progress rules (per asset class / stage)', b:'', lock:false},
|
|
{id:'s12', t:'Plan vs actual variance reporting', b:'', lock:false},
|
|
{id:'s13', t:'Standup / weekly progress report export', b:'', lock:false},
|
|
{id:'s14', t:'Role based access (PM / Field / QC / Admin)', b:'', lock:false},
|
|
{id:'s15', t:'QC hold points inside construction tracking', b:'', lock:false},
|
|
{id:'s16', t:'IWP / work package linkage (AWP alignment)', b:'', lock:false},
|
|
{id:'s17', t:'controls.dev API read/write for field updates', b:'', lock:false},
|
|
{id:'s18', t:'PrimeFlow import (legacy IO data)', b:'', lock:false},
|
|
{id:'s19', t:'Acumatica integration (cost codes / hours)', b:'', lock:false},
|
|
{id:'s20', t:'Parts / device library management', b:'', lock:false},
|
|
{id:'s21', t:'Panel / termination level granularity', b:'', lock:false},
|
|
{id:'s22', t:'Dashboards with R/Y/G indicators', b:'', lock:false},
|
|
{id:'s23', t:'Labor / crew hours tracking', b:'', lock:false},
|
|
{id:'s24', t:'GC / owner tool data exchange (Micron side)', b:'', lock:false},
|
|
{id:'s25', t:'Project templates and cloning', b:'', lock:false},
|
|
{id:'s26', t:'Audit trail (user / timestamp on every update)', b:'', lock:false},
|
|
{id:'s27', t:'Change / revision handling for engineering data', b:'', lock:false},
|
|
{id:'s28', t:'Excel import/export round trip', b:'', lock:false},
|
|
{id:'s29', t:'Training materials and quick start guides', b:'', lock:false},
|
|
{id:'s30', t:'In app notifications / @mentions', b:'', lock:false}
|
|
],
|
|
rank: [
|
|
{id:'r1', t:'Mobile field entry', n:'Named top priority in prior sessions'},
|
|
{id:'r2', t:'Reporting (standup + rollups)', n:''},
|
|
{id:'r3', t:'Integration and phasing (controls.dev sync)', n:''},
|
|
{id:'r4', t:'Role based access', n:''},
|
|
{id:'r5', t:'Ease of training and adoption', n:''},
|
|
{id:'r6', t:'Plan vs actual variance', n:''},
|
|
{id:'r7', t:'Weighted progress rules', n:''}
|
|
],
|
|
cutAfter: 5,
|
|
decisions: [],
|
|
ready: [
|
|
{id:'d1', g:'Data', t:'Micron IO list source identified and format confirmed', w:'Which system of record, which export format, who provides it', o:'', s:0},
|
|
{id:'d2', g:'Data', t:'Cable and wire list data availability confirmed', w:'', o:'', s:0},
|
|
{id:'d3', g:'Data', t:'Asset hierarchy agreed (Building > System > Panel > Device > Termination)', w:'Depth of tracking granularity for the pilot', o:'', s:0},
|
|
{id:'d4', g:'Data', t:'Import format and refresh cadence agreed with engineering', w:'One time load vs live sync for the pilot', o:'', s:0},
|
|
{id:'d5', g:'Platform', t:'controls.dev instance provisioned for Micron project', w:'', o:'Cameron', s:0},
|
|
{id:'d6', g:'Platform', t:'API endpoints ready for field status writes', w:'', o:'Cameron', s:0},
|
|
{id:'d7', g:'Platform', t:'Role definitions configured (PM / Field / QC / Admin)', w:'', o:'', s:0},
|
|
{id:'d8', g:'Field', t:'Field devices confirmed (tablets / phones, count and type)', w:'', o:'Wyman', s:0},
|
|
{id:'d9', g:'Field', t:'Fab site network / device security constraints understood', w:'Semiconductor site restrictions on devices, cameras, connectivity', o:'Wyman', s:0},
|
|
{id:'d10', g:'Field', t:'Field team availability windows confirmed with Micron schedule', w:'', o:'Wyman', s:0},
|
|
{id:'d11', g:'People', t:'Pilot cohort named, including at least one named skeptic', w:'Change management framework requirement', o:'Nick', s:0},
|
|
{id:'d12', g:'People', t:'Friction routing loop defined (who owns fixing what the field reports)', w:'', o:'', s:0},
|
|
{id:'d13', g:'People', t:'Training approach and materials scoped', w:'', o:'', s:0},
|
|
{id:'d14', g:'Governance', t:'Pilot success metrics defined (behavioral, not sentiment)', w:'Update compliance, entry latency, duplicate entry eliminated; not satisfaction scores', o:'Nick', s:0},
|
|
{id:'d15', g:'Governance', t:'Explicit stop path defined (what triggers pausing the pilot)', w:'', o:'', s:0},
|
|
{id:'d16', g:'Governance', t:'Baseline current state process documented before go live', w:'Avoids reconciliation gap with change management framework', o:'', s:0}
|
|
]
|
|
};
|
|
|
|
let S = load();
|
|
|
|
function load(){
|
|
try{
|
|
const raw = localStorage.getItem(KEY);
|
|
if(raw) return JSON.parse(raw);
|
|
}catch(e){}
|
|
return JSON.parse(JSON.stringify(DEFAULT_STATE));
|
|
}
|
|
function save(){
|
|
try{ localStorage.setItem(KEY, JSON.stringify(S)); }catch(e){}
|
|
}
|
|
function uid(){ return 'x' + Math.random().toString(36).slice(2,9); }
|
|
function esc(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
|
|
/* ================= TABS ================= */
|
|
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');
|
|
});
|
|
});
|
|
|
|
/* ================= SCOPE BOARD ================= */
|
|
const BUCKETS = [
|
|
{k:'mvp', label:'MVP', el:'colMvp', n:'nMvp'},
|
|
{k:'later', label:'Later', el:'colLater', n:'nLater'},
|
|
{k:'out', label:'Out', el:'colOut', n:'nOut'},
|
|
{k:'park', label:'Park', el:'colPark', n:'nPark'}
|
|
];
|
|
|
|
function cardHTML(item){
|
|
const chips = BUCKETS.map(b=>{
|
|
const on = item.b === b.k ? ' on-' + b.k : '';
|
|
return `<button class="chip${on}" data-id="${item.id}" data-b="${b.k}">${b.label}</button>`;
|
|
}).join('');
|
|
const lockTag = item.lock ? '<span class="lock-tag">Pre locked</span>' : '';
|
|
const del = item.lock ? '' : `<button class="chip del" data-del="${item.id}" title="Remove item">✕</button>`;
|
|
return `<div class="card${item.lock?' locked':''}">
|
|
<div class="card-title"><span>${esc(item.t)}</span>${lockTag}</div>
|
|
<div class="card-actions">${chips}${del}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderScope(){
|
|
const tray = document.getElementById('tray');
|
|
const unsorted = S.scope.filter(i=>!i.b);
|
|
tray.innerHTML = unsorted.length ? unsorted.map(cardHTML).join('') : '<span style="color:var(--text-helper);font-size:14px;">All items sorted. Nice work.</span>';
|
|
document.getElementById('cntUnsorted').textContent = unsorted.length;
|
|
BUCKETS.forEach(b=>{
|
|
const items = S.scope.filter(i=>i.b===b.k);
|
|
document.getElementById(b.el).innerHTML = items.map(cardHTML).join('');
|
|
document.getElementById(b.n).textContent = items.length;
|
|
});
|
|
}
|
|
|
|
document.getElementById('panel-board').addEventListener('click', e=>{
|
|
const t = e.target;
|
|
if(t.dataset && t.dataset.b && t.dataset.id){
|
|
const item = S.scope.find(i=>i.id===t.dataset.id);
|
|
if(item){ item.b = (item.b === t.dataset.b) ? '' : t.dataset.b; save(); renderScope(); }
|
|
}
|
|
if(t.dataset && t.dataset.del){
|
|
S.scope = S.scope.filter(i=>i.id!==t.dataset.del);
|
|
save(); renderScope();
|
|
}
|
|
});
|
|
|
|
document.getElementById('btnAddScope').addEventListener('click', ()=>{
|
|
const inp = document.getElementById('newScopeItem');
|
|
const v = inp.value.trim();
|
|
if(!v) return;
|
|
S.scope.push({id:uid(), t:v, b:'', lock:false});
|
|
inp.value=''; save(); renderScope();
|
|
});
|
|
document.getElementById('newScopeItem').addEventListener('keydown', e=>{ if(e.key==='Enter') document.getElementById('btnAddScope').click(); });
|
|
|
|
/* ================= RANKER ================= */
|
|
function renderRank(){
|
|
const wrap = document.getElementById('rankList');
|
|
let html = '';
|
|
S.rank.forEach((item, i)=>{
|
|
if(i === S.cutAfter){
|
|
html += `<hr class="cutline"><div style="position:relative;"><span class="cutline-label" style="position:absolute; right:0; top:-25px;">DESCOPE LINE: below here goes first if the timeline slips</span></div>`;
|
|
}
|
|
html += `<div class="rank-item${i<3?' top3':''}">
|
|
<div class="rank-badge">${i+1}</div>
|
|
<div class="rank-label">${esc(item.t)}${item.n?`<div class="rank-note">${esc(item.n)}</div>`:''}</div>
|
|
<button class="arrow" data-up="${i}" ${i===0?'disabled':''} title="Move up">▲</button>
|
|
<button class="arrow" data-down="${i}" ${i===S.rank.length-1?'disabled':''} title="Move down">▼</button>
|
|
<button class="chip del" data-rdel="${i}" title="Remove">✕</button>
|
|
</div>`;
|
|
});
|
|
html += `<div style="margin-top:16px; font-size:13px; color:var(--text-helper);">
|
|
Descope line sits after position ${S.cutAfter}.
|
|
<button class="btn subtle" id="cutUp" style="margin-left:10px;">Move line up</button>
|
|
<button class="btn subtle" id="cutDown">Move line down</button>
|
|
</div>`;
|
|
wrap.innerHTML = html;
|
|
document.getElementById('cutUp').addEventListener('click', ()=>{ if(S.cutAfter>1){S.cutAfter--; save(); renderRank();} });
|
|
document.getElementById('cutDown').addEventListener('click', ()=>{ if(S.cutAfter<S.rank.length){S.cutAfter++; save(); renderRank();} });
|
|
}
|
|
|
|
document.getElementById('rankList').addEventListener('click', e=>{
|
|
const t = e.target;
|
|
if(t.dataset.up !== undefined){
|
|
const i = +t.dataset.up;
|
|
[S.rank[i-1], S.rank[i]] = [S.rank[i], S.rank[i-1]];
|
|
save(); renderRank();
|
|
} else if(t.dataset.down !== undefined){
|
|
const i = +t.dataset.down;
|
|
[S.rank[i], S.rank[i+1]] = [S.rank[i+1], S.rank[i]];
|
|
save(); renderRank();
|
|
} else if(t.dataset.rdel !== undefined){
|
|
S.rank.splice(+t.dataset.rdel, 1);
|
|
if(S.cutAfter > S.rank.length) S.cutAfter = S.rank.length;
|
|
save(); renderRank();
|
|
}
|
|
});
|
|
|
|
document.getElementById('btnAddRank').addEventListener('click', ()=>{
|
|
const inp = document.getElementById('newRankItem');
|
|
const v = inp.value.trim();
|
|
if(!v) return;
|
|
S.rank.push({id:uid(), t:v, n:''});
|
|
inp.value=''; save(); renderRank();
|
|
});
|
|
document.getElementById('newRankItem').addEventListener('keydown', e=>{ if(e.key==='Enter') document.getElementById('btnAddRank').click(); });
|
|
|
|
/* ================= REGISTRY ================= */
|
|
function renderRegistry(){
|
|
const wrap = document.getElementById('regTableWrap');
|
|
document.getElementById('cntDecisions').textContent = S.decisions.length;
|
|
if(!S.decisions.length){
|
|
wrap.innerHTML = '<div class="empty">No decisions logged yet. The first one usually takes two minutes; the rest take thirty seconds.</div>';
|
|
return;
|
|
}
|
|
const rows = S.decisions.map((d,i)=>`<tr>
|
|
<td style="color:var(--text-helper);">${i+1}</td>
|
|
<td style="font-weight:500;">${esc(d.text)}</td>
|
|
<td>${esc(d.type)}</td>
|
|
<td>${esc(d.owner)}</td>
|
|
<td>${esc(d.why)}</td>
|
|
<td>${esc(d.trade)}</td>
|
|
<td><span class="status-tag st-${d.status.toLowerCase()}">${esc(d.status)}</span></td>
|
|
<td><button class="row-del" data-ddel="${i}" title="Remove">✕</button></td>
|
|
</tr>`).join('');
|
|
wrap.innerHTML = `<table>
|
|
<thead><tr><th>#</th><th>Decision</th><th>Type</th><th>Owner</th><th>Rationale</th><th>Trade off</th><th>Status</th><th></th></tr></thead>
|
|
<tbody>${rows}</tbody></table>`;
|
|
}
|
|
|
|
document.getElementById('btnAddDecision').addEventListener('click', ()=>{
|
|
const text = document.getElementById('dText').value.trim();
|
|
if(!text) return;
|
|
S.decisions.push({
|
|
text,
|
|
type: document.getElementById('dType').value,
|
|
owner: document.getElementById('dOwner').value,
|
|
status: document.getElementById('dStatus').value,
|
|
why: document.getElementById('dWhy').value.trim(),
|
|
trade: document.getElementById('dTrade').value.trim(),
|
|
at: new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})
|
|
});
|
|
['dText','dWhy','dTrade'].forEach(id=>document.getElementById(id).value='');
|
|
save(); renderRegistry(); toast('Decision logged');
|
|
});
|
|
|
|
document.getElementById('regTableWrap').addEventListener('click', e=>{
|
|
if(e.target.dataset.ddel !== undefined){
|
|
S.decisions.splice(+e.target.dataset.ddel, 1);
|
|
save(); renderRegistry();
|
|
}
|
|
});
|
|
|
|
/* ================= READINESS ================= */
|
|
const RSTATES = ['Not started','In progress','Ready','Blocked'];
|
|
function renderReady(){
|
|
const groups = [...new Set(S.ready.map(i=>i.g))];
|
|
const wrap = document.getElementById('readyGroups');
|
|
wrap.innerHTML = groups.map(g=>{
|
|
const items = S.ready.filter(i=>i.g===g).map(item=>`
|
|
<div class="ready-item">
|
|
<div class="ready-label">${esc(item.t)}${item.w?`<span class="why">${esc(item.w)}</span>`:''}</div>
|
|
<input class="ready-owner" data-own="${item.id}" value="${esc(item.o)}" placeholder="Owner">
|
|
<button class="st-btn s${item.s}" data-cyc="${item.id}">${RSTATES[item.s]}</button>
|
|
</div>`).join('');
|
|
return `<div class="ready-group"><h2>${esc(g)}</h2>${items}</div>`;
|
|
}).join('');
|
|
const c = [0,0,0,0];
|
|
S.ready.forEach(i=>c[i.s]++);
|
|
document.getElementById('rdyNot').textContent = c[0];
|
|
document.getElementById('rdyProg').textContent = c[1];
|
|
document.getElementById('rdyReady').textContent = c[2];
|
|
document.getElementById('rdyBlock').textContent = c[3];
|
|
}
|
|
|
|
document.getElementById('readyGroups').addEventListener('click', e=>{
|
|
if(e.target.dataset.cyc){
|
|
const item = S.ready.find(i=>i.id===e.target.dataset.cyc);
|
|
if(item){ item.s = (item.s + 1) % 4; save(); renderReady(); }
|
|
}
|
|
});
|
|
document.getElementById('readyGroups').addEventListener('change', e=>{
|
|
if(e.target.dataset.own){
|
|
const item = S.ready.find(i=>i.id===e.target.dataset.own);
|
|
if(item){ item.o = e.target.value.trim(); save(); }
|
|
}
|
|
});
|
|
|
|
document.getElementById('btnAddReady').addEventListener('click', ()=>{
|
|
const inp = document.getElementById('newReadyItem');
|
|
const v = inp.value.trim();
|
|
if(!v) return;
|
|
S.ready.push({id:uid(), g:'Added in meeting', t:v, w:'', o:'', s:0});
|
|
inp.value=''; save(); renderReady();
|
|
});
|
|
document.getElementById('newReadyItem').addEventListener('keydown', e=>{ if(e.key==='Enter') document.getElementById('btnAddReady').click(); });
|
|
|
|
/* ================= SAVE JSON (raw data dump for migration/import) ================= */
|
|
document.getElementById('btnSaveJson').addEventListener('click', ()=>{
|
|
const stamp = new Date().toISOString().slice(0,10);
|
|
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_Scope_Lock_state_${stamp}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
toast('State saved to JSON');
|
|
});
|
|
|
|
/* ================= EXPORT ================= */
|
|
document.getElementById('btnExport').addEventListener('click', ()=>{
|
|
const d = new Date();
|
|
const stamp = d.toISOString().slice(0,10);
|
|
let md = `# Project SDE Scope Lock Meeting Summary\n\nDate: ${d.toLocaleDateString()} \nPilot: Micron \nGenerated from the Scope Lock Meeting Suite\n\n`;
|
|
|
|
md += `## 1. Scope Boundary Decisions\n\n`;
|
|
const bl = {mvp:'In MVP (3 month construction tracking build)', later:'Later Phase', out:'Out of Project SDE', park:'Parking Lot (needs owner + date)'};
|
|
for(const k of ['mvp','later','out','park']){
|
|
const items = S.scope.filter(i=>i.b===k);
|
|
md += `### ${bl[k]}\n`;
|
|
md += items.length ? items.map(i=>`- ${i.t}${i.lock?' (pre locked)':''}`).join('\n') + '\n\n' : '- None\n\n';
|
|
}
|
|
const uns = S.scope.filter(i=>!i.b);
|
|
if(uns.length){
|
|
md += `### UNRESOLVED (never sorted)\n` + uns.map(i=>`- ${i.t}`).join('\n') + '\n\n';
|
|
}
|
|
|
|
md += `## 2. MVP Priority Ranking\n\nDescope line after position ${S.cutAfter}. Items below the line are descoped first if the timeline slips.\n\n`;
|
|
S.rank.forEach((r,i)=>{
|
|
md += `${i+1}. ${r.t}${i+1 === S.cutAfter ? '\n---- DESCOPE LINE ----' : ''}\n`;
|
|
});
|
|
md += '\n';
|
|
|
|
md += `## 3. Decision Registry\n\n`;
|
|
if(S.decisions.length){
|
|
md += `| # | Decision | Type | Owner | Rationale | Trade off | Status | Time |\n|---|---|---|---|---|---|---|---|\n`;
|
|
S.decisions.forEach((x,i)=>{
|
|
const c = v => String(v||'').replace(/\|/g,'/');
|
|
md += `| ${i+1} | ${c(x.text)} | ${x.type} | ${x.owner} | ${c(x.why)} | ${c(x.trade)} | ${x.status} | ${x.at||''} |\n`;
|
|
});
|
|
md += '\n';
|
|
} else {
|
|
md += 'No decisions were logged.\n\n';
|
|
}
|
|
|
|
md += `## 4. Micron Pilot Readiness\n\n`;
|
|
const groups = [...new Set(S.ready.map(i=>i.g))];
|
|
groups.forEach(g=>{
|
|
md += `### ${g}\n`;
|
|
S.ready.filter(i=>i.g===g).forEach(i=>{
|
|
md += `- [${RSTATES[i.s]}] ${i.t}${i.o?` (Owner: ${i.o})`:''}\n`;
|
|
});
|
|
md += '\n';
|
|
});
|
|
const blocked = S.ready.filter(i=>i.s===3);
|
|
if(blocked.length){
|
|
md += `### Blocked items requiring decisions\n` + blocked.map(i=>`- ${i.t}${i.o?` (Owner: ${i.o})`:''}`).join('\n') + '\n';
|
|
}
|
|
|
|
const blob = new Blob([md], {type:'text/markdown'});
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = `SDE_Scope_Lock_Summary_${stamp}.md`;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
toast('Summary downloaded');
|
|
});
|
|
|
|
/* ================= RESET ================= */
|
|
document.getElementById('btnReset').addEventListener('click', ()=>{
|
|
if(confirm('Reset all four tools to their starting state? This clears everything entered in this meeting.')){
|
|
S = JSON.parse(JSON.stringify(DEFAULT_STATE));
|
|
save(); renderAll(); toast('Reset complete');
|
|
}
|
|
});
|
|
|
|
/* ================= TOAST ================= */
|
|
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'), 2000);
|
|
}
|
|
|
|
function renderAll(){ renderScope(); renderRank(); renderRegistry(); renderReady(); }
|
|
renderAll();
|
|
</script>
|
|
</body>
|
|
</html>
|