diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index bd124a5..5ad1e6d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -469,3 +469,29 @@ project-scoped roles. Changes are audit-logged as `project_access_changed`. the project's members plus app admins, each with their effective role on that project. A project with nobody assigned shows only the admins, which is why assigning people is the first step on a new job. + +## Asset freshness (why the app can't run half-updated) + +A page must never run against a stylesheet or script from a previous deploy. Three +things enforce that, and all three are needed: + +1. **`Cache-Control: no-cache` on HTML/CSS/JS** — set by NGINX + (`nginx/conf.d/wp-suite.conf`) and by the dev server (`_NoCacheCode` in + `server/app.py`). With no header at all the browser applies *heuristic* freshness, + roughly 10% of each file's age, so the least recently changed file gets the longest + lifetime — which is exactly how HTML and CSS drift apart. ETag/Last-Modified still + make each revalidation a cheap 304. +2. **The service worker fetches code with `cache: 'no-cache'`** (`html/sw.js`) and + precaches with `cache: 'reload'`. A plain `fetch(req)` inherits the request's + default cache mode and consults the browser HTTP cache, so "network-first" alone + was not enough. Non-`ok` responses fall back to the cache rather than replacing a + page the cache could still serve, and cache keys drop the query string so in-app + links (`?project=…&tab=…`) still resolve offline. +3. **Components whose CSS-missing state is *broken* carry their own critical layout.** + The embedded creator's iframe keeps its sizing inline (and `sizeWPFrame()` re-applies + it), and the work-package panel injects a floor of positioning rules from + `wp-creation-app.js`. Both had failure modes — a 300×150 iframe, and panel controls + dumped loose into the form — that a missing rule turned into a broken page rather + than a plain one. + +If you change the shell file list in `sw.js`, bump `CACHE`. diff --git a/html/admin.html b/html/admin.html index ab0f2e3..4fbdcbc 100644 --- a/html/admin.html +++ b/html/admin.html @@ -5,6 +5,9 @@ Admin Console — Work Package Suite + + @@ -208,7 +211,6 @@ - diff --git a/html/auth-guard.js b/html/auth-guard.js index 6922ee6..49380c7 100644 --- a/html/auth-guard.js +++ b/html/auth-guard.js @@ -185,10 +185,12 @@ wrap.appendChild(who); var onAdmin = /(^|\/)admin\.html$/.test(location.pathname); if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); } - if (typeof window.wpPreferences === 'function') { - wrap.appendChild(sep()); - wrap.appendChild(link('Language & time', function () { window.wpPreferences(); })); - } + // Always offered; wp-format.js may still be parsing when the menu is built, so + // the check happens at click time rather than once, up front. + wrap.appendChild(sep()); + wrap.appendChild(link('Language & time', function () { + if (typeof window.wpPreferences === 'function') window.wpPreferences(); + })); wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); })); wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); })); return wrap; diff --git a/html/field.html b/html/field.html index 0573dcd..c1501cb 100644 --- a/html/field.html +++ b/html/field.html @@ -5,6 +5,9 @@ Field View — Work Package Suite + + @@ -83,7 +86,6 @@ - diff --git a/html/index.html b/html/index.html index 6a79697..f463611 100644 --- a/html/index.html +++ b/html/index.html @@ -5,6 +5,9 @@ Work Package Suite — Prime Controls + + @@ -662,7 +665,6 @@ .catch(() => {}); } - diff --git a/html/login.js b/html/login.js index db808e2..f95e8f3 100644 --- a/html/login.js +++ b/html/login.js @@ -80,6 +80,9 @@ // ── sign in ──────────────────────────────────────────────────────────────── var form = byId('login-form'); var submitBtn = byId('submit'); + // Guarded because a cached older login.html may not have the reset views; an + // unguarded addEventListener on null would break sign-in itself. + if (!form || !submitBtn) return; form.addEventListener('submit', function (e) { e.preventDefault(); clearBanners(); @@ -117,7 +120,7 @@ .catch(function () { resetAvailable = false; return false; }); } - byId('forgot-link').addEventListener('click', function (e) { + (byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) { e.preventDefault(); view('forgot'); // Prefill from the sign-in box so nobody types their username twice. @@ -131,13 +134,13 @@ }); }); - byId('back-to-login').addEventListener('click', function (e) { + (byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) { e.preventDefault(); view('login'); }); - var forgotForm = byId('forgot-form'); - var forgotBtn = byId('forgot-submit'); + var forgotForm = byId('forgot-form') || document.createElement('form'); + var forgotBtn = byId('forgot-submit') || document.createElement('button'); forgotForm.addEventListener('submit', function (e) { e.preventDefault(); clearBanners(); @@ -167,13 +170,13 @@ }); // ── set a new password (from the emailed link) ────────────────────────────── - byId('reset-to-login').addEventListener('click', function (e) { + (byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) { e.preventDefault(); view('login'); }); - var resetForm = byId('reset-form'); - var resetBtn = byId('reset-submit'); + var resetForm = byId('reset-form') || document.createElement('form'); + var resetBtn = byId('reset-submit') || document.createElement('button'); resetForm.addEventListener('submit', function (e) { e.preventDefault(); clearBanners(); diff --git a/html/sw.js b/html/sw.js index 0a2074d..99317e1 100644 --- a/html/sw.js +++ b/html/sw.js @@ -14,7 +14,7 @@ 'use strict'; // Bumped when the shell file list changes, so clients fetch the new assets // instead of serving a half-old shell from the previous cache. -const CACHE = 'wp-suite-shell-v4'; +const CACHE = 'wp-suite-shell-v5'; const SHELL = [ '/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html', '/field.html', '/login.html', '/admin.html', @@ -31,7 +31,10 @@ self.addEventListener('install', (e) => { // Cache each shell asset individually so one missing file doesn't abort install. e.waitUntil( caches.open(CACHE) - .then((c) => Promise.all(SHELL.map((u) => c.add(u).catch(() => {})))) + // cache:'reload' bypasses the browser HTTP cache. Without it the precache + // can be filled from stale HTTP entries, freezing a mismatched shell. + .then((c) => Promise.all(SHELL.map( + (u) => c.add(new Request(u, { cache: 'reload' })).catch(() => {})))) .then(() => self.skipWaiting()) ); }); @@ -64,33 +67,41 @@ self.addEventListener('fetch', (e) => { const isCode = CODE_RE.test(url.pathname); + // Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…, + // and the embedded creator used to carry a cache-busting timestamp, so keying on the + // full URL both missed every offline navigation and grew the cache without bound. + const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' }); + const fromCache = () => caches.match(key).then((c) => c || caches.match(req)); + + const store = (res) => { + if (res && res.ok && res.type !== 'opaque') { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(key, copy)).catch(() => {}); + } + return res; + }; + if (isCode) { e.respondWith( - fetch(req) + // cache:'no-cache' forces revalidation with the server. Plain fetch() inherits + // the request's default cache mode, which consults the browser HTTP cache — so + // "network-first" alone still let a page run against a stale sibling file. + fetch(req, { cache: 'no-cache' }) .then((res) => { - if (res && res.ok) { - const copy = res.clone(); - caches.open(CACHE).then((c) => c.put(req, copy)); - } - return res; + // A 502/404 must not replace a page the cache could still serve. + if (!res || !res.ok) return fromCache().then((c) => c || res); + return store(res); }) - .catch(() => caches.match(req)) // offline → last good copy + .catch(() => fromCache()) // offline → last good copy + .then((res) => res || Response.error()) // never resolve to undefined ); return; } e.respondWith( - caches.match(req).then((cached) => { - const network = fetch(req) - .then((res) => { - if (res && res.ok) { - const copy = res.clone(); - caches.open(CACHE).then((c) => c.put(req, copy)); - } - return res; - }) - .catch(() => cached); // offline → cached copy - return cached || network; // cache-first, then refresh + caches.match(key).then((cached) => { + const network = fetch(req).then(store).catch(() => cached); + return cached || network.then((res) => res || Response.error()); }) ); }); diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index e11ecce..be4514f 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -291,10 +291,15 @@ window.addEventListener('DOMContentLoaded',()=>{ updateProjectDisplay(); loadProjectUsers(); // team pickers: who's on this project applyBimFlag(); // hide the BIM section unless an admin enabled it - // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page. + // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=. Consumed ONCE — + // leaving ?view=dashboard in the URL used to make the Work Package Creation tab + // keep opening the dashboard for the rest of the session. const tab = params.get('tab'); - if(params.get('view') === 'dashboard') switchTool('dashboard'); + _deepLinkWp = params.get('wp') || ''; + const wantDashboard = params.get('view') === 'dashboard'; + if(wantDashboard) switchTool('dashboard'); else if(tab === 'wp' || tab === 'sop') switchTool(tab); + else if(_deepLinkWp) switchTool('wp'); } if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){ ProjectData.pullProject(projId).then(afterPull).catch(afterPull); @@ -479,7 +484,10 @@ function switchTool(tool){ document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—'; if(contentTool === 'wp') renderWPTab(isDash); - applyEmbedLayout(contentTool === 'wp'); + // Only go full-bleed when the creator is actually showing. With the SOP + // incomplete this tab shows a short 'complete the SOP first' gate; making the + // page unscrollable around it can clip its button off the bottom. + applyEmbedLayout(contentTool === 'wp' && sopComplete); updateStepUI(); updateProjectDisplay(); @@ -488,13 +496,23 @@ function switchTool(tool){ // The embedded creator/dashboard fills the window below the app chrome, so there // is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling // page — and the creator's sticky bars have a real viewport to stick to. +// A work package the global search asked us to open, handed to the creator on its +// next load and then cleared. +let _deepLinkWp = ''; + function applyEmbedLayout(on){ + // Below this, "fill the window" leaves nothing usable: the iframe would be shorter + // than the creator's own sticky bars while the page itself can't scroll. Fall back + // to normal page flow and let the page scroll instead. + const MIN_FILL_H = 460; + const fits = (window.innerHeight - chromeHeight()) >= MIN_FILL_H; + const full = !!on && fits; const area = document.querySelector('.content-area'); const frame = document.getElementById('wp-frame'); - if(area) area.classList.toggle('embed-full', !!on); - if(frame) frame.classList.toggle('fill', !!on); - document.body.classList.toggle('embed-full', !!on); - sizeWPFrame(on); + if(area) area.classList.toggle('embed-full', full); + if(frame) frame.classList.toggle('fill', full); + document.body.classList.toggle('embed-full', full); + sizeWPFrame(full); } // Size the frame with INLINE styles, not only CSS classes. Inline wins over any @@ -516,16 +534,30 @@ function sizeWPFrame(on){ } } -// Whatever is left of the window below the app bar + tab strip. -function viewportMinusChrome(){ +function chromeHeight(){ const hdr = document.querySelector('.header'); const nav = document.querySelector('.main-nav'); - const chrome = (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48); - return Math.max(320, window.innerHeight - chrome); + return (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48); } +// Whatever is left of the window below the app bar + tab strip. No floor: a floor +// taller than the remaining space pushes the frame (and the creator's fixed save bar) +// off a window that has scrolling disabled. +function viewportMinusChrome(){ + return Math.max(0, window.innerHeight - chromeHeight()); +} +// Re-measure on resize, and re-decide whether full-bleed still fits. window.addEventListener('resize', () => { - if(document.body.classList.contains('embed-full')) sizeWPFrame(true); + if(currentTool === 'wp' || currentTool === 'dashboard') applyEmbedLayout(true); }, {passive:true}); +// The app bar grows when wp-chrome.js injects the project switcher and search, which +// happens after the frame has already been sized. Watch the chrome instead of relying +// on someone resizing the window. +try { + const _ro = new ResizeObserver(() => { + if(document.body.classList.contains('embed-full')) sizeWPFrame(true); + }); + ['.header', '.main-nav'].forEach(sel => { const el = document.querySelector(sel); if(el) _ro.observe(el); }); +} catch(e) { /* no ResizeObserver: the resize handler above still covers it */ } // Show the gate or the embedded Work Package Creator depending on SOP status. // wantDash=true opens the creator straight to the dashboard view. @@ -536,12 +568,49 @@ function renderWPTab(wantDash){ if(sopComplete){ gate.style.display = 'none'; frame.style.display = 'block'; - // Reload each time so the creator picks up the latest SOP from localStorage. const sp = new URLSearchParams(window.location.search); - const dash = wantDash || sp.get('view') === 'dashboard'; const projId = sp.get('project') || (activeProject && activeProject.id) || ''; - frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '') - + (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now(); + const wantWp = _deepLinkWp; _deepLinkWp = ''; + const dash = wantDash; + + // The frame's identity is the PROJECT only. The view (form vs dashboard) and + // which package to open are applied by calling into the loaded document, so + // switching tabs never reloads it — reloading discarded unsaved form edits, made + // the creator unreachable offline, and stored a fresh copy in the SW cache each + // time. It's same-origin, so a direct call is fine. + const src = 'wp-creation-index.html?embedded=1' + + (projId ? '&project=' + encodeURIComponent(projId) : ''); + + const applyNow = () => { + try { + const cw = frame.contentWindow; + if(!cw) return; + if(wantWp && typeof cw.openWpById === 'function' && !cw.openWpById(wantWp)){ + if(typeof cw.toast === 'function') cw.toast('That work package is not on this project.'); + } + if(dash && typeof cw.showDashboard === 'function') cw.showDashboard(); + else if(!dash && typeof cw.showForm === 'function') cw.showForm(); + } catch(e){ /* cross-document timing; nothing useful to do */ } + }; + // Wait for the creator's data, not just its document. `load` fires before + // pullProject() resolves, so opening a specific package straight after load + // silently found nothing. + const whenReady = () => { + try { + const cw = frame.contentWindow; + if(cw && cw.wpCreatorReady) { applyNow(); return; } + if(cw && cw.document) { cw.document.addEventListener('wp-creator-ready', applyNow, {once:true}); return; } + } catch(e){} + applyNow(); + }; + + if(frame.getAttribute('data-src') === src && frame.contentWindow){ + whenReady(); + } else { + frame.setAttribute('data-src', src); + frame.addEventListener('load', whenReady, {once:true}); + frame.src = src; + } }else{ gate.style.display = 'block'; frame.style.display = 'none'; diff --git a/html/work-package-suite-styles.css b/html/work-package-suite-styles.css index 3dba4ba..06027b1 100644 --- a/html/work-package-suite-styles.css +++ b/html/work-package-suite-styles.css @@ -333,6 +333,19 @@ body.embed-full { overflow: hidden; } margin-top: 0.25rem; } +/* Small helper text under a field. It's used on this page (step 2's CM hint, the + team-member notices) but its only rule used to live in wp-creation-styles.css, + which this page does not link — so it rendered as unstyled body text. */ +.field-hint { font-size: 12px; color: var(--text-dim); margin-top: 0.25rem; } +.field-hint strong { color: var(--text-light); } + +/* The sign-off name pickers sit outside .field, so they got no form styling at all. */ +.user-pick { + padding: 0.75rem; border: 1px solid var(--border); border-radius: 0; + font-size: 14px; font-family: inherit; color: var(--text); background: var(--bg); +} +.user-pick:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-light); } + /* ROLES */ .required-roles { display: flex; diff --git a/html/work-package-suite.html b/html/work-package-suite.html index fb46063..3e94661 100644 --- a/html/work-package-suite.html +++ b/html/work-package-suite.html @@ -5,6 +5,9 @@ Work Package Suite + + @@ -429,7 +432,6 @@ - diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 1047a0d..a5480a7 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -1403,6 +1403,12 @@ const WP_NAV_CRITICAL_CSS = ` body.wp-nav-collapsed .wp-nav-link-label,body.wp-nav-collapsed .wp-nav-sect, body.wp-nav-collapsed .wp-nav-filter,body.wp-nav-collapsed .wp-nav-group, body.wp-nav-collapsed .wp-nav-cta-label,body.wp-nav-collapsed .wp-nav-cta-more{display:none;} + /* Modals are hidden by a stylesheet rule; without it their contents render inline + in the middle of the form. Same reasoning as the panel: this is a floor. */ + .modal-overlay:not(.open),.cmt-overlay:not(.open){display:none!important;} + /* The jump bar's sticky offset is set inline by JS; give it a sane default so a + stale stylesheet can't park it behind the opaque header. */ + .section-nav-bar{position:sticky;top:48px;z-index:30;background:#fff;} `; function injectWpNavCriticalCss(){ @@ -1586,6 +1592,15 @@ function renderWpNav(){ }).join(''); } +// Open a package by id. The suite shell calls this instead of reloading the whole +// document with a ?wp= parameter, so unsaved edits and scroll position survive. +function openWpById(id){ + const ix = savedPackages.findIndex(x => x.id === id); + if(ix < 0) return false; + wpNavOpen(ix); + return true; +} + function wpNavOpen(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; @@ -2100,9 +2115,21 @@ function bootData(){ renderSavedList(); positionSectionNav(); cmtInit(); - // Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard). - const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } + // Deep-link: a specific package (?wp=, from the global search), or the dashboard. + const p=new URLSearchParams(location.search); + const wantWp=p.get('wp'); + if(wantWp){ + const ix=savedPackages.findIndex(x=>x.id===wantWp); + if(ix>=0) wpNavOpen(ix); + else toast('That work package is not on this project (it may have been deleted).'); + } + if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } track('app_open'); + // The embedding shell needs to know when the packages are actually in hand: the + // frame's `load` event fires long before pullProject() resolves, so anything that + // acts on a specific package has to wait for this instead. + window.wpCreatorReady = true; + try { document.dispatchEvent(new CustomEvent('wp-creator-ready')); } catch(e){} } window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); }); // Pull this project's shared SOP + Work Packages from the server first, then boot diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index 483acba..4ec9bf1 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -5,6 +5,9 @@ Work Package (IWP) — Prime Controls + + @@ -394,6 +397,5 @@ - diff --git a/nginx/conf.d/wp-suite.conf b/nginx/conf.d/wp-suite.conf index 3905097..b67673b 100644 --- a/nginx/conf.d/wp-suite.conf +++ b/nginx/conf.d/wp-suite.conf @@ -22,6 +22,16 @@ server { location / { try_files $uri $uri/ =404; + + # Code assets must revalidate on every load. With no Cache-Control the browser + # applies HEURISTIC freshness (roughly 10% of the file's age), so the least + # recently changed file gets the LONGEST lifetime — which is exactly how a page + # ends up running against a stylesheet or script from a previous deploy. + # ETag/Last-Modified still make the revalidation a cheap 304. + location ~* \.(html|css|js|webmanifest)$ { + add_header Cache-Control "no-cache" always; + try_files $uri =404; + } } # Proxy /api/ to the FastAPI container (service name "api" on the internal network) diff --git a/server/app.py b/server/app.py index a3fe7dd..2d78d39 100644 --- a/server/app.py +++ b/server/app.py @@ -1625,4 +1625,21 @@ def list_comments( # Mounted LAST so the /api/* routes above always match first. _html_dir = os.path.join(os.path.dirname(__file__), "..", "html") if os.path.isdir(_html_dir): - app.mount("/", StaticFiles(directory=_html_dir, html=True), name="site") + + class _NoCacheCode(StaticFiles): + """Serve code assets with Cache-Control: no-cache. + + Production runs behind NGINX (which now sets this itself), but the dev server + is what people actually click around in — and with no header at all the + browser applies HEURISTIC freshness per file (~10% of the file's age), so the + least recently changed file gets the longest lifetime and HTML/CSS/JS drift + apart between reloads. ETag/Last-Modified still make revalidation a cheap 304. + """ + + async def get_response(self, path, scope): + res = await super().get_response(path, scope) + if path.endswith((".html", ".css", ".js", ".webmanifest")) or path in ("", "/", "."): + res.headers["Cache-Control"] = "no-cache" + return res + + app.mount("/", _NoCacheCode(directory=_html_dir, html=True), name="site")