From b14bc4a182b7737a6fcea72fcaa50ab8497dc732 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:21:05 +0000 Subject: [PATCH] Docs, README, launchers, PWA install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 7. docs/MODULE-GUIDE.md documents the whole module contract from an author's point of view — manifest reference, client factory + moduleApi, server init/routes/health, scoping and resilience rules, a copy-paste minimal module, and a testing section with the two mocks and a resilience checklist. README rewritten with run/configure/use instructions in the prodcom-listener style, plus double-click launchers for macOS and Windows. PWA: manifest, icons, and a minimal network-first service worker that never touches /api/*. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- README.md | 142 ++++++++++++-- Start ProdDash.command | 4 + docs/MODULE-GUIDE.md | 364 ++++++++++++++++++++++++++++++++++++ public/admin/index.html | 1 + public/icons/icon-180.png | Bin 0 -> 4193 bytes public/icons/icon-192.png | Bin 0 -> 5937 bytes public/icons/icon-512.png | Bin 0 -> 19593 bytes public/index.html | 6 + public/manifest.webmanifest | 30 +++ public/shell.js | 4 + public/sw.js | 45 +++++ start.bat | 5 + 12 files changed, 581 insertions(+), 20 deletions(-) create mode 100644 Start ProdDash.command create mode 100644 docs/MODULE-GUIDE.md create mode 100644 public/icons/icon-180.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 start.bat diff --git a/README.md b/README.md index 9a2a0e6..dab0645 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,129 @@ # ProdDash -A new web application, built on lessons and code from two existing Waters Church -production tools, which are included here as reference forks: +A modular **production dashboard** for Waters Church. Any browser on the +building network opens one page and composes its own dashboard out of +**modules** — independent tiles that each show one aspect of a live service. +Ships with the live **ProdCom transcript**, the **ProPresenter Now / Next** +display, and a booth **clock**; new modules drop into `modules/` without +touching the shell (see [docs/MODULE-GUIDE.md](docs/MODULE-GUIDE.md)). + +## How it works + +`server.js` (zero dependencies, just Node.js) does four things: + +1. Serves the dashboard shell (`public/`) and the admin page (`/admin`). +2. Discovers modules in `modules/` and serves each one's client at + `/modules//`. +3. Mounts each enabled module's server routes under `/api/modules//` — + that's where module servers proxy ProdCom, poll ProPresenter, and stream + live updates over SSE, so browsers never talk to production gear + directly (no CORS, and API keys never leave the server). +4. Stores server-wide module config and named layouts under `config/`. + +Each browser arranges its own tiles (add, drag, resize, remove, per-tile +settings); the layout autosaves to that browser's `localStorage`. Named +layouts can also be saved to the server and loaded from any other browser. +Every module survives its upstream disappearing: tiles show a red status dot +and a clear degraded state, then recover on their own. + +## Run it + +Requires [Node.js](https://nodejs.org) 18 or newer. + +```bash +node server.js +``` + +Or double-click **Start ProdDash.command** (macOS) / **start.bat** (Windows). + +Then open the URL it prints — `http://localhost:24500` on this machine, or +the `http://:24500` address from any other device on the +network. + +## Configure + +Server-wide module settings (ProdCom URL, ProPresenter host, API keys, +enable/disable) live in the **admin page** — open **Admin** from the +dashboard's top bar, or go to `/admin`. Saving re-initialises the module +immediately; open tiles reconnect on their own. + +Shell settings live in [config/proddash.json](config/proddash.json): + +| Key | Meaning | +| --- | --- | +| `port` | Port ProdDash serves on (default `24500`) | +| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | + +Environment variables `PORT` and `PRODDASH_PASSCODE` override the file. +`config/modules.json` (module config) and `config/layouts/` (named layouts) +are written by the app itself. + +## Using the dashboard + +- **+ Add tile** — add any enabled module; multiple tiles of the same + module are fine (e.g. two transcripts filtered to different channels). +- Drag a tile by its **header** to move it; drag the **corner handle** to + resize; **✕** removes it. +- The **gear** on a tile holds per-tile settings (filters, text size, + display options) — these belong to this browser's layout, not the server. +- **Layout ▾** — save the current arrangement to the server under a name, + load a named layout (it becomes a local copy — browsers are never linked), + or reset this browser's dashboard. +- Every tile has a **status dot**: green = live, amber = connecting, + red = upstream unreachable (hover it for details). Tiles reconnect + automatically. + +## Included modules + +- **ProdCom Transcript** — live transcript stream with in-place updates of + in-progress speech, channel colors, per-tile channel visibility toggles + and group presets. Admin config: ProdCom URL, API key. +- **ProPresenter Now / Next** — what's live and what's next, with slide + progress, driven by the shared `propresenter-core` client (ProPresenter + 7/20). Admin config: host, port, password. +- **Clock** — a big booth clock; also the smallest possible module and the + reference for module authors. + +To develop modules against mocks instead of real gear: +`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`) and +`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter on +`127.0.0.1:1599`). See the testing section of the +[module guide](docs/MODULE-GUIDE.md). + +## Install as a desktop web app + +Open the page in a browser first, then: + +- **macOS — Safari**: File → **Add to Dock…** +- **macOS/Windows — Chrome**: click the install icon in the address bar, or + ⋮ menu → Cast, save and share → **Install page as app…** +- **Windows — Edge**: ⋯ menu → Apps → **Install this site as an app** + +Note: Chrome only offers the one-click PWA install on `http://localhost` (a +"secure context"). When opening from *another* machine over plain HTTP, use +Edge's "Install this site as an app" or Chrome's ⋮ → Cast, save and share → +"Create shortcut…" (check "Open as window") — same result: a dock/taskbar +app in its own window. + +## Repository layout ``` ProdDash/ -├── prodcom-listener/ fork of ProdCom Listener — live transcript web app -│ (zero-dependency Node server + SSE proxy to the ProdCom API) -└── band-lineup-display/ fork of BandLineupDisplay — backstage stage-lineup Electron app, - plus the shared ProPresenter 7/20 client (propresenter-core/) - and the standalone Now/Next display (propresenter-app/) +├── server.js shell server: static files, module discovery/mounting, +│ admin + layouts APIs +├── public/ dashboard shell client (index.html, shell.js, style.css) +│ └── admin/ admin page client +├── modules/ installed modules, one folder each +│ ├── clock/ +│ ├── prodcom-transcript/ +│ └── propresenter-now-next/ (carries its own copy of propresenter-core) +├── config/ proddash.json + state written by the app +├── docs/MODULE-GUIDE.md how to build and install a module +├── tools/prodcom-mock.js mock ProdCom API for development +├── prodcom-listener/ reference fork — read-only, not served +└── band-lineup-display/ reference fork — read-only, not served ``` -The new application will live at the repo root as it takes shape; the two -reference folders are snapshots (build output and `node_modules` excluded) and -are not wired into a build. - -## Reference apps at a glance - -- **prodcom-listener** — serves a web UI and proxies `/prodcom/*` to the ProdCom - API, including the Server-Sent Events transcript stream, so browsers never - talk to ProdCom directly. See [prodcom-listener/README.md](prodcom-listener/README.md). -- **band-lineup-display** — Electron app for the backstage screen showing stage - lineup photos with mic/pack labels. Carries the ProPresenter **Now / Next** - feature; `propresenter-core/` is the single home for every ProPresenter - quirk. See [band-lineup-display/README.md](band-lineup-display/README.md). +The two reference folders are snapshots of the apps ProdDash grew out of; +they are not wired into anything (but `band-lineup-display/tmp/pp-mock.js` +is handy for testing). diff --git a/Start ProdDash.command b/Start ProdDash.command new file mode 100644 index 0000000..0ca52ec --- /dev/null +++ b/Start ProdDash.command @@ -0,0 +1,4 @@ +#!/bin/zsh +# Double-click to start ProdDash (macOS) +cd "$(dirname "$0")" +exec node server.js diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md new file mode 100644 index 0000000..1dc59a4 --- /dev/null +++ b/docs/MODULE-GUIDE.md @@ -0,0 +1,364 @@ +# ProdDash Module Guide + +How to build, install and test a ProdDash module. Everything a module author +needs is on this page — you never have to read the shell's source. + +A **module** is one kind of dashboard tile: the live transcript, the +Now/Next display, a clock. Users add any number of tiles of your module to +their own dashboards, move and resize them, and give each tile its own +per-tile settings. Your module renders inside the tile and talks to the +outside world only through the small API the shell hands it. + +## The shape of a module + +A module is one folder dropped into `modules/`: + +``` +modules// +├── module.json manifest (required) +├── client.js ES module, client entry (required) +├── server.js server entry (optional — only if you need server code) +└── ... assets (css, images…) served statically at /modules// +``` + +**Installation is exactly this:** copy the folder into `modules/`, restart +ProdDash (`node server.js`), then configure and enable it at `/admin`. No +shell code changes, ever. + +## module.json + +```json +{ + "id": "my-module", + "name": "My Module", + "version": "1.0.0", + "description": "One line shown in the Add-tile picker and the admin page", + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { "w": 2, "h": 2 }, + "defaultSize": { "w": 4, "h": 3 }, + "configSchema": { + "upstreamUrl": { "type": "string", "label": "Upstream URL", "default": "http://10.0.0.5:1234" }, + "apiKey": { "type": "password", "label": "API key", "default": "" } + }, + "instanceSchema": { + "channel": { "type": "string", "label": "Channel filter", "default": "" } + } +} +``` + +| Field | Required | Meaning | +| --- | --- | --- | +| `id` | yes | Must equal the folder name. Lowercase, dashes. | +| `name` | yes | Human name shown on the tile header and in menus. | +| `version`, `description` | no | Shown in the admin page / picker. | +| `client` | yes | Client entry file, loaded as an ES module. | +| `server` | no | Server entry file (CommonJS), loaded with `require()`. | +| `style` | no | A stylesheet the shell injects once per module. | +| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. | +| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. | +| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). | + +Both schemas map field names to specs: + +``` +{ "type": "string" | "number" | "boolean" | "select" | "password", + "label": "Shown next to the field", + "default": , + "options": [ { "value": "a", "label": "A" }, … ] // "select" only +} +``` + +`password` fields are admin-config only in practice: their values are stored +in `config/modules.json`, handed to your *server* entry, and **never sent to +browsers** — not in `/api/modules`, and never echoed back into the admin +form. Anything upstream that needs the secret must go through your server +routes. + +Admin config lives in `config/modules.json` (written by the admin page — +your module never touches that file). Per-tile settings live inside each +browser's layout. + +## Client entry (`client.js`) + +An ES module whose **default export is a factory**. The shell calls it once +per tile: + +```js +export default function create({ root, moduleApi }) { + return { + start() {}, // begin rendering / connecting + stop() {}, // tear down EVERYTHING (timers, streams, observers) + onResize(w, h) {}, // optional; tile was resized (w, h in grid cells) + onConfigChange(cfg) {} // optional; admin config was saved + }; +} +``` + +- `root` — the tile's body element. **The module owns everything inside it + and must never touch DOM outside it.** +- All four methods are optional, but a module without `stop()` will leak + timers when its tile is removed. `stop()` must leave nothing running. + +### moduleApi + +| Member | What it is | +| --- | --- | +| `id` | Your module id. | +| `instanceId` | Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. | +| `config` | Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. | +| `instanceSettings` | This tile's settings: your `instanceSchema` defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. | +| `saveInstanceSettings(patch)` | Merge `patch` into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets *inside* your tile (a toggled filter, a chosen tab). | +| `fetch(path, opts)` | `fetch` scoped to your server routes: `fetch('/state')` hits `/api/modules//state`. | +| `sse(path, handlers)` | An `EventSource` scoped the same way, **with auto-reconnect** (see below). Returns a handle with `close()`. | +| `setStatus(state, msg)` | Drives the tile's status dot: `'ok'`, `'connecting'` or `'error'`, plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. | + +`sse(path, handlers)` handlers: + +```js +const stream = moduleApi.sse('/stream', { + open(e) {}, // (re)connected + error(e) {}, // connection lost — say so via setStatus + message(e) {}, // unnamed "data:" events + events: { state(e) {} }, // named events by name +}); +// later: stream.close() +``` + +Browsers' `EventSource` retries transient drops itself but gives up for good +when a retry gets a completed non-SSE response — which is what a proxy's 502 +looks like while the upstream is down. The shell's `sse()` recreates closed +streams on a 3-second timer until they work again, so **reconnect logic is +free**: handle `open` (refresh/backfill your state; you may have missed +events) and `error` (report degraded status), and you're resilient. Streams +you forget to close are force-closed when the tile unmounts — but close your +own in `stop()` anyway. + +### Lifecycle you must expect + +- **Any number of instances.** Two transcript tiles with different filters + is a feature. Keep all state inside `create()`'s closure — no module-level + mutable state in `client.js`. +- **Remounts are routine.** When the user edits the tile's gear settings the + shell calls `stop()` and starts a fresh instance. When admin config + changes, the shell calls your `onConfigChange(cfg)` if you export one, + otherwise it remounts you. Either way you must come back cleanly. +- **The tile can be removed at any moment.** `stop()` is your only notice. + +### Rules for the client + +1. **Never touch DOM outside `root`. Never define global CSS.** Scope every + rule in your stylesheet with the attribute the shell puts on your tiles: + + ```css + [data-module="my-module"] .my-thing { … } + ``` + + (Shadow DOM works too, if you prefer full isolation.) + +2. **Style with the shell's CSS variables — no hardcoded colors.** The + palette: `--bg` (page), `--panel` (tile), `--border`, `--text`, + `--muted`, `--accent`, `--danger`, `--warn`. Derive tints with + `color-mix(in srgb, var(--accent) 20%, transparent)`. This is what makes + every module look native on the dark, high-contrast booth theme. + +3. **No external network calls from the client.** Browsers talk only to + ProdDash. Anything upstream (your device, service, API) goes through your + module's server routes — same reasoning as the reference apps: no CORS, + no per-browser credentials, one place that owns the connection. + +4. **Handle upstream loss.** Reconnect with backoff (or lean on `sse()`), + and report honestly via `setStatus`. Tiles run unattended during live + services; a module that silently freezes is worse than one that says + it's down. + +## Server entry (`server.js`) + +Optional — only needed when your module talks to something upstream or keeps +server-side state. CommonJS, Node built-ins only (keep the zero-dependency +ethos; Node 18+ so global `fetch` is available). Exports either or both of: + +```js +module.exports = { + init({ config, log }) { + // Called on startup / re-init, BEFORE routes(). Start your upstream + // connection here. Return a handle: + return { + stop() {}, // tear everything down + health() { return { status: 'ok', message: '' }; } // optional + }; + }, + + routes({ config, log }) { + // Route table, mounted under /api/modules// + return { + 'GET /state': (req, res) => { … }, + 'GET /stream': (req, res) => { … }, // SSE — see notes + 'POST /command': (req, res) => { … }, + 'GET /proxy/*': (req, res) => { … }, // trailing /* = prefix route + '* /anything/*': (req, res) => { … }, // * method = any method + }; + }, +}; +``` + +- `config` is your admin config: `configSchema` defaults overlaid with what + the admin page saved — **including password fields**. +- `log(...)` prefixes output with your module id. +- Handlers get plain Node `(req, res)`. On a prefix route, `req.wildcard` is + the matched remainder (leading `/` included) and `req.search` is the query + string (with `?`, or `''`) — so a proxy rebuilds the upstream path as + `req.wildcard + req.search`. +- Exact routes match the path exactly; prefix routes (`/x/*`) match `/x` and + everything under it. First declared match wins. +- **Re-init instead of restart:** when the admin page saves your config, the + shell calls your handle's `stop()`, then `init()` and `routes()` again + with the new config. Keep module-level state inside `init()`'s closure (or + reset it there) so a re-init starts clean. Disabling a module unmounts its + routes entirely (clients get 404). +- `health()` feeds the admin page's status line — report your upstream's + reachability (`status: 'ok' | 'connecting' | 'error'`), not just "running". +- A throw from `init()`/`routes()` marks the module as failed: the error + shows in `/admin`, and API calls answer 502 until a config fix re-inits + it. Validate config early so bad input fails loudly there. + +### Server-Sent Events from a module + +For live data, expose a `/stream` route and let clients use +`moduleApi.sse('/stream', …)`: + +```js +'GET /stream': (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }); + res.write('retry: 3000\n\n'); + res.write(`event: state\ndata: ${JSON.stringify(latest)}\n\n`); // instant paint + streams.add(res); + req.on('close', () => streams.delete(res)); +} +``` + +Broadcast only when the payload actually changed, send a `: ping\n\n` +comment every ~15 s so idle connections survive sleepy Wi-Fi, and wrap every +per-client `res.write` in try/catch (drop the client on failure). The +`propresenter-now-next` module is the reference implementation of this +pattern. + +## Complete minimal example + +A working clock — copy-paste this as your starter. (A slightly fancier +version ships in `modules/clock/`.) + +`modules/hello-clock/module.json` + +```json +{ + "id": "hello-clock", + "name": "Hello Clock", + "version": "1.0.0", + "description": "Minimal example module", + "client": "client.js", + "style": "style.css", + "minSize": { "w": 2, "h": 1 }, + "defaultSize": { "w": 3, "h": 2 }, + "instanceSchema": { + "showSeconds": { "type": "boolean", "label": "Show seconds", "default": true } + } +} +``` + +`modules/hello-clock/client.js` + +```js +export default function create({ root, moduleApi }) { + let timer = null; + + function tick() { + const { showSeconds } = moduleApi.instanceSettings; + root.querySelector('.hc-time').textContent = new Date().toLocaleTimeString( + [], { hour: '2-digit', minute: '2-digit', ...(showSeconds ? { second: '2-digit' } : {}) } + ); + } + + return { + start() { + root.innerHTML = '
'; + tick(); + timer = setInterval(tick, 250); + moduleApi.setStatus('ok', 'Ticking'); + }, + stop() { + clearInterval(timer); + root.innerHTML = ''; + }, + }; +} +``` + +`modules/hello-clock/style.css` + +```css +[data-module="hello-clock"] .hc-time { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 2.4em; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text); +} +``` + +Drop the folder in `modules/`, restart ProdDash, and “Hello Clock” appears +in the Add-tile picker (new modules are enabled by default; `/admin` can +disable them). + +## Testing your module + +**Run it:** + +```bash +node server.js # from the repo root — prints the LAN URL +``` + +The server logs each module it mounts (or why it refused to). `/admin` +shows mount errors and your `health()` line; the browser console shows +client-side failures (a tile that fails to start says so in the tile). + +**Poke the pieces directly:** + +- `GET /api/modules` — what the shell sees (your manifest + non-secret config). +- `GET /api/modules//...` — your routes, `curl`-able. For SSE: + `curl -N http://localhost:24500/api/modules//stream`. +- `GET /api/admin/state` — enabled flags, health, mount errors. + +**Mock upstreams** (so you can develop without the real gear): + +- ProdCom: `node tools/prodcom-mock.js` (port 24480) — channels, groups, + history, and a live stream with in-progress speech. Point the + transcript module's ProdCom URL at `http://127.0.0.1:24480`. +- ProPresenter: `node band-lineup-display/tmp/pp-mock.js 1599` — playlists + and an advancing slide index (including the arrangement-repeat case). + Point the Now/Next module at host `127.0.0.1`, port `1599`. + +**The resilience checklist** — every module must pass this before it's done: + +1. Add your tile, then **kill the upstream** (or the mock). The status dot + must go red (with a useful hover message) and the tile must show a clear + degraded state, not a freeze. +2. **Restart the upstream.** The tile must recover by itself — no reload, + no clicking. +3. **Restart ProdDash itself** with the tile open. Same expectation. +4. Add **two tiles of your module** with different per-tile settings — both + must work independently. +5. **Remove your tile**, then check the browser console and server log: no + errors, no timers still firing, no streams still open. +6. Change your admin config in `/admin` while the tile is open — the tile + must pick it up (via `onConfigChange` or the automatic remount). +7. Reload the page — the tile comes back with its settings intact. diff --git a/public/admin/index.html b/public/admin/index.html index a273555..3081a90 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -5,6 +5,7 @@ ProdDash Admin + diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..f7475cdbb3fddcae36c99f3ad489a20542f49ff8 GIT binary patch literal 4193 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 AbN~PV literal 0 HcmV?d00001 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..96ada4d8a6fb017c4274c207d277ef2be3df67d6 GIT binary patch literal 5937 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 U5pO3J#I=Cbl^;KHAJj|9lK=n! literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..8dfcb4f1cf08dc46b8f398c08b35ce166618733f GIT binary patch literal 19593 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 LNkvXXu0mjf=|_l$ literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html index 69afd10..83b26d4 100644 --- a/public/index.html +++ b/public/index.html @@ -5,6 +5,12 @@ ProdDash + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..27b77a2 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,30 @@ +{ + "name": "ProdDash", + "short_name": "ProdDash", + "description": "Modular production dashboard for Waters Church", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0b0e12", + "theme_color": "#0b0e12", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/shell.js b/public/shell.js index 6ae8ec5..f4a8412 100644 --- a/public/shell.js +++ b/public/shell.js @@ -844,3 +844,7 @@ async function boot() { } boot(); + +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..1d4af60 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,45 @@ +'use strict'; + +/* Minimal service worker: caches the shell so the PWA is installable. + Never touches /api/* (live data + SSE streams). Network-first, so open + dashboards always run the latest shell when the server is reachable. */ + +const CACHE = 'proddash-v1'; +const SHELL = [ + '/', + '/style.css', + '/shell.js', + '/manifest.webmanifest', + '/icons/icon-192.png', + '/icons/icon-512.png', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (event.request.method !== 'GET' || url.pathname.startsWith('/api/')) return; + + // Network first, cache fallback + event.respondWith( + fetch(event.request) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, copy)); + return res; + }) + .catch(() => caches.match(event.request, { ignoreSearch: true })) + ); +}); diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..a6086fc --- /dev/null +++ b/start.bat @@ -0,0 +1,5 @@ +@echo off +rem Double-click to start ProdDash (Windows) +cd /d %~dp0 +node server.js +pause