/* Shared project layer for the Work Package Suite. Projects are the top-level container — every SOP and Work Package belongs to one. Project records live in the SQL database (via /api/projects); this adapter is API-first and falls back to a localStorage mirror so the suite still works in local dev / offline. Included by the home page and the suite. */ (function (global) { 'use strict'; var API = '/api'; var LS_PROJECTS = 'wp_projects'; // local mirror of the project list var LS_ACTIVE = 'wp_active_project'; // active project id var LS_ACTIVE_OBJ = 'wp_active_project_obj'; function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } } function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} } function cacheUpsert(p) { var list = readLocal(); var ix = list.findIndex(function (x) { return x.id === p.id; }); if (ix >= 0) list[ix] = p; else list.unshift(p); writeLocal(list); } 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', site: 'Boise, ID — Fab', sample: true }; var ProjectData = { SAMPLE: SAMPLE_PROJECT, esc: esc, // Returns the project list. Tries the API; falls back to the local mirror. list: function () { return fetch(API + '/projects', { headers: { 'Accept': 'application/json' } }) .then(function (r) { if (!r.ok) throw 0; return r.json(); }) .then(function (rows) { writeLocal(rows); return rows; }) .catch(function () { return readLocal(); }); }, get: function (id) { return fetch(API + '/projects/' + encodeURIComponent(id)) .then(function (r) { if (!r.ok) throw 0; return r.json(); }) .catch(function () { return readLocal().find(function (x) { return x.id === id; }) || null; }); }, // Create or update. Assigns an id when new. Mirrors to localStorage either way. save: function (p) { if (!p.id) p.id = uid(); return fetch(API + '/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }) .then(function (r) { if (!r.ok) throw 0; return r.json(); }) .then(function (saved) { cacheUpsert(saved); return saved; }) .catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only }, // Deleting a project cascades its SOPs and work packages, and the server // allows it only for a Project Admin. Drop it from the local cache ONLY if // the server actually deleted it (or it was already gone) — removing it on a // 403 would hide a project that still exists for everyone else. remove: function (id) { return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' }) .then(function (r) { if (r.ok || r.status === 404) { cacheRemove(id); return true; } return r.json().catch(function () { return null; }).then(function (j) { throw new Error((j && j.detail) || ('Could not delete the project (HTTP ' + r.status + ').')); }); }); }, // Archiving a project lives in the admin console (html/admin.js), which doesn't // load this file — deliberately not mirrored here, so there's only one // 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) { // 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); // A caller that knew only the id leaves the store holding a stub, and every // reader then renders "(unnamed)" — field.js sets {id} on boot and resolves the // record into a variable of its own. Fill the stub in from the cached list, or // from the API when the cache has not loaded yet. The re-entry carries a name, // so it cannot loop; the id re-check stops a slow response from overwriting a // project the user has since switched to. if (p && p.id && !p.name) { var self = this; var cached = readLocal().filter(function (x) { return x.id === p.id; })[0]; if (cached && cached.name) { self.setActive(cached); return; } try { if (self.get) { self.get(p.id).then(function (full) { if (full && full.name && self.getActiveId() === full.id) self.setActive(full); }).catch(function () {}); } } catch (e) {} } }, // 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. // 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; } }; // ── Server sync for SOPs and Work Packages ───────────────────────────────── // SOPs and WPs are authoritative on the server (so every user of a project sees // the same data). To avoid rewriting the two apps, we keep their existing // localStorage keys as a per-browser CACHE: pullProject() hydrates those exact // keys from the API on page load, and the push* helpers write through to the // API whenever the apps save. The apps' own (synchronous) reads are unchanged. // (Original author: C-West8, "storing data in DB instead of client only"; // reintegrated on top of the BIM/per-package work.) function nsKey(base, id) { return id ? base + '__' + id : base; } function currentUser() { try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; } } // A saved Work Package is a flat object in the browser; the API splits it into // promoted columns + a `data` blob. We store the whole flat object in `data` // for perfect round-tripping (so BIM fields, kind, projectLinks, etc. all // survive), and mirror the few fields the API promotes to columns. function pkgToServer(p, projectId) { return { id: p.id, project_id: p.projectId || projectId || null, parent_id: p.instanceOf || null, number: p.number || '', subject: p.subject || '', type: p.type || '', status: p.status || 'Draft', assignee_id: p.assigneeId || null, created_by: p.createdBy || currentUser(), data: p }; } function serverToPkg(row) { var p = Object.assign({}, row.data || {}); // full flat object lives in data p.id = row.id; p.projectId = row.project_id || p.projectId || ''; if (row.number) p.number = row.number; if (row.subject != null) p.subject = row.subject; if (row.type != null) p.type = row.type; if (row.status) p.status = row.status; // honor server-side status changes if (row.parent_id) p.instanceOf = row.parent_id; p.archived = !!row.archived_at; p.assigneeId = row.assignee_id || ''; return p; } // Pull this project's SOP + WPs from the API into the localStorage keys the // apps read. Resolves even on failure (offline / no API) so boot continues. ProjectData.pullProject = function (projectId) { if (!projectId) return Promise.resolve(); var jobs = []; jobs.push( fetch(API + '/sops/latest?complete=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (sopRow) { if (sopRow && sopRow.data) { var d = sopRow.data; // { sop, state } as written by pushSOP if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop)); if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state)); localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1'); } }).catch(function () {}) ); jobs.push( fetch(API + '/wps?full=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (rows) { if (Array.isArray(rows)) { localStorage.setItem(nsKey('wp_iwp_v1', projectId), JSON.stringify(rows.map(serverToPkg))); } }).catch(function () {}) ); return Promise.all(jobs).then(function () {}); }; // ── Durable write-through outbox ─────────────────────────────────────────── // SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a // silently-failed POST leaves the browser and server divergent. Instead of a // fire-and-forget request, each mutation is appended to a localStorage-backed // queue and flushed to the API with retry + backoff. The API upserts by id and // DELETE is idempotent, so re-sending a queued op is always safe. The app's own // local cache still updates immediately, so rendering never waits on the network. var OUTBOX_KEY = 'wp_sync_outbox_v1'; var _flushTimer = null, _backoff = 0, _flushing = false; function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } } function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} } // Append an op, coalescing by (kind,key) so only the latest write per entity is // queued. A delete supersedes any pending upsert for the same id. function enqueue(op) { var q = qRead(); if (op.kind === 'wp-del') { q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); }); } else { q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); }); } op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); op.tries = 0; q.push(op); qWrite(q); notifySync(); scheduleFlush(0); } function opRequest(op) { if (op.kind === 'wp-del') { return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' }); } return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body) }); } function bumpTries(opId, err) { var q = qRead(); for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } } qWrite(q); } // Permanently-failed op (a 4xx client error) — keep it for visibility but stop // retrying, so a rejected write can't loop forever. `err` is the server's own // explanation when it sent one; it is what the sync badge shows the user. function markDead(opId, err) { var q = qRead(); for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } } qWrite(q); } // Attempt every live op; successes are removed, 4xx client errors are marked // dead (won't succeed on retry), transient failures (429/5xx/network) stay queued. function flush() { if (_flushing) return Promise.resolve(); var q = qRead().filter(function (o) { return !o.dead; }); if (!q.length) { notifySync(); return Promise.resolve(); } _flushing = true; notifySync(); var chain = Promise.resolve(), anyFail = false; q.forEach(function (op) { chain = chain.then(function () { return opRequest(op).then(function (r) { var status = r ? r.status : 0; var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); } else if (status >= 400 && status < 500 && status !== 429) { // Refused once, refused forever — so the only useful thing left is the // reason. A 409 here is the archived-project gate, whose detail tells // the user the project is read-only and how to get it unarchived; a // bare "HTTP 409" would leave them staring at a change that vanished. return r.json().catch(function () { return null; }).then(function (j) { var why = (j && typeof j.detail === 'string' && j.detail) || ('HTTP ' + status); markDead(op.opId, why); }); } else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); } }).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); }); }); }); return chain.then(function () { _flushing = false; notifySync(); if (qRead().filter(function (o) { return !o.dead; }).length) { _backoff = anyFail ? Math.min((_backoff || 5000) * 2, 60000) : 0; scheduleFlush(_backoff || 15000); } else { _backoff = 0; } }); } function scheduleFlush(delay) { if (_flushTimer) return; // one pending flush at a time _flushTimer = setTimeout(function () { _flushTimer = null; flush(); }, delay || 0); } // ── sync status (drives the indicator + any listeners) ────────────────────── // `failed` stays the total not-getting-through count (what listeners already // read); `dead` splits out the ops the server has permanently refused, with the // first reason it gave, because those two states need different words. function syncCounts() { var q = qRead(), pending = 0, failed = 0, dead = 0, reason = ''; for (var i = 0; i < q.length; i++) { if (q[i].dead) { dead++; if (!reason && q[i].lastErr) reason = String(q[i].lastErr); } else if ((q[i].tries || 0) >= 3) failed++; else pending++; } return { pending: pending, failed: failed + dead, dead: dead, reason: reason, syncing: _flushing }; } ProjectData.syncStatus = syncCounts; function notifySync() { var c = syncCounts(); try { document.dispatchEvent(new CustomEvent('wp-sync-changed', { detail: c })); } catch (e) {} renderSyncBadge(c); } // Tiny sync indicator (bottom-left). Rendered only in the top-level window so it // isn't duplicated inside the embedded creator iframe; the top window still sees // the iframe's queue changes via the 'storage' event below. var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })(); var _badgeHideTimer = null; function renderSyncBadge(c) { if (!_isTop || !document.body) return; var el = document.getElementById('wp-sync-badge'); if (!el) { el = document.createElement('div'); el.id = 'wp-sync-badge'; el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' + 'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' + 'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;'; document.body.appendChild(el); } if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; } // A dead op is a refusal, not a hiccup — "retrying" would be a lie, and the // reason is the only thing that tells the user what to do (e.g. the project is // archived). Stack it under the headline; the badge never hides in this state. el.style.flexDirection = c.dead ? 'column' : 'row'; el.style.alignItems = c.dead ? 'flex-start' : 'center'; el.style.maxWidth = c.dead ? 'min(340px, calc(100vw - 32px))' : 'none'; if (c.dead) { el.innerHTML = '✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected — not saved' + (c.reason ? '' + esc(c.reason) + '' : ''); el.style.color = '#a2191f'; el.style.borderColor = '#ffd7d9'; el.style.background = '#fff1f1'; el.style.display = 'inline-flex'; } else if (c.failed) { el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying'; el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex'; } else if (c.pending) { el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…'; el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex'; } else { el.textContent = '✓ All changes saved'; el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex'; _badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800); } } // Flush triggers: on reconnect, on cross-frame queue changes, on tab focus, and // a periodic backstop. Anything left from a previous session flushes on load. try { window.addEventListener('online', function () { _backoff = 0; scheduleFlush(0); }); window.addEventListener('storage', function (e) { if (e.key === OUTBOX_KEY) { notifySync(); scheduleFlush(0); } }); document.addEventListener('visibilitychange', function () { if (!document.hidden) scheduleFlush(0); }); setInterval(function () { if (qRead().filter(function (o) { return !o.dead; }).length) scheduleFlush(0); }, 20000); } catch (e) {} if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { notifySync(); scheduleFlush(0); }); } else { setTimeout(function () { notifySync(); scheduleFlush(0); }, 0); } // ── public write API (now durable via the outbox) ─────────────────────────── // Write a completed SOP (plus the builder's raw state). Deterministic id per // project so re-completing updates the same row. ProjectData.pushSOP = function (projectId, sop, state) { if (!projectId) return Promise.resolve(null); enqueue({ kind: 'sop', key: 'sop__' + projectId, body: { id: 'sop__' + projectId, project_id: projectId, name: (sop && sop.project && sop.project.name) || 'SOP', number: (sop && sop.project && sop.project.number) || '', complete: true, created_by: currentUser(), data: { sop: sop, state: state } } }); return Promise.resolve(true); }; // Upsert a single Work Package. The local cache stays the source of truth for // immediate rendering; the outbox guarantees the write reaches the server. ProjectData.pushWP = function (p, projectId) { if (!p || !p.id) return Promise.resolve(null); enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) }); return Promise.resolve(true); }; ProjectData.removeWP = function (id) { if (!id) return Promise.resolve(); enqueue({ kind: 'wp-del', key: id }); return Promise.resolve(true); }; // Force a flush now and resolve when the queue drains (or a round-trip is done). ProjectData.flushSync = function () { _backoff = 0; return flush(); }; // Archive / unarchive a Work Package (hide from active lists without deleting). // Direct request (not the outbox) — it's a deliberate, low-frequency action and // the caller updates the view on the returned result. ProjectData.archiveWP = function (id, archived) { if (!id) return Promise.resolve(null); return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false }) }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); }; // Fetch this project's ARCHIVED packages (full docs) for the dashboard's // "show archived" view. Returns app-shaped package objects (p.archived === true). ProjectData.listArchived = function (projectId) { if (!projectId) return Promise.resolve([]); return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : []; }) .then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; }) .catch(function () { return []; }); }; // 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) {} // 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);