The set mixed colour emoji with dingbats, and the same glyph read as two things partly BECAUSE emoji render as per-platform artwork. The system chosen: monochrome text-presentation glyphs - the suite is classic-script vanilla HTML with no bundler, so an SVG sprite or icon font is a new asset pipeline, while text glyphs render through the same font stack as the words beside them. The enforceable form of "renders identically on Windows, macOS and a tablet": no emoji-range codepoint and no U+FE0F selector anywhere in UI source, swept by the probe on every run. Converted: green-check/red-cross emoji in the admin and users consoles to checkmark/cross, no-entry to circled-slash (blocked/on hold), the lock to the pencil already meaning "edit with a logged reason" on sign-offs, the star to the diamond, the folder to the reference marker, the side nav's lightning to the gear, and the WATCH glyph (U+231A - emoji-presentation BY DEFAULT per Unicode) to a text-presentation clock face. Dropped where the label already carried the meaning: lightning on Save & view, the camera on Add photo, the page/frame pictograms on file rows (the filename is the label). Stale help copy fixed while its emoji left: it still described the pre-T9.4 "Load sample" and the pre-T7.10 "Usage Logs" locations. The meaning-to-icon mapping is in docs/reference/tokens.md - one meaning per glyph, one glyph per meaning, both directions asserted from the document itself; the probe also sweeps every page for glyphs not in the approved set, so an unmapped icon cannot creep in. Verification (each probe run alone): NEW tests/icon_check.py 5/5. Regressions: frame_check 38/38, files_check 36/36, a11y_check 22/22, cards_check 44/44. Items: S6 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
213 lines
12 KiB
JavaScript
213 lines
12 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', 'Ready for QA', 'QC', 'Closed', 'Issue'];
|
||
var GATED = ['Issued', 'In Progress', 'Ready for QA', '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 ''; } }
|
||
// S10: see the note on the creator's toast. Role first, then text.
|
||
function toast(m, kind) {
|
||
var t = document.getElementById('toast'); if (!t) return;
|
||
t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
|
||
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();
|
||
prefetchMyDrawings();
|
||
}).catch(function () {});
|
||
}
|
||
}
|
||
|
||
// CR-007/D8: warm the drawing cache for MY packages while there is a network.
|
||
// Deliberately only the requesting user's assignments - the decision was that
|
||
// offline coverage follows assignment, not the whole project's 2GB.
|
||
function prefetchMyDrawings() {
|
||
var myId = (window.WP_USER || {}).id;
|
||
if (!myId || !('serviceWorker' in navigator)) return;
|
||
WPS.filter(function (p) { return p.assigneeId === myId; }).forEach(function (p) {
|
||
((p.files) || []).forEach(function (f) {
|
||
if (f && f.id) fetch('/api/files/' + f.id).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>'
|
||
: p.status === 'Ready for QA' ? '<span class="pill ok">Ready for QA</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>' +
|
||
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
|
||
p.files.map(function (f) {
|
||
return '<a class="fld-drawing" href="/api/files/' + esc(f.id) + '" target="_blank" rel="noopener">' +
|
||
'' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
|
||
}).join('') + '</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();
|