From e5c450597a28256ca55cb90d78f72ccdb811a190 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Mon, 15 Jun 2026 15:46:59 -0700 Subject: [PATCH] Isolate SOP/WP data per project (namespaced localStorage) SOP and Work Package localStorage keys are now namespaced by the active project via ProjectData.key(base) -> base+'__'+ (SK() in the suite, wpKey() in the creator), so switching projects shows that project's own SOP and packages. project-data.js runs a one-time discard of the legacy un-namespaced keys (guarded by wp_ns_migrated_v1), per the chosen approach. Active project is resolved before the store loads in both the suite and the creator so namespaced keys resolve correctly, including standalone deep-links. Co-Authored-By: Claude Opus 4.8 (1M context) --- ONBOARDING.md | 21 ++++++++++------ html/index.html | 7 +++--- html/project-data.js | 18 +++++++++++++- html/work-package-suite-app.js | 44 +++++++++++++++++++++------------- html/wp-creation-app.js | 18 ++++++++++---- 5 files changed, 75 insertions(+), 33 deletions(-) diff --git a/ONBOARDING.md b/ONBOARDING.md index bd93f2a..8b2cbab 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -158,13 +158,20 @@ SOP and Work Package belongs to one. empty. Passes `&project` into the WP-creator iframe. - **WP creator:** stamps `projectId` onto every saved package (for API sync). -**Not yet done (the next fork):** SOP/WP *localStorage* is still global, not -namespaced per project — selecting a different project locally still shows the -same `wp_suite_sop` / `wp_iwp_v1` data. The intended end state is per-project -data via the API (`GET /api/sops/latest?project_id=…`, `GET /api/wps?project_id=…`). -Decide whether to (a) namespace the local keys by project id with a migration of -existing un-namespaced data into a "default" project, or (b) jump straight to the -API for SOP/WP reads. See the open question below. +**Per-project isolation (done, local):** SOP/WP localStorage keys are now +namespaced per active project via `ProjectData.key(base)` → +`base + '__' + ` (`SK()` in the suite, `wpKey()` in the creator). +So each project keeps its own `wp_suite_sop` / `wp_suite_state` / +`wp_suite_sop_complete` / `wp_iwp_v1`. On first load after this change, +`project-data.js` runs a **one-time discard** of the legacy un-namespaced keys +(guarded by `wp_ns_migrated_v1`) — chosen over migrating, since the local data +was throwaway demo content. + +**Still ahead (true Phase 2):** move SOP/WP reads+writes to the API filtered by +`project_id` (`GET /api/sops/latest?project_id=…`, `GET /api/wps?project_id=…`) +so projects are shared across users, not just isolated per browser. The +endpoints already accept the `project_id` filter; the front end still reads +localStorage. **Pending — Phase 2: wire the front end to the API** - SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`). diff --git a/html/index.html b/html/index.html index b1c037b..5967045 100644 --- a/html/index.html +++ b/html/index.html @@ -601,11 +601,10 @@ function reflectSOPStatus(active){ let complete = false, projName = ''; try { - complete = localStorage.getItem('wp_suite_sop_complete') === '1'; - const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null'); + // 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 || ''; - // Only treat the SOP as belonging to this project when names match. - if(active && projName && active.name && projName !== active.name) complete = false; } catch(e){} const sopCard = document.getElementById('card-sop'); diff --git a/html/project-data.js b/html/project-data.js index 326c3d3..10f285d 100644 --- a/html/project-data.js +++ b/html/project-data.js @@ -73,8 +73,24 @@ 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); } } catch (e) {} - } + }, + + // Per-project namespacing for the SOP/WP localStorage keys, e.g. + // key('wp_iwp_v1') → 'wp_iwp_v1__proj_ab12' + // Falls back to the bare key when no project is active. + key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; } }; + // One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale + // global state can't leak across projects. (User chose: discard, don't migrate.) + try { + if (!localStorage.getItem('wp_ns_migrated_v1')) { + ['wp_suite_sop', 'wp_suite_state', 'wp_suite_sop_complete', 'wp_iwp_v1'].forEach(function (k) { + try { localStorage.removeItem(k); } catch (e) {} + }); + localStorage.setItem('wp_ns_migrated_v1', '1'); + } + } catch (e) {} + global.ProjectData = ProjectData; })(window); diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index 78d0a1e..8414f4c 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -4,6 +4,10 @@ let currentStep = 1; let sopComplete = false; let allComments = []; +// Per-project storage key: 'wp_suite_sop' → 'wp_suite_sop__' when a +// project is active. Keeps each project's SOP separate in the browser. +function SK(base){ try { return (typeof ProjectData !== 'undefined' && ProjectData.key) ? ProjectData.key(base) : base; } catch(e){ return base; } } + let state = { project: {name:'', number:'', client:'', division:'', site:''}, team: {pm:'', apm:'', cm:'', qm:''}, @@ -126,13 +130,15 @@ window.addEventListener('DOMContentLoaded',()=>{ renderStandardConstraints(); renderSequenceSteps(); renderSources(); + // Resolve the active project FIRST so per-project storage keys are correct + // before we restore this project's SOP. + const params = new URLSearchParams(window.location.search); + applyProjectContext(params.get('project')); restoreSavedSOP(); updateStepUI(); updateProjectDisplay(); - // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?project= from the home page. - const params = new URLSearchParams(window.location.search); - applyProjectContext(params.get('project')); + // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page. const tab = params.get('tab'); if(params.get('view') === 'dashboard') switchTool('wp'); else if(tab === 'wp' || tab === 'sop') switchTool(tab); @@ -203,9 +209,9 @@ function loadSampleData(){ function restoreSavedSOP(){ let savedState = null, savedSop = null, complete = false; try { - complete = localStorage.getItem('wp_suite_sop_complete') === '1'; - savedState = JSON.parse(localStorage.getItem('wp_suite_state') || 'null'); - savedSop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null'); + complete = localStorage.getItem(SK('wp_suite_sop_complete')) === '1'; + savedState = JSON.parse(localStorage.getItem(SK('wp_suite_state')) || 'null'); + savedSop = JSON.parse(localStorage.getItem(SK('wp_suite_sop')) || 'null'); } catch(e){} if(!complete || !savedState) return; @@ -306,15 +312,18 @@ function onSOPReady(){ // record so the SOP is authored against the chosen project. let activeProject = null; function applyProjectContext(projectId){ - try { if(typeof ProjectData !== 'undefined') activeProject = ProjectData.getActive(); } catch(e){} - if(projectId && (!activeProject || activeProject.id !== projectId)){ - // Deep-linked to a project we don't have cached yet — resolve it, then prefill. - try { - if(typeof ProjectData !== 'undefined'){ + try { + if(typeof ProjectData !== 'undefined'){ + if(projectId && ProjectData.getActiveId() !== projectId){ + // Deep-linked to a project that isn't the cached active one. Seed the id + // immediately so namespaced storage keys resolve, then fetch the full record. + const cached = ProjectData.getActive(); + ProjectData.setActive(cached && cached.id === projectId ? cached : { id: projectId }); ProjectData.get(projectId).then(p => { if(p){ activeProject = p; ProjectData.setActive(p); prefillProjectFields(); updateProjectDisplay(); } }); } - } catch(e){} - } + activeProject = ProjectData.getActive(); + } + } catch(e){} prefillProjectFields(); } function prefillProjectFields(){ @@ -713,11 +722,12 @@ function completeSOP(){ sopComplete = true; updateProjectDisplay(); - // Persist for the home page (green / "Review") and for the WP Creator tab. + // Persist for the home page (green / "Review") and for the WP Creator tab, + // namespaced to the active project so each project keeps its own SOP. try { - localStorage.setItem('wp_suite_sop', JSON.stringify(sop)); - localStorage.setItem('wp_suite_state', JSON.stringify(state)); - localStorage.setItem('wp_suite_sop_complete', '1'); + localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop)); + localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state)); + localStorage.setItem(SK('wp_suite_sop_complete'), '1'); } catch(e){} track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length}); diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 2b1ebb8..93771fe 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -758,8 +758,10 @@ function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card') // ── SAVED PACKAGES ─────────────────────────────────────────────────────────── const STORE_KEY='wp_iwp_v1'; -function saveStore(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(savedPackages)); }catch(e){} } -function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(STORE_KEY)); if(Array.isArray(d)) savedPackages=d; }catch(e){} } +// Per-project namespaced key so each project keeps its own packages in the browser. +function wpKey(base){ try{ return (typeof ProjectData!=='undefined'&&ProjectData.key)?ProjectData.key(base):base; }catch(e){ return base; } } +function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify(savedPackages)); }catch(e){} } +function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } } function renderSavedList(){ const card=document.getElementById('saved-card'), body=document.getElementById('saved-body'); document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:''; @@ -1012,15 +1014,23 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList })); // ── BOOT ───────────────────────────────────────────────────────────────────── +// Resolve the active project BEFORE loading the store so namespaced keys resolve. +(function seedProject(){ + const params = new URLSearchParams(location.search); + activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || ''; + if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()!==activeProjectId){ + const cached = ProjectData.getActive && ProjectData.getActive(); + ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId }); + } +})(); loadStore(); (function bootSOP(){ // When embedded in the Suite, hide the SOP import/sample controls (SOP is injected) // and prefer the SOP the Suite just completed (persisted to localStorage). const params = new URLSearchParams(location.search); if(params.get('embedded')) document.body.classList.add('embedded'); - activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || ''; try { - const raw = localStorage.getItem('wp_suite_sop'); + const raw = localStorage.getItem(wpKey('wp_suite_sop')); if(raw){ const d = JSON.parse(raw); if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }