Completing T1.1. My first verification primed localStorage before loading each
page, which made both sources of truth agree and hid two remaining cases. Re-run
with genuinely cold storage, the app bar still showed "(unnamed)" on the field
view and "Select a project" on the console pages.
Two causes, both the same F1 shape - a page holding a copy the shared store
does not have:
1. field.js could only write {id} at boot (it needs the id synchronously, for
the per-project storage namespace), then resolved the full record into a
local PROJECT variable, rendered "Project: Job A" from it, and never
published it. The store kept the stub, so the bar read "(unnamed)".
setActive now fills a nameless record in from the cached project list, or
from the API when the cache has not loaded yet, and re-checks the id before
applying a slow response so it cannot overwrite a project the user has since
switched to. That fixes every caller of this shape rather than the one that
was caught - work-package-suite-app.js and wp-creation-app.js write the same
stub. field.js also publishes the record it already fetched, so the common
path costs no extra request.
2. admin.html and users.html have no project-resolution logic of their own, so
nothing read ?project= and a deep link left the bar on whatever was last
stored. The bar is the one component every chromed page has, so it resolves
the parameter once in wp-chrome.js rather than being taught to five pages.
Verified with localStorage cleared before every navigation: a cold deep link
now shows the project on field, SOP wizard, launcher, admin and users, and the
stored record carries the name rather than a stub.
The creator remains the one page with no app bar - it loads no chrome because
it renders as the iframe child. T7.1.
browser_check 71/71. f_items F1 FIXED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
187 lines
11 KiB
JavaScript
187 lines
11 KiB
JavaScript
/* Field view — a touch-optimized screen for updating a Work Package's status,
|
||
constraints, and a photo/note log from the work face. Reads the same shared
|
||
data as the desktop creator (via project-data.js) and saves through the sync
|
||
outbox, so it works offline and syncs when the network returns. */
|
||
'use strict';
|
||
|
||
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
|
||
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
|
||
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
|
||
|
||
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
||
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
|
||
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
|
||
// Predecessor packages that aren't Closed yet. A package waiting on upstream work
|
||
// is not release-ready either, so the field list must not call it Ready — the
|
||
// server would refuse to issue it (see enforce_release_gates).
|
||
function waitingCount(p, all) {
|
||
var preds = (p && p.predecessors) || [];
|
||
if (!preds.length) return 0;
|
||
var byId = {};
|
||
(all || []).forEach(function (x) { byId[x.id] = x; });
|
||
return preds.filter(function (id) { var q = byId[id]; return q && q.status !== 'Closed'; }).length;
|
||
}
|
||
function fmtTs(s) { try { return wpFormatDateTime(s); } catch (e) { return s || ''; } }
|
||
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
|
||
function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }
|
||
|
||
// ── boot / data ──────────────────────────────────────────────────────────────
|
||
function boot() {
|
||
var params = new URLSearchParams(location.search);
|
||
PID = params.get('project') || (ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||
if (!PID) { showNoProject(); return; }
|
||
if (ProjectData.getActiveId && ProjectData.getActiveId() !== PID) { try { ProjectData.setActive({ id: PID }); } catch (e) {} }
|
||
// Publish the resolved record rather than keeping it to ourselves: line 34 could
|
||
// only write the id, and the app bar reads the shared record, not PROJECT.
|
||
if (ProjectData.get) {
|
||
ProjectData.get(PID).then(function (p) {
|
||
PROJECT = p;
|
||
if (p && p.name) { try { ProjectData.setActive(p); } catch (e) {} }
|
||
renderCtx();
|
||
}).catch(function () {});
|
||
}
|
||
loadWPs();
|
||
}
|
||
function renderCtx() {
|
||
var el = document.getElementById('fld-ctx'); if (!el) return;
|
||
if (PROJECT) el.innerHTML = 'Project: <b>' + esc(PROJECT.name || '') + '</b>' + (PROJECT.number ? ' · ' + esc(PROJECT.number) : '');
|
||
else el.textContent = 'Project: ' + PID;
|
||
}
|
||
function readCache() { try { return JSON.parse(localStorage.getItem(nsKey(PID)) || '[]') || []; } catch (e) { return []; } }
|
||
function writeCache() { try { localStorage.setItem(nsKey(PID), JSON.stringify(WPS)); } catch (e) {} }
|
||
function activePkgs(list) { return list.filter(function (p) { return !p.split && !p.archived; }); } // real work, not masters/archived
|
||
|
||
function loadWPs() {
|
||
WPS = activePkgs(readCache()); // offline-first: show cached packages immediately
|
||
renderList();
|
||
if (ProjectData.pullProject) {
|
||
ProjectData.pullProject(PID).then(function () {
|
||
WPS = activePkgs(readCache());
|
||
if (!curId) renderList(); else renderDetail();
|
||
}).catch(function () {});
|
||
}
|
||
}
|
||
function showNoProject() {
|
||
var s = document.getElementById('screen-list');
|
||
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
|
||
}
|
||
|
||
// ── list ───────────────────────────────────────────────────────────────────
|
||
function renderList() {
|
||
var box = document.getElementById('wp-list'); if (!box) return;
|
||
var q = ((document.getElementById('fld-search') || {}).value || '').toLowerCase();
|
||
var rows = WPS.filter(function (p) { return !q || ((p.number || '') + ' ' + (p.subject || '') + ' ' + (p.type || '')).toLowerCase().indexOf(q) >= 0; });
|
||
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
|
||
box.innerHTML = rows.map(function (p) {
|
||
var open = openCount(p);
|
||
var waiting = waitingCount(p, WPS); // the full set, not the filtered rows
|
||
var cls = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ready' : '');
|
||
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>'
|
||
: (open ? '<span class="pill warn">' + open + ' open</span>'
|
||
: (waiting ? '<span class="pill warn">waits on ' + waiting + '</span>'
|
||
: '<span class="pill ok">Ready</span>'));
|
||
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
|
||
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
|
||
'<div class="subj">' + esc(p.subject || '') + '</div>' +
|
||
'<div class="meta"><span class="pill st">' + esc(stLabel(p.status)) + '</span>' + readyPill +
|
||
(p.type ? '<span class="pill st">' + esc(p.type) + '</span>' : '') + '</div></button>';
|
||
}).join('');
|
||
}
|
||
|
||
// ── detail ─────────────────────────────────────────────────────────────────
|
||
function curWP() { return WPS.find(function (p) { return p.id === curId; }); }
|
||
function openWP(id) { curId = id; pendingPhoto = ''; draftNote = ''; renderDetail(); window.scrollTo(0, 0); }
|
||
function backToList() {
|
||
curId = null; pendingPhoto = ''; draftNote = '';
|
||
document.getElementById('screen-detail').style.display = 'none';
|
||
document.getElementById('screen-list').style.display = '';
|
||
renderList();
|
||
}
|
||
function renderDetail() {
|
||
var p = curWP(); if (!p) { backToList(); return; }
|
||
document.getElementById('screen-list').style.display = 'none';
|
||
var d = document.getElementById('screen-detail'); d.style.display = '';
|
||
|
||
var stBtns = STATUSES.map(function (s) {
|
||
return '<button class="st-btn' + (s === 'Issue' ? ' hold' : '') + (p.status === s ? ' on' : '') + '" onclick="setStatus(\'' + s + '\')">' + esc(stLabel(s)) + '</button>';
|
||
}).join('');
|
||
|
||
var cx = (p.constraints) || [];
|
||
var cxRows = cx.length ? cx.map(function (c, i) {
|
||
var st = c.status || 'open';
|
||
return '<div class="cx-row"><div class="cx-name">' + esc(c.name) + '</div>' +
|
||
'<button class="cx-state ' + st + '" onclick="cycleConstraint(' + i + ')">' + (st === 'cleared' ? 'Cleared' : st === 'na' ? 'N/A' : 'Open') + '</button></div>';
|
||
}).join('') : '<div style="color:var(--cds-text-helper);font-size:14px">No constraints on this package.</div>';
|
||
|
||
var log = ((p.fieldLog) || []).slice().reverse().map(function (e) {
|
||
return '<div class="log-item"><div class="lm">' + esc(e.by || '—') + ' · ' + esc(fmtTs(e.ts)) + (e.status ? ' · ' + esc(stLabel(e.status)) : '') + '</div>' +
|
||
(e.note ? esc(e.note) : '') + (e.photo && /^data:image\//.test(e.photo) ? '<img src="' + esc(e.photo) + '" alt="site photo">' : '') + '</div>';
|
||
}).join('') || '<div style="color:var(--cds-text-helper);font-size:14px">No field updates yet.</div>';
|
||
|
||
d.innerHTML =
|
||
'<button class="fld-back" onclick="backToList()">‹ All packages</button>' +
|
||
'<div class="fld-h1">' + esc(p.number || '(no number)') + '</div>' +
|
||
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
|
||
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
|
||
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
|
||
'<div class="fld-sec"><h3>Add field update</h3>' +
|
||
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
|
||
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
|
||
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
|
||
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
|
||
'</div>' +
|
||
'<div class="fld-sec"><h3>Field log</h3>' + log + '</div>';
|
||
}
|
||
|
||
// ── mutations (each auto-saves via the outbox; the global sync badge shows state) ──
|
||
function saveWP(p) {
|
||
var ix = WPS.findIndex(function (x) { return x.id === p.id; });
|
||
if (ix >= 0) WPS[ix] = p;
|
||
writeCache();
|
||
if (typeof ProjectData !== 'undefined' && ProjectData.pushWP) ProjectData.pushWP(p, PID);
|
||
}
|
||
function setStatus(s) {
|
||
var p = curWP(); if (!p) return;
|
||
if (GATED.indexOf(s) >= 0 && openCount(p) > 0) { toast('Clear all constraints before moving to ' + stLabel(s)); return; }
|
||
if (p.status === s) return;
|
||
p.status = s;
|
||
if (s === 'Issued' && !p.issuedAt) p.issuedAt = new Date().toISOString();
|
||
saveWP(p); renderDetail(); toast('Status: ' + stLabel(s));
|
||
}
|
||
function cycleConstraint(i) {
|
||
var p = curWP(); if (!p || !p.constraints || !p.constraints[i]) return;
|
||
var order = ['open', 'cleared', 'na'];
|
||
var cur = p.constraints[i].status || 'open';
|
||
p.constraints[i].status = order[(order.indexOf(cur) + 1) % 3];
|
||
saveWP(p); renderDetail();
|
||
}
|
||
function onPhoto(ev) {
|
||
var f = ev.target.files && ev.target.files[0]; if (!f) return;
|
||
var st = document.getElementById('photo-status'); if (st) st.textContent = 'Processing…';
|
||
var url = URL.createObjectURL(f);
|
||
var img = new Image();
|
||
img.onload = function () {
|
||
var max = 1280, w = img.width, h = img.height, scale = Math.min(1, max / Math.max(w, h));
|
||
var cv = document.createElement('canvas');
|
||
cv.width = Math.round(w * scale); cv.height = Math.round(h * scale);
|
||
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
|
||
try { pendingPhoto = cv.toDataURL('image/jpeg', 0.7); } catch (e) { pendingPhoto = ''; }
|
||
URL.revokeObjectURL(url);
|
||
if (st) st.textContent = pendingPhoto ? 'Photo attached ✓' : 'Could not read photo';
|
||
};
|
||
img.onerror = function () { URL.revokeObjectURL(url); if (st) st.textContent = 'Could not read photo'; };
|
||
img.src = url;
|
||
}
|
||
function addUpdate() {
|
||
var p = curWP(); if (!p) return;
|
||
var note = (draftNote || '').trim();
|
||
if (!note && !pendingPhoto) { toast('Add a note or photo first'); return; }
|
||
if (!p.fieldLog) p.fieldLog = [];
|
||
p.fieldLog.push({ ts: new Date().toISOString(), by: me(), note: note, photo: pendingPhoto || '', status: p.status });
|
||
pendingPhoto = ''; draftNote = '';
|
||
saveWP(p); renderDetail(); toast('Update added to log');
|
||
}
|
||
|
||
boot();
|