Files
Project-SDE-WP-Suite/html/project-data.js
n.siegfried c024cba844 T4.5/T4.6/T4.7 - S10/S11/S12: it announces, it is legible, focus is visible
Three small accessibility items, done together because they share one probe and
one measurement method. tests/a11y_check.py, 22 checks, all passing.

S10 — ANNOUNCEMENTS. The app had zero aria-live regions; login.html's
role="alert" / role="status" pair was the only correct example in the codebase.
Both toasts now take an optional kind and set the role BEFORE the text, because
assistive technology announces on the content change and a role applied after
describes the next message rather than this one. The sync badge announces
politely.

Admin banners are handled by a rule rather than by editing thirteen assignment
sites: a MutationObserver in console-util.js marks anything carrying `.bad` as
role=alert and everything else role=status. Thirteen edits is thirteen chances to
get it wrong, and any banner added later would have missed out. The probe checks
a banner created after load, which is the case that would have regressed.

S11 — CONTRAST. Re-measured rather than quoting either published figure, as the
file map asked. #8d8d8d is 3.32:1 on white, not "about 2.9:1" as the plan says;
console.css:103's 3.3:1 was right. On the shaded surfaces it is worse - 3.01:1 on
a success banner. --cds-text-helper (#6f6f6f) clears 4.5:1 on all seven
backgrounds the app actually paints, tightest 4.56:1.

Fixed once, in the token, so all three sheets inherit it. console.css's local
override is gone, as the task requires. Disabled text was repointed to
--cds-text-disabled rather than darkened with everything else: making disabled
text MORE legible makes a disabled control look enabled.

The probe measures against the background actually painted behind each element,
walking ancestors for the first non-transparent one - not an assumed white, which
is how "passes on paper" and "fails on the page" come to disagree.

S12 — FOCUS. An app-wide :focus-visible floor in theme-light.css at zero
specificity, so any component can still draw its own. Filled controls get an
explicit rule at class specificity: a blue ring inside a blue button measures
1.00:1, which is not a subtle problem but no indicator at all. console.css's
inset ring had exactly that defect on button.primary.

`outline: none` is down from six to one, and that one (.wpc-search-input) has its
replacement in the rule above it - the shell rings on :focus-within, and ringing
both would draw two rectangles.

TWO REAL DEFECTS THE PROBE FOUND that reading would not have:

  - .wp-navbtn's ring is white, which is right on the near-black app bar and
    invisible on the creator's white header. Same button, same class, two hosts,
    1.00:1 on one of them.
  - The comment drawer is translated off-screen when closed, and a transform
    moves a thing without removing it from the tab order. Its name field,
    textarea, Add button and close button were all still focusable: a keyboard
    user could tab into a panel they could not see and could not tell they were
    in. Now visibility:hidden while closed, with the transition delayed so it
    still animates both ways.

The probe itself needed three corrections, each of which was a wrong answer
before it was a right one, and each worth knowing:

  - focus emulation must be ON, or :focus-visible never matches in headless and
    every element reports clean - a pass that means nothing.
  - which surface a ring is drawn against depends on the offset the BROWSER uses,
    not the one the stylesheet asked for. Chromium redraws a low-contrast author
    ring in white at offset 0 on a filled control, which is more contrast than was
    requested; measuring that against the parent scores it 1.00:1 and calls a
    correct ring a defect.
  - focus() on a hidden control does nothing, so the probe has to ask whether the
    focus actually landed. A closed drawer still has layout; a bounding box is not
    evidence that anyone can reach it.

Metric 7, aria-live regions: was 0 at wave 0, now 13 role/aria-live sites across
7 files.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:20:57 -05:00

481 lines
24 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
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';
// S10. Polite: this reports background syncing, and interrupting someone to
// say a queue drained is exactly the noise that gets aria-live turned off.
el.setAttribute('role', 'status');
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';
// B5. This badge reports the OUTBOX — whether saved records have reached the
// project — and it used to say "✓ All changes saved", which is what a draft
// autosave says. So the app was already making the promise B5 says it does not
// keep: the badge went green when the queue emptied, whether or not anything in
// the form had been saved at all.
//
// Every string here now names the project explicitly. The draft indicator
// (WPAutosave.mountIndicator) is the one that speaks for the form, and the two
// can no longer be read as each other.
if (c.dead) {
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected by the project — 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 yet sent to the project — retrying';
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
} else if (c.pending) {
el.textContent = '↻ Sending ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + ' to the project…';
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
} else {
el.textContent = '✓ Everything sent to the project';
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);