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=<id>, 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) <noreply@anthropic.com>
450 lines
23 KiB
JavaScript
450 lines
23 KiB
JavaScript
/* 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, '"').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);
|
|
},
|
|
|
|
// 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 = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected — not saved</span>' +
|
|
(c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : '');
|
|
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);
|