From 5d27a1e086cc2c192fa5f0573a5dee210bfd00e5 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Wed, 19 Aug 2026 10:28:02 -0700 Subject: [PATCH] T7.5 - A6: the sidebar answers the stand-up question Use case from the task: someone is asked in a stand-up why a package has not moved; they open it on a phone and need the answer without scrolling or clicking. The navigator row now carries: - a triage line: status - priority - due date - P6 activity, with an em dash for anything unset (a placeholder is information; a gap is a question) - the open-constraint count (already in the state chip; on a held row it moves into the hold line so it is never displaced by "on hold") - the hold reason INLINE, from the newest live entry in data.holds - the modal captured it at T7.3, so this is display work, exactly as the task said. A held package with no recorded entry (legacy data) says "no reason recorded - log it from the status control" rather than rendering an empty red slot. The row's title attribute keeps its hover summary, but hover stops being the only path to any of this (C1 - Field View runs on tablets). Triage and reason lines WRAP instead of ellipsizing - an ellipsis would hide exactly the data the row exists to show; the reason clamps at three lines so one essay cannot swallow the panel. Rows align flex-start to take the extra height. At 390px the panel is the existing overlay drawer; the row fits it with no sideways overflow and stays a >= 44px tap target. Verification (each probe run alone): NEW tests/triage_check.py 16/16 covering the held/plain/legacy row matrix at 1440px and 390px. Regression: frame_check 39/39. Items: A6 Co-Authored-By: Claude Fable 5 --- docs/reference/file-map.md | 1 + html/wp-creation-app.js | 18 +++ html/wp-creation-styles.css | 12 ++ tests/triage_check.py | 224 ++++++++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 tests/triage_check.py diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index e157e8b..a3939b2 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -279,6 +279,7 @@ python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open section 51 checks python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks +python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks ``` **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index f95aec9..f922db1 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -2247,12 +2247,30 @@ function renderWpNav(){ : (open ? open + ' open' : (waiting ? 'waits on ' + waiting : 'ready')); const active = (editingId && p.id === editingId) ? ' active' : ''; const title = (p.number || '') + ' — ' + (p.subject || '') + ' · ' + (p.status || '') + ' · ' + state; + // A6: the stand-up answer, inline. Status, priority, due date, P6 activity + // and the open count all on the row; a held package says WHY it is held, + // from the newest live hold entry - the modal captured it, so display it. + // The title attribute above keeps the hover summary, but hover is never + // the only path (C1: Field View runs on tablets). + const triage = esc(p.status || 'Draft') + ' · ' + esc(wpPriorityOf(p)) + + ' · due ' + esc(p.due || '—') + ' · P6 ' + esc(p.p6Id || '—'); + let holdLine = ''; + if (p.status === 'Issue') { + const live = (p.holds || []).filter(h => h && !h.released); + const h = live[live.length - 1]; + const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '') + : 'no reason recorded — log it from the status control'; + holdLine = '⛔ ' + (open ? open + ' open — ' : '') + + esc(why) + ''; + } return ''; diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index 5934fce..9a3a2fc 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -882,6 +882,18 @@ body { --nav-w: 288px; } body.wp-nav-collapsed { --nav-w: 56px; } + /* A6: the triage line and the inline hold reason. These WRAP - an ellipsis + here would hide exactly the data the row exists to show. The hold reason is + clamped at three lines so one essay of a reason cannot swallow the panel. */ + .wp-nav-item { align-items: flex-start; } + .wp-nav-item .wp-nav-badge { margin-top: 2px; } + .wp-nav-triage { display: block; font-size: 10.5px; line-height: 1.5; + color: var(--text-muted); white-space: normal; margin-top: 1px; } + .wp-nav-hold { display: -webkit-box; -webkit-line-clamp: 3; + -webkit-box-orient: vertical; overflow: hidden; + font-size: 10.5px; line-height: 1.45; color: var(--red); + white-space: normal; margin-top: 2px; } + /* -- collapse toggle -- */ .wp-nav-top { display: flex; align-items: center; padding: 8px 10px 2px; } .wp-nav-toggle { diff --git a/tests/triage_check.py b/tests/triage_check.py new file mode 100644 index 0000000..1ce1c8a --- /dev/null +++ b/tests/triage_check.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Can the sidebar answer the stand-up question? — A6, T7.5. + +The use case in the task: someone is asked in a stand-up why a package has not +moved. They open it on a phone and need the answer WITHOUT scrolling or +clicking — so the hold reason, captured by the hold modal since T7.3, has to be +readable straight off the navigator row, along with status, priority, due date, +P6 activity and the open-constraint count. + +Three package shapes cover the matrix: + A — on hold, reason recorded (the stand-up case) + B — a plain draft (no hold: the row must look sensible, not have empty slots) + C — on hold the legacy way, holds:[] (the row must say the reason is missing, + not render an empty red line) + +Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome. +Exit 0 all passed, 1 a failure, 2 could not run. +""" +import json +import os +import re +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import cdp # noqa: E402 +from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402 +from sections_check import set_sop # noqa: E402 +from stepper_check import dismiss_dialogs # noqa: E402 + +REASON = "Boom lift recalled for inspection" + + +def ascii_(v, n=300): + return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n] + + +def settle(seconds=0.6): + time.sleep(seconds) + + +def wait_creator(page, tries=40): + for _ in range(tries): + if page.eval("!!window.wpCreatorReady"): + return True + time.sleep(0.3) + return False + + +def constraint_btn(page, index, which): + labels = {"open": "Open", "cleared": "Cleared", "na": "N/A"} + page.eval("""(() => { + const tr = document.querySelectorAll('#constraint-body tr')[%d]; + [...tr.querySelectorAll('.cstatus button')] + .find(b => b.textContent.trim() === %s).click(); + })()""" % (index, json.dumps(labels[which]))) + settle(0.4) + + +def click_status(page, val): + page.eval("document.querySelector('#status-group .radio-pill[data-val=%s]').click()" + % json.dumps(val)) + settle(0.4) + + +def set_field(page, fid, val): + page.eval("(() => { const el = document.getElementById(%s); el.value = %s; " + "el.dispatchEvent(new Event('input', {bubbles: true})); })()" + % (json.dumps(fid), json.dumps(val))) + + +def nav_row(page, number): + """The navigator row for a package number, as data.""" + return json.loads(page.eval("""JSON.stringify((() => { + const row = [...document.querySelectorAll('.wp-nav-item')] + .find(b => (b.querySelector('.wp-nav-num')||{textContent:''}).textContent === %s); + if (!row) return null; + const g = sel => { const e = row.querySelector(sel); return e ? e.textContent : null; }; + const r = row.getBoundingClientRect(); + return {triage: g('.wp-nav-triage'), hold: g('.wp-nav-hold'), + state: g('.wp-nav-state'), subj: g('.wp-nav-subj'), + h: r.height, scrollW: row.scrollWidth, clientW: row.clientWidth}; + })())""" % json.dumps(number))) + + +def main(): + exe = cdp.find_browser() + if not exe: + print("no headless-capable browser found; set WP_BROWSER.") + return 2 + + tmpdir = tempfile.mkdtemp(prefix="wpsuite-triage-") + db_path = os.path.join(tmpdir, "check.db") + server = None + browser = None + try: + tok = seed(db_path) + set_sop(db_path, {}) + port = cdp.free_port() + base = "http://127.0.0.1:%d" % port + server = start_server(port, db_path) + + browser = cdp.Browser(exe) + page = browser.page() + page.clear_cookies() + page.set_cookie("wp_session", tok["root"]) + page.viewport(1440, 900) + page.goto(base + "/wp-creation-index.html?project=projA") + dismiss_dialogs(page) + chk("the creator boots", wait_creator(page)) + settle(1.5) + page.eval("window.prompt=()=>null; window.alert=()=>{}; window.confirm=()=>false;") + + # ── build package A: on hold, with a reason ─────────────────────────── + print("\n1. package A: held, reason recorded (the stand-up case)") + set_field(page, "wp_subject", "Horn strobe conduit") + set_field(page, "wp_type", "Conduit Install") + set_field(page, "wp_priority", "High") + set_field(page, "wp_due", "2026-09-01") + set_field(page, "wp_p6_id", "A-1040") + n = page.eval("pkgConstraints.length") + for i in range(n): + constraint_btn(page, i, "cleared") + click_status(page, "In Progress") + constraint_btn(page, 0, "open") # opens the hold modal + page.eval("document.getElementById('hold-details').value=%s" % json.dumps(REASON)) + page.eval("submitHold()") + settle(0.4) + page.eval("savePackage(false)") + settle(0.8) + numA = page.eval("(savedPackages[savedPackages.length-1]||{}).number||''") + row = nav_row(page, numA) + chk("the held package renders a row in the sidebar", row is not None, numA) + chk("the hold reason is ON the row — no modal, no hover, no click", + row and REASON in (row["hold"] or ""), ascii_(row)) + chk("...and the row names the constraint it hangs on", + row and "Construction Equipment" in (row["hold"] or "") + or (row and (row["hold"] or "").count(":") >= 1), ascii_(row and row["hold"])) + chk("no modal is open while all of that is readable", + page.eval("!document.querySelector('#hold-modal.open')")) + tri = (row or {}).get("triage") or "" + chk("status, priority, due date and P6 activity are all on the row together", + "Issue" in tri and "High" in tri and "2026-09-01" in tri and "A-1040" in tri, + ascii_(tri)) + chk("...and the open-constraint count is on the row too", + row and re.search(r"1 open", (row["hold"] or "") + " " + (row["state"] or "")), + ascii_(row)) + + # ── package B: a plain draft ────────────────────────────────────────── + print("\n2. package B: nothing held, nothing weird") + page.eval("newPackage()") + settle(0.6) + set_field(page, "wp_subject", "Wire pull") + set_field(page, "wp_type", "Conduit Install") + page.eval("savePackage(false)") + settle(0.8) + numB = page.eval("(savedPackages[savedPackages.length-1]||{}).number||''") + row_b = nav_row(page, numB) + chk("the no-hold package has NO hold line at all — not an empty red slot", + row_b is not None and row_b["hold"] is None, ascii_(row_b)) + chk("...its state still says something (ready / N open), never a blank", + row_b and (row_b["state"] or "").strip() != "", ascii_(row_b)) + tri_b = (row_b or {}).get("triage") or "" + chk("...and its triage line shows placeholders, not gaps, for unset fields", + "Draft" in tri_b and "—" in tri_b and "Normal" in tri_b, ascii_(tri_b)) + + # ── package C: held the legacy way, holds:[] ────────────────────────── + print("\n3. package C: a legacy hold with no entry") + page.eval("""(() => { + const p = collectPackage(); + p.id = 'wp_legacy_probe'; p.number = 'WP99-LEGACY'; + p.subject = 'Legacy hold'; p.status = 'Issue'; p.holds = []; + p.constraints[0].status = 'open'; + savedPackages.push(p); renderWpNav(); + })()""") + settle(0.5) + row_c = nav_row(page, "WP99-LEGACY") + chk("a hold with no recorded entry says so instead of rendering nothing", + row_c and "no reason recorded" in (row_c["hold"] or ""), ascii_(row_c)) + + # ── 390px: the phone in the stand-up ────────────────────────────────── + print("\n4. 390px") + page.viewport(390, 900, mobile=True) + settle(0.8) + page.eval("openWpNav()") + settle(0.6) + row = nav_row(page, numA) + chk("390px: the held row renders in the opened panel", row is not None + and row["h"] > 0, ascii_(row)) + chk("390px: the reason is still readable there", row + and REASON in (row["hold"] or ""), ascii_(row)) + chk("390px: the row does not overflow its panel sideways", + row and row["scrollW"] <= row["clientW"] + 2, ascii_(row)) + chk("390px: the row is a comfortable tap target (>= 44px tall)", + row and row["h"] >= 44, ascii_(row)) + + js_errors = [e for e in page.js_errors()] + chk("no JavaScript errors anywhere in this run", not js_errors, + ascii_(js_errors[:2])) + + finally: + if browser is not None: + try: + browser.close() + except Exception: + pass + if server is not None: + try: + server.terminate() + except Exception: + pass + + print("\n" + "-" * 54) + print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL))) + for f in _FAIL: + print(" - " + f) + return 1 if _FAIL else 0 + + +if __name__ == "__main__": + sys.exit(main())