From 5d5511a4585a4edac49e68d0ec33e3ea2e13343c Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Fri, 14 Aug 2026 18:13:32 -0500 Subject: [PATCH] T1.1 - F1: one source of truth for the active project; the app bar subscribes The hero, the picker and the create-user card showed the active project while the app bar still read "Select a project". Three separate causes, all of them the same shape - a reader with its own copy of the value. 1. Nothing told the bar. wp-chrome.js rendered projectLabel() once at build time and refreshed it only when /api/projects came back, so selecting a project updated the hero and left the bar behind. ProjectData.setActive now notifies, and the bar subscribes through ProjectData.onActiveChange instead of holding a copy. A plain array of callbacks - this is one value with a handful of readers, not a reason for a state library. 2. admin.html and users.html load wp-chrome.js but never loaded project-data.js, so window.ProjectData was undefined and their bar could NEVER show a project - it read "Select a project" permanently, whatever was selected. Both now load it, ahead of wp-chrome.js. 3. setActive({id}) erased the name. field.js, wp-creation-app.js and work-package-suite-app.js all set the id first and the full record second; writing that stub verbatim left the bar rendering "(unnamed)". setActive now merges onto the stored record when the id matches, so a partial write cannot lose fields it did not mean to touch. Also: index.html never honoured ?project=, though every other page does, so a deep link on a browser with nothing stored showed "Select a project" while the URL said otherwise. It now resolves the parameter before reconciling. setActive is the only code path that writes wp_active_project / wp_active_project_obj - project-data.js:83-105, noted there in a comment so it stays that way. A storage listener keeps a second tab from showing a project the user has since switched away from. Verified, all at 1440px and against the wave 0 baseline: - bar shows the project on launcher, SOP wizard, admin, field, users - survives a hard refresh on each of them - selecting a project updates hero and bar in one interaction, no reload - with nothing selected the bar reads "Select a project" and both the launcher picker and the bar's own switcher are reachable - deep link ?project= works on a cold browser, hero and bar agree - setActive({id}) after a full record keeps the name The creator is the one page with no app bar to fix: it loads neither wp-chrome.js nor wp-chrome.css, because it renders as the iframe child of the SOP wizard. Giving it chrome is T7.1's work once B7 dissolves that boundary - adding it here would put a second app bar inside the embedded view. This is the "all 6 pages" wording in the plan meeting the 7 pages that exist; see file-map D1. tests/f_items.py F1 now reports FIXED. F2-F6 still reproduce, untouched. browser_check.py 71/71. Co-Authored-By: Claude Opus 5 (1M context) --- html/admin.html | 4 ++++ html/index.html | 9 ++++++++ html/project-data.js | 50 ++++++++++++++++++++++++++++++++++++++++++-- html/users.html | 4 ++++ html/wp-chrome.js | 10 +++++++++ 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/html/admin.html b/html/admin.html index a0132ea..8adae4f 100644 --- a/html/admin.html +++ b/html/admin.html @@ -213,6 +213,10 @@ + + diff --git a/html/index.html b/html/index.html index c65898c..3f77fc9 100644 --- a/html/index.html +++ b/html/index.html @@ -423,6 +423,15 @@ function initProjects(){ ProjectData.list().then(list => { _projects = list || []; + // A deep link names the project explicitly, and every other page in the + // suite already honours ?project=. This one did not, so arriving here + // with a link on a browser that had nothing stored showed "Select a + // project" while the URL said otherwise. Honour it before reconciling. + const wanted = new URLSearchParams(location.search).get('project'); + if(wanted){ + const target = _projects.find(p => p.id === wanted); + if(target) ProjectData.setActive(target); + } // Reconcile the active project against the list; clear if it's gone. const active = ProjectData.getActive(); const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null; diff --git a/html/project-data.js b/html/project-data.js index 8a66578..22db885 100644 --- a/html/project-data.js +++ b/html/project-data.js @@ -24,6 +24,16 @@ } function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); } + // Subscribers to the active project. Deliberately a plain array and a plain + // callback — this is one value with a handful of readers, not a reason for a + // state library. A throwing subscriber must not stop the others being told. + var activeSubs = []; + function notifyActive(p) { + activeSubs.slice().forEach(function (fn) { + try { fn(p); } catch (e) {} + }); + } + var SAMPLE_PROJECT = { name: 'Micron FMCS Install (sample)', number: '26-67-008', client: 'Micron Technology, Inc.', division: 'Semiconductor', @@ -78,13 +88,40 @@ // implementation of it rather than two that can disagree. // ── active project context ──────────────────────────────────────────────── + // setActive is the ONLY thing in the app that writes LS_ACTIVE / LS_ACTIVE_OBJ. + // Everything that displays the active project reads it back through getActive() + // or subscribes with onActiveChange(). Keep it that way: F1 was two readers with + // their own copies, and the global one lost. getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } }, getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } }, setActive: function (p) { try { - if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); } - else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); } + if (p) { + // Several callers know only the id — a deep link resolving before the + // record arrives (field.js, wp-creation-app.js, work-package-suite-app.js + // all call setActive({id}) first and the full record second). Writing that + // stub verbatim erases the name, and the app bar then renders "(unnamed)". + // Merging keeps the fuller record; fields the caller does supply still win. + var prev = this.getActive(); + if (prev && prev.id === p.id) p = Object.assign({}, prev, p); + localStorage.setItem(LS_ACTIVE, p.id); + localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); + } else { + localStorage.removeItem(LS_ACTIVE); + localStorage.removeItem(LS_ACTIVE_OBJ); + } } catch (e) {} + notifyActive(p || null); + }, + + // Subscribe to active-project changes. Returns an unsubscribe function. + // The app bar uses this instead of holding its own copy of the value. + onActiveChange: function (fn) { + if (typeof fn !== 'function') return function () {}; + activeSubs.push(fn); + return function () { + activeSubs = activeSubs.filter(function (f) { return f !== fn; }); + }; }, // Per-project namespacing for the SOP/WP localStorage keys, e.g. @@ -399,5 +436,14 @@ } } catch (e) {} + // A second tab switching project leaves this one showing a project the user is no + // longer on. The storage event fires only in OTHER tabs, which is exactly the case + // setActive's own notification cannot cover. + try { + global.addEventListener('storage', function (e) { + if (e.key === LS_ACTIVE_OBJ || e.key === LS_ACTIVE) notifyActive(ProjectData.getActive()); + }); + } catch (e) {} + global.ProjectData = ProjectData; })(window); diff --git a/html/users.html b/html/users.html index b11c209..15293be 100644 --- a/html/users.html +++ b/html/users.html @@ -100,6 +100,10 @@ + + diff --git a/html/wp-chrome.js b/html/wp-chrome.js index 5cabc68..ae546b4 100644 --- a/html/wp-chrome.js +++ b/html/wp-chrome.js @@ -379,6 +379,16 @@ loadProjects(switcher); checkArchived(m.host); window.wpChromeRefresh = function () { switcher.wpcRefresh(); }; + + // Subscribe rather than keep our own copy of the value. Before this, the label + // was rendered once at build time and refreshed only when /api/projects came + // back, so selecting a project on the launcher updated the hero and left the bar + // reading "Select a project" — that was F1. + try { + if (window.ProjectData && ProjectData.onActiveChange) { + ProjectData.onActiveChange(function () { switcher.wpcRefresh(); }); + } + } catch (e) {} } // Wait for the auth guard: an unauthenticated page is about to redirect, and