There is no iframe in html/ any more. The creator is a top-level document with
the same app bar and the same tab strip as the SOP wizard; the two tabs that
used to swap a frame are links between them.
DEVIATION, stated rather than smuggled. The wave file says "remove the iframe
boundary so the creator renders in the parent document". It renders as its own
document instead. Every done-when is met - no iframe, no cross-frame messaging,
F4 resolved structurally, CR-006 toggles with no special-casing, back and
forward intact with T4.2's URL state - but the route is the other one, and the
reason is in creator-frame.md's own numbers:
merge into parent make it a page
selector collisions to resolve 21 0
script global collisions 9 0
cross-frame call sites to remove 28 28
probe entry points needing rework ~29 2
The 21 and the 9 were never the cost of dissolving the boundary. They are the
cost of MERGING TWO DOCUMENTS, which is a different change the boundary was
hiding. And 29 probe call sites address wp-creation-index.html directly, so a
route that keeps that address keeps all of them. creator-frame.md section 5
records this in full.
What went, and what replaced it:
#wp-frame, applyEmbedLayout, sizeWPFrame, viewportMinusChrome, chromeHeight,
renderWPTab, the resize handler, the ResizeObserver, --wp-chrome-h,
.content-area.embed-full, body.embed-full -> the window sizes the page
?embedded=1, body.embedded, .embed-hide, .embed-first -> nothing. An old
link carrying the param is ignored rather than half-obeyed.
openWpById / showDashboard / showForm / dashApplyFlag / applySopSections
called across the frame -> the URL. ?project= ?view= ?wp= ?flag= were
already read at the creator's own boot (T4.2), which is exactly why those four
could be DELETED rather than migrated. X4 is closed: the surviving path is the
one T5.5 built and proved.
inIframe in auth-guard.js, wp-chrome.js, wp-sidenav.js, help.js and _isTop in
project-data.js -> gone. help.js now reads the explicit WP_HELP_NO_FAB flag
both tool pages set, instead of inferring intent from where it is rendered.
.main-nav / .nav-tab in work-package-suite-styles.css -> wp-chrome.css,
because a tab row only one of two documents can style is the shape that put
the tabs in the parent and the toolbar in the child to begin with.
The three questions creator-frame.md section 4 said no count could answer:
1. The creator gets the app bar. It was the only page loading neither
wp-chrome file. Its header is now the .header-left / .header-right pair the
wizard uses, so the switcher lands in the same place on both.
2. Two sequence components, scoped not merged - confirmed Aug 18 that the
sequence is authored in the SOP and adjustable per package. BL-015 stays.
3. body.embedded is gone. The header it hid is replaced by the app bar; the
sample controls are visible in a new package toolbar (D1); the analytics
button is visible there until T7.10 moves it. The Dashboard BUTTON in that
row became a TAB, which is the one place B7's "fold the toolbar into the
tab row" actually happened.
Old addresses still resolve. ?tab=wp, ?view=dashboard and ?wp=<id> are in
bookmarks, in wp-sidenav's link map, and they are the shape CR-011 and CR-014
were specified against (X1). The wizard forwards them with replace(), so Back
does not bounce. Breaking these silently was the one regression this task could
have shipped that nobody would notice for weeks. frame_check.py section 4 pins
all three.
BEHAVIOUR CHANGE, deliberate. The live cross-frame hand-off showed the creator a
section toggle that had NOT been saved: flip it, look, reload, and the section
came back. What the creator shows now is the SOP that is stored. sections_check
5b pins both halves - an unsaved toggle does not travel, a saved one does.
BEHAVIOUR CHANGE, not deliberate, logged as BL-020. A tab switch is a page exit
now, so leaving the wizard with unsaved SOP edits fires T4.3's unsaved-work
guard. Nothing is lost - the guard writes the draft first and T4.3 recovers it -
but it is friction that did not exist, and suppressing a deliberate guard is a
product decision with its own downside. Logged, not quietly handled here.
tests/frame_check.py, 39 checks, new. Two of them exist because of failures
during this task rather than in it:
- "both documents parse and boot". A const shadowing a function parameter is a
SyntaxError, and work-package-suite-app.js did not parse at all for one run.
Four checks in url_state_check went red and not one said "the script did not
load". Asserting a page's own entry points exist costs nothing.
- "focus emulation is on, so a focus reading means something". An earlier draft
called page.call instead of page.ws.call inside a try/except and measured
nothing, reporting no focus ring anywhere - which looks exactly like a
finding. Trap 5 in reverse, for the second time in this project.
The four backlog entries logged against this file, re-measured rather than
assumed:
BL-001 still reproduces (485px in a 390px viewport) but its RECORDED CAUSE IS
WRONG. --nav-w now computes to 56px, so the injected-style explanation
is spent. The overflow is the creator's data tables - #asset-body's
lays out at 520px with no scroll container. frame_check reports the
offending boxes by selector and skips position:fixed subtrees, because
the comments drawer parked off-screen at right:844 made the first
measurement blame the drawer. Pinned, not fixed: T7.2 lays out the form.
BL-013 CLOSED. It was fixed by S12 in WAVE 4 - wp-creation-styles.css:209
carries the comment naming this entry - and nobody updated it. It was
quoted as a live CLAUDE.md violation while planning wave 7 and had not
been true for four waves. a11y_check walks 120 focusable elements on
the creator and every one rings at >= 3:1.
BL-006 15 by the probe's measure, unchanged; different denominator, stated.
BL-007 68 raw radii by the probe's measure. Nothing has reduced it in four
waves; it is measured every run now instead of once.
BL-018 cost a FOURTH probe. frame_check imports set_sop from sections_check
rather than writing a fifth copy of the workaround. T9.9 owns it.
Probes re-pointed, with reasons in the files: sections_check 5b (drove the live
hand-off), pipeline_check check 2 (read through contentDocument), f_items F4
(drove standalone and embedded; there is one mode now), validation_check
(lost "the wrong tab", gained the SOP gate).
Verified: frame_check 39/39, sections_check 95/95, pipeline_check 44/44,
url_state_check 23/23, validation_check 83/83, a11y_check 22/22,
autosave_check 34/34, aggregates_check 16/16, stepper_check 71/71,
browser_check 71/71, launcher_check 58/58, generalinfo_check 49/49,
rollup_check 63/63, cards_check 44/44, locations_check 58/58.
f_items: F1-F5 fixed, F6 reproduces (T7.2).
Metrics: iframes 1 -> 0, colour literals in rules outside theme-light.css 0,
dialogs 64, <div onclick> 2, .help-tip 18.
Items: B7 D1
Task: T7.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
482 lines
24 KiB
JavaScript
482 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, '&').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). This was gated on being the top-level
|
|
// window so it was not drawn twice - once by the suite page and again inside
|
|
// the embedded creator. B7/T7.1 dissolved that frame, so there is one document
|
|
// and one badge. The 'storage' listener below stays: it is what keeps two
|
|
// TABS in step, which is a different thing and still happens.
|
|
var _badgeHideTimer = null;
|
|
function renderSyncBadge(c) {
|
|
if (!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);
|