diff --git a/Dockerfile b/Dockerfile index 7dad0ce..7137f6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ COPY public ./public COPY server ./server COPY shared ./shared COPY db/migrations ./db/migrations +COPY scripts ./scripts +RUN node scripts/stamp-asset-version.mjs # The optional Pi agent configuration is mounted read-only here at runtime. ENV HOME=/home/node diff --git a/public/account.html b/public/account.html index aabbc8b..5a32131 100644 --- a/public/account.html +++ b/public/account.html @@ -4,8 +4,8 @@ Account — Roast Planner - - + + @@ -254,6 +254,6 @@ - + diff --git a/public/admin.html b/public/admin.html index 5ba958b..3f1dfb6 100644 --- a/public/admin.html +++ b/public/admin.html @@ -4,8 +4,8 @@ Admin — Roast Planner - - + + @@ -197,6 +197,6 @@ - + diff --git a/public/cupping.html b/public/cupping.html index d396298..d8c49f3 100644 --- a/public/cupping.html +++ b/public/cupping.html @@ -4,8 +4,8 @@ Cupping — Roast Planner - - + + @@ -260,6 +260,6 @@ document.getElementById("cupping-session-view").classList.remove("hidden"); } - + diff --git a/public/forgot.html b/public/forgot.html index 8ea65e3..9b5153b 100644 --- a/public/forgot.html +++ b/public/forgot.html @@ -4,8 +4,8 @@ Reset your password — Roast Planner - - + + @@ -38,6 +38,6 @@ - + diff --git a/public/index.html b/public/index.html index d6567c7..ea95e5a 100644 --- a/public/index.html +++ b/public/index.html @@ -7,11 +7,11 @@ - - - + + + - +
@@ -2492,6 +2492,6 @@ - + diff --git a/public/inventory.html b/public/inventory.html index f4bdd1d..6e85afc 100644 --- a/public/inventory.html +++ b/public/inventory.html @@ -4,8 +4,8 @@ Inventory — Roast Planner - - + + @@ -217,6 +217,6 @@
- + diff --git a/public/js/account.js b/public/js/account.js index 974953f..a419ff9 100644 --- a/public/js/account.js +++ b/public/js/account.js @@ -1,6 +1,6 @@ -import { api, protectedFetch } from "./api.js?v=20260730-release2"; -import { initSideNav, loadNavUser } from "./nav.js?v=20260730-release2"; -import { showToast } from "./toast.js?v=20260730-release2"; +import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; +import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__"; +import { showToast } from "./toast.js?v=__ASSET_VERSION__"; function fmtDate(value) { return value ? new Date(value).toLocaleString() : "—"; diff --git a/public/js/admin.js b/public/js/admin.js index 7541517..f7b59eb 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,6 +1,6 @@ -import { api, protectedFetch } from "./api.js?v=20260730-release2"; -import { initSideNav, loadNavUser } from "./nav.js?v=20260730-release2"; -import { showToast } from "./toast.js?v=20260730-release2"; +import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; +import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__"; +import { showToast } from "./toast.js?v=__ASSET_VERSION__"; let currentUsers = []; let planFilterUserId = null; diff --git a/public/js/cupping.js b/public/js/cupping.js index 05c34b8..29e3859 100644 --- a/public/js/cupping.js +++ b/public/js/cupping.js @@ -1,7 +1,7 @@ -import { api, protectedFetch } from "./api.js?v=20260730-release2"; -import { initSideNav, loadNavUser } from "./nav.js?v=20260730-release2"; -import { showToast } from "./toast.js?v=20260730-release2"; -import { wireWhyPanels } from "./why-panels.js?v=20260730-release2"; +import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; +import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__"; +import { showToast } from "./toast.js?v=__ASSET_VERSION__"; +import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__"; import { SCORE_ATTRS, SCORE_LABELS, @@ -11,7 +11,7 @@ import { MAX_FLAVOR_TAGS, MAX_CUP_COUNT, computeTotalScore, -} from "/shared/cupping.js?v=20260730-release2"; +} from "/shared/cupping.js?v=__ASSET_VERSION__"; const sessionId = new URLSearchParams(location.search).get("session"); diff --git a/public/js/inventory.js b/public/js/inventory.js index 0a17bed..96679e0 100644 --- a/public/js/inventory.js +++ b/public/js/inventory.js @@ -1,6 +1,6 @@ -import { api, protectedFetch } from "./api.js?v=20260730-release2"; -import { initSideNav, loadNavUser } from "./nav.js?v=20260730-release2"; -import { showToast } from "./toast.js?v=20260730-release2"; +import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; +import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__"; +import { showToast } from "./toast.js?v=__ASSET_VERSION__"; let lots = []; let showArchived = false; diff --git a/public/js/lot-picker.js b/public/js/lot-picker.js index 028e88a..d66678c 100644 --- a/public/js/lot-picker.js +++ b/public/js/lot-picker.js @@ -1,5 +1,5 @@ -import { api, protectedFetch } from "./api.js?v=20260730-release2"; -import { showToast } from "./toast.js?v=20260730-release2"; +import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; +import { showToast } from "./toast.js?v=__ASSET_VERSION__"; let lots = []; diff --git a/public/js/main.js b/public/js/main.js index f1cc6c4..2604da7 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,19 +1,19 @@ -import { FIELD_IDS, blankPlan } from "/shared/fields.js?v=20260730-release2"; -import { computeLedger } from "/shared/ledger.js?v=20260730-release2"; +import { FIELD_IDS, blankPlan } from "/shared/fields.js?v=__ASSET_VERSION__"; +import { computeLedger } from "/shared/ledger.js?v=__ASSET_VERSION__"; import { formatDuration, formatSigned, parseRangeMidpoint, -} from "/shared/time.js?v=20260730-release2"; -import { CULTIVARS, findCultivar } from "/shared/reference-data.js?v=20260730-release2"; -import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js?v=20260730-release2"; -import { initPrefillPanel } from "./prefill-ui.js?v=20260730-release2"; -import { initAlogPanel } from "./alog-ui.js?v=20260730-release2"; -import { initPrint } from "./print.js?v=20260730-release2"; -import { initLotPicker } from "./lot-picker.js?v=20260730-release2"; -import { protectedFetch, csrfToken } from "./api.js?v=20260730-release2"; -import { initSideNav } from "./nav.js?v=20260730-release2"; -import { wireWhyPanels } from "./why-panels.js?v=20260730-release2"; +} from "/shared/time.js?v=__ASSET_VERSION__"; +import { CULTIVARS, findCultivar } from "/shared/reference-data.js?v=__ASSET_VERSION__"; +import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js?v=__ASSET_VERSION__"; +import { initPrefillPanel } from "./prefill-ui.js?v=__ASSET_VERSION__"; +import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__"; +import { initPrint } from "./print.js?v=__ASSET_VERSION__"; +import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__"; +import { protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__"; +import { initSideNav } from "./nav.js?v=__ASSET_VERSION__"; +import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__"; const FIELD_ID_SET = new Set(FIELD_IDS); const STORAGE_PREFIX = "roastPlannerPlan.v2"; @@ -903,7 +903,7 @@ function wirePwa() { const hadController = !!navigator.serviceWorker.controller; window.addEventListener("load", () => { navigator.serviceWorker - .register("/sw.js?v=20260730-release2") + .register("/sw.js?v=__ASSET_VERSION__") .then((registration) => { const showUpdate = () => document.getElementById("btn-refresh").classList.remove("hidden"); diff --git a/public/landing.html b/public/landing.html index 6045270..da8108b 100644 --- a/public/landing.html +++ b/public/landing.html @@ -8,8 +8,8 @@ name="description" content="A structured worksheet that computes first crack, development, and drop from your bean — then keeps the plan next to what actually happened." /> - - + + diff --git a/public/login.html b/public/login.html index 7bd9154..1c2f24a 100644 --- a/public/login.html +++ b/public/login.html @@ -4,8 +4,8 @@ Log in — Roast Planner - - + + @@ -44,6 +44,6 @@ - + diff --git a/public/reset.html b/public/reset.html index 7698575..c1b0586 100644 --- a/public/reset.html +++ b/public/reset.html @@ -4,8 +4,8 @@ Set a new password — Roast Planner - - + + @@ -46,6 +46,6 @@ - + diff --git a/public/setup.html b/public/setup.html index e55a058..5355412 100644 --- a/public/setup.html +++ b/public/setup.html @@ -4,8 +4,8 @@ Administrator setup — Roast Planner - - + + @@ -58,6 +58,6 @@ - + diff --git a/public/signup.html b/public/signup.html index 042ffc5..8c39c8f 100644 --- a/public/signup.html +++ b/public/signup.html @@ -4,8 +4,8 @@ Create an account — Roast Planner - - + + @@ -55,6 +55,6 @@ - + diff --git a/public/sw.js b/public/sw.js index 597415e..e92a749 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "roast-planner-static-v8"; +const CACHE_NAME = "roast-planner-static-__ASSET_VERSION__"; // Only /app is precached as a navigable page: it is a data-free authenticated shell (draft // data lives only in account-scoped local storage, and logout clears that namespace), so it is // the one page that is both safe and useful to relaunch offline. The marketing page, auth @@ -7,25 +7,25 @@ const CACHE_NAME = "roast-planner-static-v8"; // stale content. const APP_SHELL = [ "/app", - "/app.css?v=20260730-release2", - "/worksheet.css?v=20260730-release2", - "/manifest.webmanifest?v=20260730-release2", - "/icon.svg?v=20260730-release2", - "/js/main.js?v=20260730-release2", - "/js/api.js?v=20260730-release2", - "/js/nav.js?v=20260730-release2", - "/js/toast.js?v=20260730-release2", - "/js/why-panels.js?v=20260730-release2", - "/js/lot-picker.js?v=20260730-release2", - "/js/prefill-ui.js?v=20260730-release2", - "/js/alog-ui.js?v=20260730-release2", - "/js/print.js?v=20260730-release2", - "/shared/fields.js?v=20260730-release2", - "/shared/ledger.js?v=20260730-release2", - "/shared/time.js?v=20260730-release2", - "/shared/reference-data.js?v=20260730-release2", - "/shared/curve.js?v=20260730-release2", - "/shared/cupping.js?v=20260730-release2", + "/app.css?v=__ASSET_VERSION__", + "/worksheet.css?v=__ASSET_VERSION__", + "/manifest.webmanifest?v=__ASSET_VERSION__", + "/icon.svg?v=__ASSET_VERSION__", + "/js/main.js?v=__ASSET_VERSION__", + "/js/api.js?v=__ASSET_VERSION__", + "/js/nav.js?v=__ASSET_VERSION__", + "/js/toast.js?v=__ASSET_VERSION__", + "/js/why-panels.js?v=__ASSET_VERSION__", + "/js/lot-picker.js?v=__ASSET_VERSION__", + "/js/prefill-ui.js?v=__ASSET_VERSION__", + "/js/alog-ui.js?v=__ASSET_VERSION__", + "/js/print.js?v=__ASSET_VERSION__", + "/shared/fields.js?v=__ASSET_VERSION__", + "/shared/ledger.js?v=__ASSET_VERSION__", + "/shared/time.js?v=__ASSET_VERSION__", + "/shared/reference-data.js?v=__ASSET_VERSION__", + "/shared/curve.js?v=__ASSET_VERSION__", + "/shared/cupping.js?v=__ASSET_VERSION__", ]; self.addEventListener("install", (event) => { diff --git a/scripts/stamp-asset-version.mjs b/scripts/stamp-asset-version.mjs new file mode 100644 index 0000000..48ae984 --- /dev/null +++ b/scripts/stamp-asset-version.mjs @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +const PLACEHOLDER = "__ASSET_VERSION__"; +const root = path.resolve(process.argv[2] || process.cwd()); +const assetRoots = ["public", "shared"]; + +async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? walk(target) : target; + }), + ); + return files.flat().sort(); +} + +const files = ( + await Promise.all(assetRoots.map((directory) => walk(path.join(root, directory)))) +).flat(); +const sources = await Promise.all(files.map((file) => readFile(file, "utf8"))); +const version = createHash("sha256") + .update(files.map((file, index) => `${path.relative(root, file)}\0${sources[index]}`).join("\0")) + .digest("hex") + .slice(0, 16); +let replacements = 0; +await Promise.all( + files.map(async (file, index) => { + const source = sources[index]; + const count = source.split(PLACEHOLDER).length - 1; + if (!count) return; + replacements += count; + await writeFile(file, source.replaceAll(PLACEHOLDER, version)); + }), +); +if (!replacements) throw new Error(`No ${PLACEHOLDER} markers found under ${assetRoots.join(", ")}.`); +console.log(`Stamped ${replacements} asset references with version ${version}.`); diff --git a/shared/curve.js b/shared/curve.js index ad685e7..67151a6 100644 --- a/shared/curve.js +++ b/shared/curve.js @@ -1,7 +1,7 @@ // Browser-safe. Geometry for the roast-curve SVG grid (viewBox 0 0 714 334), matching // manual-roast-planner.html's worksheet box 10 grid exactly. -import { MACHINE } from "./reference-data.js?v=20260730-release2"; +import { MACHINE } from "./reference-data.js?v=__ASSET_VERSION__"; export const PLOT = { x0: 52, x1: 652, y0: 24, y1: 294, tMaxS: 900 }; diff --git a/shared/ledger.js b/shared/ledger.js index d33234b..755fd9b 100644 --- a/shared/ledger.js +++ b/shared/ledger.js @@ -2,8 +2,8 @@ // Mirrors manual-roast-planner.html's worksheet box 6 (lines 1-9, totals A/C/D) // and box 7 (the four sanity checks) exactly. Imported by both server and browser. -import { parseDuration, parseRangeMidpoint } from "./time.js?v=20260730-release2"; -import { YELLOW_RATIO, SANITY_BANDS } from "./reference-data.js?v=20260730-release2"; +import { parseDuration, parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__"; +import { YELLOW_RATIO, SANITY_BANDS } from "./reference-data.js?v=__ASSET_VERSION__"; /** * @param {import("./fields.js").Plan} plan diff --git a/shared/reference-data.js b/shared/reference-data.js index a38a865..d4f585e 100644 --- a/shared/reference-data.js +++ b/shared/reference-data.js @@ -2,7 +2,7 @@ // (Step 1 cultivar table, Step 3 processing table, Step 4 roast-level table, Step 8 machine bands, // Step 7 sanity bands). Keep this the single source of truth — don't retype these numbers elsewhere. -import { parseRangeMidpoint } from "./time.js?v=20260730-release2"; +import { parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__"; export const YELLOW_RATIO = 0.56; const DEFAULT_DEV_THIRD_S = 90; // "1:30", the modal third-profile-number most cultivar rows share