T4.1 - B4: the counts come from the server, and disagreement is said out loud

The defect B4 names is not that the numbers were wrong. On one browser with one
cache they were right. It is that they were derived from the caller's own
localStorage, so two people on the same project saw different numbers and neither
was told.

SERVER. /api/wps/metrics now returns everything the creator's dashboard shows -
total, mine, release-ready, on hold, overdue, est/actual hours, by_status,
by_discipline, progress (overall and per discipline) and the gating list. It
already existed for a subset; the rest was being summed in the browser.

Two things moved to the server rather than being duplicated there:

  - PROGRESS_WEIGHT, the status-to-percentage table. It was PROGRESS_W in
    wp-creation-app.js; the JS copy is deleted rather than left in place, because
    two copies of a weighting table is how the two drift apart.
  - "release-ready" now counts `waitingOn` predecessors as blocking, which the
    browser did and the old endpoint did not. Without that the phrase would have
    changed meaning the moment the dashboard stopped computing it locally.

New GET /api/projects/{id}/summary gives the launcher the SOP state it was
reading out of localStorage.

by_location is shaped for CR-018 in wave 6, per the task's instruction not to
build a shape that cannot group by building/floor/sector. It reports its own
dimensions alongside the groups:

  {"dimensions": ["location"], "groups": [{"key": {...}, "total": n,
    "release_ready": n, "on_hold": n, "overdue": n, "by_status": {...}}]}

Today a package carries one free-text `location`, so that is the one dimension.
_location_key() already prefers structured building/floor/sector when present, so
CR-004 changes the dimensions and the keys and leaves the response shape alone.

CLIENT. The launcher's SOP card and the dashboard's tiles, chips, progress bars
and gating panel all read the server. There is deliberately no cache fallback: a
silently-stale number that looks authoritative is the thing being removed, so a
failed request renders an explicit error and a retry.

Writes flush through the outbox before the counts are re-read (dashRefreshAfterWrite).
Without that the refresh races the push and shows pre-write totals - the same
stale number arriving by a different route.

THE ONE COUNT STILL COMPUTED LOCALLY, stated rather than skipped: the board table
is a LIST of the packages this browser holds, which is what keeps the field view
working offline, and its header counts rows. Rather than pretend otherwise, it is
reconciled against the server's total and shows "this browser has N of M" when
they differ - usually a save that has not finished syncing. Nobody now sees a
number that disagrees with the project without being told, which is what B4 asks
for.

VERIFICATION. tests/aggregates_check.py, 16 checks, all passing. It tests what was
broken rather than whether the totals are right - the latter passed before this
change:

  - root and pat get byte-identical aggregates for the same project
  - the dashboard shows the server's total after localStorage is POISONED with a
    different package list; it cannot do that if it is summing the cache
  - a simulated outage renders "Counts unavailable", names the failure, offers a
    retry, renders no tiles beside it, and announces via role=alert
  - the launcher reports the SOP complete against a cache that says otherwise,
    and says "Could not check" when the request fails
  - by_location's groups are keyed by its declared dimensions, carry their own
    rollups, and sum to the project total

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:24:12 -05:00
parent 12c0e5ca74
commit b670ae719d
4 changed files with 572 additions and 62 deletions

View File

@@ -133,6 +133,9 @@
color: var(--cds-support-success);
margin-bottom: 0.5rem;
}
/* B4: an unreachable server is a third state, and it has to look like neither
of the other two. Not green, not silence. */
.card-status.card-status-error { color: var(--wp-status-warning-text); }
.card.disabled {
opacity: 0.6;
pointer-events: none;
@@ -567,24 +570,28 @@
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
&nbsp;<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
// Pull the project's shared SOP/WPs from the server into the local cache
// first, so the SOP "Complete / Review" status reflects what other users did.
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
else reflectSOPStatus(active);
// Pull the project's shared SOP/WPs into the local cache so the tools boot
// with data. The card status below does NOT come from that cache — see
// reflectSOPStatus.
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).catch(()=>{}); }
reflectSOPStatus(active);
}
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
// Reflect SOP completion on the tool cards (scoped to the active project).
// Reflect SOP completion on the tool cards (B4).
//
// This used to read `wp_suite_sop_complete` out of localStorage. That key is a
// per-browser mirror of the server, so the card answered "has THIS browser seen
// the SOP completed", not "is the SOP complete" — and the two diverge the moment
// a colleague finishes the SOP on their own machine. The card said "Complete SOP
// first" and nothing indicated the answer was stale, which is the failure mode
// B4 describes: a per-browser number that looks authoritative.
//
// There is deliberately no cache fallback. If the server cannot be reached the
// card says so; it does not guess, and it does not show a remembered answer as
// though it were current.
function reflectSOPStatus(active){
let complete = false, projName = '';
try {
// Storage is namespaced per project, so these already scope to `active`.
complete = localStorage.getItem(ProjectData.key('wp_suite_sop_complete')) === '1';
const sop = JSON.parse(localStorage.getItem(ProjectData.key('wp_suite_sop')) || 'null');
projName = sop && sop.project && sop.project.name || '';
} catch(e){}
const sopCard = document.getElementById('card-sop');
const sopBtn = document.getElementById('card-sop-btn');
const wpCard = document.getElementById('card-wp');
@@ -596,18 +603,44 @@
wpCard && wpCard.classList.remove('disabled');
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
if(complete){
sopCard.classList.add('complete');
sopBtn.textContent = 'Review';
const status = document.createElement('div');
status.className = 'card-status';
status.textContent = '✓ SOP Complete' + (projName ? ' — ' + projName : '');
sopCard.insertBefore(status, sopCard.firstChild);
if(wpBtn) wpBtn.textContent = 'Open Creator';
} else {
if(wpCard) wpCard.classList.add('disabled');
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
}
const setStatus = (text, cls) => {
const el = document.createElement('div');
el.className = 'card-status' + (cls ? ' ' + cls : '');
el.textContent = text;
sopCard.insertBefore(el, sopCard.firstChild);
return el;
};
// Neutral while in flight: neither "complete" nor "not complete" is known yet,
// and asserting either would be the same lie in a different direction.
sopBtn.textContent = 'Open tool';
if(wpBtn) wpBtn.textContent = 'Checking…';
fetch('/api/projects/' + encodeURIComponent(active.id) + '/summary',
{ headers: { 'Accept': 'application/json' } })
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(sum => {
if(ProjectData.getActiveId() !== active.id) return; // switched while in flight
const old = sopCard.querySelector('.card-status'); if(old) old.remove();
if(sum.sop_complete){
sopCard.classList.add('complete');
sopBtn.textContent = 'Review';
setStatus('✓ SOP complete' + (sum.sop_name ? ' — ' + sum.sop_name : ''));
if(wpBtn) wpBtn.textContent = 'Open creator';
} else {
if(wpCard) wpCard.classList.add('disabled');
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
}
})
.catch(err => {
if(ProjectData.getActiveId() !== active.id) return;
const old = sopCard.querySelector('.card-status'); if(old) old.remove();
// Explicitly unknown. The Creator is left reachable rather than disabled:
// locking someone out of their work because a status request failed is a
// worse outcome than letting the tool tell them itself.
setStatus('⚠ Could not check SOP status — ' + (err && err.message || 'offline'), 'card-status-error');
if(wpBtn) wpBtn.textContent = 'Open creator';
});
}
initProjects();