Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c9e5b0d0d | ||
|
|
45e67c918f | ||
|
|
8d6c471b10 | ||
|
|
1a63da77f3 | ||
|
|
eec422661b | ||
|
|
792714c6d5 | ||
|
|
5a780cefa8 | ||
|
|
306d256832 | ||
|
|
5ef82748ea |
@@ -0,0 +1,40 @@
|
|||||||
|
name: Test and deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: roast-command-center-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-and-deploy:
|
||||||
|
runs-on: unraid
|
||||||
|
steps:
|
||||||
|
- name: Check out the pushed revision
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Deploy to Unraid Docker
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
--project-name roast_command_center \
|
||||||
|
--env-file /run/roast-production.env \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
up -d --build --remove-orphans
|
||||||
|
|
||||||
|
- name: Verify the running container
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
--project-name roast_command_center \
|
||||||
|
--env-file /run/roast-production.env \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
exec -T app node -e "fetch('http://127.0.0.1:8090/').then((response) => process.exit(response.ok ? 0 : 1))"
|
||||||
|
curl --fail --retry 10 --retry-delay 2 https://roast.srmr.xyz/
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Run `npm test` before every commit and whenever changing authentication, API authorization, service-worker behavior, or persistence. Add a regression test under `test/` for every new route and for every security/ownership bug. Tests use an in-memory PostgreSQL-compatible database; production migration is run automatically at startup against `DATABASE_URL`.
|
Run `npm test` before every commit and whenever changing authentication, API authorization, service-worker behavior, or persistence. Add a regression test under `test/` for every new route and for every security/ownership bug. Tests use an in-memory PostgreSQL-compatible database; production migration is run automatically at startup against `DATABASE_URL`.
|
||||||
|
|
||||||
Every change pushed to the remote must be deployed to the Unraid Docker service immediately afterward. Verify the deployed container is healthy and the production endpoint responds before reporting completion.
|
Every change pushed to the remote must be deployed to the Unraid Docker service immediately afterward. Verify the deployed container is healthy and the production endpoint responds before reporting completion. Production runs from `/mnt/user/dev/roast_command_center` on xNAS. The Gitea Actions runner is the `gitea-act-runner` container (registered as `unraid-roast-deployer`); use it for the eventual push-to-`master` test/deploy workflow. Do not create duplicate runners, and never expose a runner registration token in a command, log, or commit.
|
||||||
|
|
||||||
|
Docker builds run `scripts/stamp-asset-version.mjs`, which replaces `__ASSET_VERSION__` in browser assets and the service-worker cache. Keep those markers intact: each build must produce fresh CSS/JS URLs so Cloudflare cannot serve a mixed stale frontend. The Docker entrypoint copies the read-only `PI_AGENT_CONFIG_DIR` seed mounted at `/run/pi-agent-config` into writable `/home/node/.pi/agent`; Pi locks `auth.json` at runtime, so do not mount that directory directly over the runtime path or prefill will lose configured model credentials.
|
||||||
|
|
||||||
For local development, copy `.env.example`, start `docker compose up --build`, then visit `http://localhost:8090`. Production must set `APP_ORIGIN=https://roast.srmr.xyz`, `COOKIE_SECURE=true`, a strong `POSTGRES_PASSWORD`, and a random `BOOTSTRAP_SETUP_TOKEN`. Bootstrap `[email protected]` exactly once at `POST /api/auth/bootstrap`, then remove `BOOTSTRAP_SETUP_TOKEN` from deployment configuration. Never commit credentials or a bootstrap password/token.
|
For local development, copy `.env.example`, start `docker compose up --build`, then visit `http://localhost:8090`. Production must set `APP_ORIGIN=https://roast.srmr.xyz`, `COOKIE_SECURE=true`, a strong `POSTGRES_PASSWORD`, and a random `BOOTSTRAP_SETUP_TOKEN`. Bootstrap `[email protected]` exactly once at `POST /api/auth/bootstrap`, then remove `BOOTSTRAP_SETUP_TOKEN` from deployment configuration. Never commit credentials or a bootstrap password/token.
|
||||||
|
|||||||
+7
-1
@@ -10,10 +10,16 @@ COPY public ./public
|
|||||||
COPY server ./server
|
COPY server ./server
|
||||||
COPY shared ./shared
|
COPY shared ./shared
|
||||||
COPY db/migrations ./db/migrations
|
COPY db/migrations ./db/migrations
|
||||||
|
COPY scripts ./scripts
|
||||||
|
RUN node scripts/stamp-asset-version.mjs
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/roast-command-center-entrypoint
|
||||||
|
RUN chmod 755 /usr/local/bin/roast-command-center-entrypoint
|
||||||
|
|
||||||
# The optional Pi agent configuration is mounted read-only here at runtime.
|
# The optional Pi agent configuration is mounted read-only at /run and copied to this writable
|
||||||
|
# runtime directory by the entrypoint; Pi's auth storage needs a sibling lock file.
|
||||||
ENV HOME=/home/node
|
ENV HOME=/home/node
|
||||||
RUN mkdir -p /home/node/.pi/agent && chown -R node:node /home/node/.pi
|
RUN mkdir -p /home/node/.pi/agent && chown -R node:node /home/node/.pi
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 8090
|
EXPOSE 8090
|
||||||
|
ENTRYPOINT ["roast-command-center-entrypoint"]
|
||||||
CMD ["node", "server/index.js"]
|
CMD ["node", "server/index.js"]
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Generate `BOOTSTRAP_SETUP_TOKEN` with `openssl rand -base64 48`, keep it only in
|
|||||||
|
|
||||||
### Pi agent configuration in Docker
|
### Pi agent configuration in Docker
|
||||||
|
|
||||||
The `app` service mounts `PI_AGENT_CONFIG_DIR` (default `./appdata/pi-agent`) read-only at `/home/node/.pi/agent`, the non-root Node user's Pi configuration directory. This lets `/api/prefill` use the same configured model at runtime without baking credentials into the image. The directory is ignored by Git and Docker build context; do not commit its contents.
|
The `app` service mounts `PI_AGENT_CONFIG_DIR` (default `./appdata/pi-agent`) read-only at `/run/pi-agent-config`. Its entrypoint copies that seed into the non-root Node user's writable runtime configuration directory before startup: Pi's credential storage needs to create a lock beside `auth.json`. This lets `/api/prefill` use the configured model without baking credentials into the image or mutating the host configuration. The directory is ignored by Git and Docker build context; do not commit its contents.
|
||||||
|
|
||||||
Before bringing up the stack, sync only the local Pi agent configuration you intend to make available to the container:
|
Before bringing up the stack, sync only the local Pi agent configuration you intend to make available to the container:
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -15,8 +15,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
# Mount only non-secret Pi agent model/auth configuration; keep it read-only.
|
# Seed Pi's writable runtime config from this read-only credential mount.
|
||||||
- ${PI_AGENT_CONFIG_DIR:-./appdata/pi-agent}:/home/node/.pi/agent:ro
|
- ${PI_AGENT_CONFIG_DIR:-./appdata/pi-agent}:/run/pi-agent-config:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# The mounted Pi configuration contains credentials and must stay read-only. Pi's auth storage
|
||||||
|
# takes a lock beside auth.json, so seed a writable per-container copy before the app starts.
|
||||||
|
# That lets ModelRuntime read and refresh credentials without ever mutating the host mount.
|
||||||
|
if [ -d /run/pi-agent-config ]; then
|
||||||
|
mkdir -p "$HOME/.pi/agent"
|
||||||
|
cp -a /run/pi-agent-config/. "$HOME/.pi/agent/"
|
||||||
|
chmod -R u+rwX "$HOME/.pi/agent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
+3
-3
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Account — Roast Planner</title>
|
<title>Account — Roast Planner</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#A8481A" />
|
<meta name="theme-color" content="#A8481A" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -254,6 +254,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/js/account.js"></script>
|
<script type="module" src="/js/account.js?v=__ASSET_VERSION__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+3
-3
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Admin — Roast Planner</title>
|
<title>Admin — Roast Planner</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#A8481A" />
|
<meta name="theme-color" content="#A8481A" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -197,6 +197,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/js/admin.js"></script>
|
<script type="module" src="/js/admin.js?v=__ASSET_VERSION__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+3
-3
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Cupping — Roast Planner</title>
|
<title>Cupping — Roast Planner</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#A8481A" />
|
<meta name="theme-color" content="#A8481A" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -260,6 +260,6 @@
|
|||||||
document.getElementById("cupping-session-view").classList.remove("hidden");
|
document.getElementById("cupping-session-view").classList.remove("hidden");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/js/cupping.js"></script>
|
<script type="module" src="/js/cupping.js?v=__ASSET_VERSION__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+3
-3
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Reset your password — Roast Planner</title>
|
<title>Reset your password — Roast Planner</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#A8481A" />
|
<meta name="theme-color" content="#A8481A" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -38,6 +38,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="/js/forgot.js"></script>
|
<script type="module" src="/js/forgot.js?v=__ASSET_VERSION__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+62
-22
@@ -7,11 +7,11 @@
|
|||||||
<meta name="theme-color" content="#A8481A" />
|
<meta name="theme-color" content="#A8481A" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
<link rel="apple-touch-icon" href="/icon.svg?v=__ASSET_VERSION__" />
|
||||||
<link rel="stylesheet" href="/worksheet.css" media="print" />
|
<link rel="stylesheet" href="/worksheet.css" media="print" />
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
@@ -310,6 +310,30 @@
|
|||||||
saved locally first, then synced to your account whenever you are
|
saved locally first, then synced to your account whenever you are
|
||||||
online.
|
online.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Temperature unit</span>
|
||||||
|
<div
|
||||||
|
class="segmented small"
|
||||||
|
id="temp-unit-toggle"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Temperature unit"
|
||||||
|
>
|
||||||
|
<label class="seg-opt"
|
||||||
|
><input type="radio" name="tempUnit" value="C" checked /><span
|
||||||
|
>°C</span
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<label class="seg-opt"
|
||||||
|
><input type="radio" name="tempUnit" value="F" /><span
|
||||||
|
>°F</span
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="field-note">
|
||||||
|
Changes how Machine Plan and Roast Log temperatures are shown.
|
||||||
|
Plans are always stored in °C, so switching back is lossless.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<p class="drawer-result">
|
<p class="drawer-result">
|
||||||
Install Roast Planner from your browser menu for a focused workspace.
|
Install Roast Planner from your browser menu for a focused workspace.
|
||||||
Updates wait for your confirmation so an in-progress plan is never
|
Updates wait for your confirmation so an in-progress plan is never
|
||||||
@@ -1371,7 +1395,14 @@
|
|||||||
fill="var(--surface-raised)"
|
fill="var(--surface-raised)"
|
||||||
/>
|
/>
|
||||||
<rect class="band" x="52" y="65.4" width="600" height="19.8" />
|
<rect class="band" x="52" y="65.4" width="600" height="19.8" />
|
||||||
<text x="58" y="62.4" class="bandline">
|
<text
|
||||||
|
x="58"
|
||||||
|
y="62.4"
|
||||||
|
class="bandline"
|
||||||
|
data-band-lo="176"
|
||||||
|
data-band-hi="187"
|
||||||
|
data-band-prefix="first-crack band, "
|
||||||
|
>
|
||||||
first-crack band, 176–187 °C
|
first-crack band, 176–187 °C
|
||||||
</text>
|
</text>
|
||||||
<g class="min">
|
<g class="min">
|
||||||
@@ -1428,15 +1459,15 @@
|
|||||||
<line class="ax" x1="52" y1="24" x2="52" y2="294" />
|
<line class="ax" x1="52" y1="24" x2="52" y2="294" />
|
||||||
<line class="ax" x1="52" y1="294" x2="652" y2="294" />
|
<line class="ax" x1="52" y1="294" x2="652" y2="294" />
|
||||||
<line class="ax" x1="652" y1="24" x2="652" y2="294" />
|
<line class="ax" x1="652" y1="24" x2="652" y2="294" />
|
||||||
<g class="al" text-anchor="end">
|
<g class="al curve-axis-temp" text-anchor="end">
|
||||||
<text x="47" y="296.8">60</text>
|
<text data-tempc="60" x="47" y="296.8">60</text>
|
||||||
<text x="47" y="260.8">80</text>
|
<text data-tempc="80" x="47" y="260.8">80</text>
|
||||||
<text x="47" y="224.8">100</text>
|
<text data-tempc="100" x="47" y="224.8">100</text>
|
||||||
<text x="47" y="188.8">120</text>
|
<text data-tempc="120" x="47" y="188.8">120</text>
|
||||||
<text x="47" y="152.8">140</text>
|
<text data-tempc="140" x="47" y="152.8">140</text>
|
||||||
<text x="47" y="116.8">160</text>
|
<text data-tempc="160" x="47" y="116.8">160</text>
|
||||||
<text x="47" y="80.8">180</text>
|
<text data-tempc="180" x="47" y="80.8">180</text>
|
||||||
<text x="47" y="44.8">200</text>
|
<text data-tempc="200" x="47" y="44.8">200</text>
|
||||||
</g>
|
</g>
|
||||||
<g class="al" text-anchor="middle">
|
<g class="al" text-anchor="middle">
|
||||||
<text x="52" y="304">0:00</text>
|
<text x="52" y="304">0:00</text>
|
||||||
@@ -1979,7 +2010,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th style="width: 28mm">Milestone</th>
|
<th style="width: 28mm">Milestone</th>
|
||||||
<th class="num" style="width: 20mm">Time</th>
|
<th class="num" style="width: 20mm">Time</th>
|
||||||
<th class="num" style="width: 18mm">°C</th>
|
<th class="num" style="width: 18mm" id="print-machine-temp-th">°C</th>
|
||||||
<th class="num" style="width: 36mm">Your logged band</th>
|
<th class="num" style="width: 36mm">Your logged band</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1995,7 +2026,7 @@
|
|||||||
name="temps.charge.tempC"
|
name="temps.charge.tempC"
|
||||||
/><span class="pv"></span>
|
/><span class="pv"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="num">133–195 °C</td>
|
<td class="num" data-band-lo="133" data-band-hi="195">133–195 °C</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Turning point</td>
|
<td>Turning point</td>
|
||||||
@@ -2012,7 +2043,7 @@
|
|||||||
name="temps.tp.tempC"
|
name="temps.tp.tempC"
|
||||||
/><span class="pv"></span>
|
/><span class="pv"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="num">77–100 °C @ 0:47–1:15</td>
|
<td class="num" data-band-lo="77" data-band-hi="100" data-band-suffix=" @ 0:47–1:15">77–100 °C @ 0:47–1:15</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Yellow</td>
|
<td>Yellow</td>
|
||||||
@@ -2025,7 +2056,7 @@
|
|||||||
name="temps.yellow.tempC"
|
name="temps.yellow.tempC"
|
||||||
/><span class="pv"></span>
|
/><span class="pv"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="num">148–166 °C</td>
|
<td class="num" data-band-lo="148" data-band-hi="166">148–166 °C</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>First crack</td>
|
<td>First crack</td>
|
||||||
@@ -2036,7 +2067,7 @@
|
|||||||
name="temps.fc.tempC"
|
name="temps.fc.tempC"
|
||||||
/><span class="pv"></span>
|
/><span class="pv"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="num">176–187 °C</td>
|
<td class="num" data-band-lo="176" data-band-hi="187">176–187 °C</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Drop</td>
|
<td>Drop</td>
|
||||||
@@ -2047,7 +2078,7 @@
|
|||||||
name="temps.drop.tempC"
|
name="temps.drop.tempC"
|
||||||
/><span class="pv"></span>
|
/><span class="pv"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="num">183–197 °C light</td>
|
<td class="num" data-band-lo="183" data-band-hi="197" data-band-suffix=" light">183–197 °C light</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -2134,7 +2165,15 @@
|
|||||||
width="600"
|
width="600"
|
||||||
height="19.8"
|
height="19.8"
|
||||||
/>
|
/>
|
||||||
<text x="58" y="62.4" class="alr" style="font-size: 7px">
|
<text
|
||||||
|
x="58"
|
||||||
|
y="62.4"
|
||||||
|
class="alr"
|
||||||
|
style="font-size: 7px"
|
||||||
|
data-band-lo="176"
|
||||||
|
data-band-hi="187"
|
||||||
|
data-band-prefix="your first-crack band, "
|
||||||
|
>
|
||||||
your first-crack band, 176–187 °C
|
your first-crack band, 176–187 °C
|
||||||
</text>
|
</text>
|
||||||
<g class="min">
|
<g class="min">
|
||||||
@@ -2207,6 +2246,7 @@
|
|||||||
class="ttl"
|
class="ttl"
|
||||||
transform="rotate(-90 14 159)"
|
transform="rotate(-90 14 159)"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
|
id="print-curve-axis-title"
|
||||||
>
|
>
|
||||||
BEAN TEMP °C
|
BEAN TEMP °C
|
||||||
</text>
|
</text>
|
||||||
@@ -2452,6 +2492,6 @@
|
|||||||
|
|
||||||
<datalist id="cultivar-list"></datalist>
|
<datalist id="cultivar-list"></datalist>
|
||||||
|
|
||||||
<script type="module" src="/js/main.js"></script>
|
<script type="module" src="/js/main.js?v=__ASSET_VERSION__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Inventory — Roast Planner</title>
|
<title>Inventory — Roast Planner</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#A8481A" />
|
<meta name="theme-color" content="#A8481A" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -217,6 +217,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/js/inventory.js"></script>
|
<script type="module" src="/js/inventory.js?v=__ASSET_VERSION__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api, protectedFetch } from "./api.js";
|
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||||
import { initSideNav, loadNavUser } from "./nav.js";
|
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
||||||
import { showToast } from "./toast.js";
|
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
||||||
|
|
||||||
function fmtDate(value) {
|
function fmtDate(value) {
|
||||||
return value ? new Date(value).toLocaleString() : "—";
|
return value ? new Date(value).toLocaleString() : "—";
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import { api, protectedFetch } from "./api.js";
|
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||||
import { initSideNav, loadNavUser } from "./nav.js";
|
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
||||||
import { showToast } from "./toast.js";
|
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
||||||
|
|
||||||
let currentUsers = [];
|
let currentUsers = [];
|
||||||
let planFilterUserId = null;
|
let planFilterUserId = null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { api, protectedFetch } from "./api.js";
|
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||||
import { initSideNav, loadNavUser } from "./nav.js";
|
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
||||||
import { showToast } from "./toast.js";
|
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
||||||
import { wireWhyPanels } from "./why-panels.js";
|
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
|
||||||
import {
|
import {
|
||||||
SCORE_ATTRS,
|
SCORE_ATTRS,
|
||||||
SCORE_LABELS,
|
SCORE_LABELS,
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
MAX_FLAVOR_TAGS,
|
MAX_FLAVOR_TAGS,
|
||||||
MAX_CUP_COUNT,
|
MAX_CUP_COUNT,
|
||||||
computeTotalScore,
|
computeTotalScore,
|
||||||
} from "/shared/cupping.js";
|
} from "/shared/cupping.js?v=__ASSET_VERSION__";
|
||||||
|
|
||||||
const sessionId = new URLSearchParams(location.search).get("session");
|
const sessionId = new URLSearchParams(location.search).get("session");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api, protectedFetch } from "./api.js";
|
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||||
import { initSideNav, loadNavUser } from "./nav.js";
|
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
||||||
import { showToast } from "./toast.js";
|
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
||||||
|
|
||||||
let lots = [];
|
let lots = [];
|
||||||
let showArchived = false;
|
let showArchived = false;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api, protectedFetch } from "./api.js";
|
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
||||||
import { showToast } from "./toast.js";
|
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
|
||||||
|
|
||||||
let lots = [];
|
let lots = [];
|
||||||
|
|
||||||
|
|||||||
+93
-16
@@ -1,19 +1,19 @@
|
|||||||
import { FIELD_IDS, blankPlan } from "/shared/fields.js";
|
import { FIELD_IDS, blankPlan } from "/shared/fields.js?v=__ASSET_VERSION__";
|
||||||
import { computeLedger } from "/shared/ledger.js";
|
import { computeLedger } from "/shared/ledger.js?v=__ASSET_VERSION__";
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
formatSigned,
|
formatSigned,
|
||||||
parseRangeMidpoint,
|
parseRangeMidpoint,
|
||||||
} from "/shared/time.js";
|
} from "/shared/time.js?v=__ASSET_VERSION__";
|
||||||
import { CULTIVARS, findCultivar } from "/shared/reference-data.js";
|
import { CULTIVARS, findCultivar } from "/shared/reference-data.js?v=__ASSET_VERSION__";
|
||||||
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js";
|
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js?v=__ASSET_VERSION__";
|
||||||
import { initPrefillPanel } from "./prefill-ui.js";
|
import { initPrefillPanel } from "./prefill-ui.js?v=__ASSET_VERSION__";
|
||||||
import { initAlogPanel } from "./alog-ui.js";
|
import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__";
|
||||||
import { initPrint } from "./print.js";
|
import { initPrint } from "./print.js?v=__ASSET_VERSION__";
|
||||||
import { initLotPicker } from "./lot-picker.js";
|
import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__";
|
||||||
import { protectedFetch, csrfToken } from "./api.js";
|
import { protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
|
||||||
import { initSideNav } from "./nav.js";
|
import { initSideNav } from "./nav.js?v=__ASSET_VERSION__";
|
||||||
import { wireWhyPanels } from "./why-panels.js";
|
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
|
||||||
|
|
||||||
const FIELD_ID_SET = new Set(FIELD_IDS);
|
const FIELD_ID_SET = new Set(FIELD_IDS);
|
||||||
const STORAGE_PREFIX = "roastPlannerPlan.v2";
|
const STORAGE_PREFIX = "roastPlannerPlan.v2";
|
||||||
@@ -26,6 +26,30 @@ let deferredInstallPrompt = null;
|
|||||||
let lotPicker = null;
|
let lotPicker = null;
|
||||||
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
|
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section
|
||||||
|
|
||||||
|
// Temperature fields are always stored in state.plan as canonical °C — this is purely a
|
||||||
|
// display-layer preference. tempC/actualBt/expectedBt cover Machine Plan, Roast Log, and
|
||||||
|
// the actuator schedule respectively; every other numeric field (%, g, days) is untouched.
|
||||||
|
const TEMP_UNIT_KEY = "roastPlannerTempUnit.v1";
|
||||||
|
const TEMP_FIELD_RE = /\.(tempC|actualBt|expectedBt)$/;
|
||||||
|
let tempUnit = localStorage.getItem(TEMP_UNIT_KEY) === "F" ? "F" : "C";
|
||||||
|
const cToF = (c) => (c * 9) / 5 + 32;
|
||||||
|
const fToC = (f) => ((f - 32) * 5) / 9;
|
||||||
|
function isTempField(name) {
|
||||||
|
return TEMP_FIELD_RE.test(name);
|
||||||
|
}
|
||||||
|
// Canonical °C string -> what the field should display right now.
|
||||||
|
function tempToDisplay(canonical) {
|
||||||
|
const n = Number.parseFloat(canonical);
|
||||||
|
if (!Number.isFinite(n)) return canonical ?? "";
|
||||||
|
return String(Math.round(tempUnit === "F" ? cToF(n) : n));
|
||||||
|
}
|
||||||
|
// What the user typed (in the current display unit) -> canonical °C string.
|
||||||
|
function tempToCanonical(display) {
|
||||||
|
const n = Number.parseFloat(display);
|
||||||
|
if (!Number.isFinite(n)) return display;
|
||||||
|
return String(Math.round((tempUnit === "F" ? fToC(n) : n) * 10) / 10);
|
||||||
|
}
|
||||||
|
|
||||||
export const state = { plan: blankPlan() };
|
export const state = { plan: blankPlan() };
|
||||||
|
|
||||||
const form = document.getElementById("plan-form");
|
const form = document.getElementById("plan-form");
|
||||||
@@ -201,6 +225,7 @@ function renderActuatorTimeline() {
|
|||||||
function renderActuators() {
|
function renderActuators() {
|
||||||
renderActuatorPrintRows();
|
renderActuatorPrintRows();
|
||||||
renderActuatorTimeline();
|
renderActuatorTimeline();
|
||||||
|
updateTempUnitUI(); // new actuator rows need their expectedBt placeholder set too
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- populate every control in the form from state.plan
|
// ---- populate every control in the form from state.plan
|
||||||
@@ -214,7 +239,7 @@ export function renderFormFromPlan() {
|
|||||||
} else if (el.type === "checkbox") {
|
} else if (el.type === "checkbox") {
|
||||||
el.checked = Boolean(value);
|
el.checked = Boolean(value);
|
||||||
} else {
|
} else {
|
||||||
el.value = value ?? "";
|
el.value = isTempField(name) ? tempToDisplay(value) : (value ?? "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateBlendVisibility(state.plan.fields["2.1"]);
|
updateBlendVisibility(state.plan.fields["2.1"]);
|
||||||
@@ -310,7 +335,8 @@ function renderBandMarkers() {
|
|||||||
const input = row.querySelector('input[name$=".tempC"]');
|
const input = row.querySelector('input[name$=".tempC"]');
|
||||||
const marker = row.querySelector(".band-marker");
|
const marker = row.querySelector(".band-marker");
|
||||||
if (!input || !marker) continue;
|
if (!input || !marker) continue;
|
||||||
const v = Number.parseFloat(input.value);
|
const raw = Number.parseFloat(input.value);
|
||||||
|
const v = Number.isFinite(raw) ? (tempUnit === "F" ? fToC(raw) : raw) : NaN;
|
||||||
if (!Number.isFinite(v)) {
|
if (!Number.isFinite(v)) {
|
||||||
marker.style.display = "none";
|
marker.style.display = "none";
|
||||||
continue;
|
continue;
|
||||||
@@ -587,7 +613,10 @@ function wireForm() {
|
|||||||
const el = e.target;
|
const el = e.target;
|
||||||
if (!el.name) return;
|
if (!el.name) return;
|
||||||
if (el.type === "radio" && !el.checked) return;
|
if (el.type === "radio" && !el.checked) return;
|
||||||
setValueForName(el.name, el.value);
|
setValueForName(
|
||||||
|
el.name,
|
||||||
|
isTempField(el.name) ? tempToCanonical(el.value) : el.value,
|
||||||
|
);
|
||||||
if (el.name === "1.1") cultivarAutofill(el.value);
|
if (el.name === "1.1") cultivarAutofill(el.value);
|
||||||
if (el.name === "2.1") updateBlendVisibility(el.value);
|
if (el.name === "2.1") updateBlendVisibility(el.value);
|
||||||
if (el.name.startsWith("blendComponents.")) updateBlendTotal();
|
if (el.name.startsWith("blendComponents.")) updateBlendTotal();
|
||||||
@@ -595,6 +624,52 @@ function wireForm() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refreshes every unit-facing bit of chrome (labels, placeholders, the toggle itself) after
|
||||||
|
// tempUnit changes. Field *values* are handled separately by renderFormFromPlan().
|
||||||
|
function updateTempUnitUI() {
|
||||||
|
const label = tempUnit === "F" ? "°F" : "°C";
|
||||||
|
for (const input of document.querySelectorAll(
|
||||||
|
'input[name$=".tempC"], input[name$=".actualBt"], input[name$=".expectedBt"]',
|
||||||
|
))
|
||||||
|
input.placeholder = label;
|
||||||
|
for (const span of document.querySelectorAll("#sec-machine .unit-input .unit"))
|
||||||
|
span.textContent = label;
|
||||||
|
const printHeader = document.getElementById("print-machine-temp-th");
|
||||||
|
if (printHeader) printHeader.textContent = label;
|
||||||
|
for (const input of document.querySelectorAll('#temp-unit-toggle input[name="tempUnit"]'))
|
||||||
|
input.checked = input.value === tempUnit;
|
||||||
|
|
||||||
|
// Static reference numbers that live outside the plan (chart axes, band captions, the
|
||||||
|
// print worksheet's "Your logged band" column) — these are fixed constants, not plan
|
||||||
|
// data, so they're driven off data-tempc/data-band-lo/hi rather than valueForName().
|
||||||
|
const conv = (c) => Math.round(tempUnit === "F" ? cToF(c) : c);
|
||||||
|
for (const el of document.querySelectorAll("[data-tempc]"))
|
||||||
|
el.textContent = String(conv(Number(el.dataset.tempc)));
|
||||||
|
for (const el of document.querySelectorAll("[data-band-lo]")) {
|
||||||
|
const lo = conv(Number(el.dataset.bandLo));
|
||||||
|
const hi = conv(Number(el.dataset.bandHi));
|
||||||
|
el.textContent = `${el.dataset.bandPrefix ?? ""}${lo}–${hi} ${label}${el.dataset.bandSuffix ?? ""}`;
|
||||||
|
}
|
||||||
|
const axisTitle = document.getElementById("print-curve-axis-title");
|
||||||
|
if (axisTitle) axisTitle.textContent = `BEAN TEMP ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTempUnit(unit) {
|
||||||
|
tempUnit = unit === "F" ? "F" : "C";
|
||||||
|
localStorage.setItem(TEMP_UNIT_KEY, tempUnit);
|
||||||
|
updateTempUnitUI();
|
||||||
|
renderFormFromPlan();
|
||||||
|
recompute();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireTempUnitToggle() {
|
||||||
|
const toggle = document.getElementById("temp-unit-toggle");
|
||||||
|
if (!toggle) return;
|
||||||
|
toggle.addEventListener("change", (e) => {
|
||||||
|
if (e.target.name === "tempUnit") setTempUnit(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function wireDrawers() {
|
function wireDrawers() {
|
||||||
const overlay = document.getElementById("drawer-overlay");
|
const overlay = document.getElementById("drawer-overlay");
|
||||||
const drawers = [...document.querySelectorAll(".drawer")];
|
const drawers = [...document.querySelectorAll(".drawer")];
|
||||||
@@ -828,7 +903,7 @@ function wirePwa() {
|
|||||||
const hadController = !!navigator.serviceWorker.controller;
|
const hadController = !!navigator.serviceWorker.controller;
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register("/sw.js")
|
.register("/sw.js?v=__ASSET_VERSION__")
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
const showUpdate = () =>
|
const showUpdate = () =>
|
||||||
document.getElementById("btn-refresh").classList.remove("hidden");
|
document.getElementById("btn-refresh").classList.remove("hidden");
|
||||||
@@ -941,6 +1016,8 @@ async function init() {
|
|||||||
initSideNav();
|
initSideNav();
|
||||||
wireDrawers();
|
wireDrawers();
|
||||||
wireToolbar();
|
wireToolbar();
|
||||||
|
wireTempUnitToggle();
|
||||||
|
updateTempUnitUI();
|
||||||
wirePwa();
|
wirePwa();
|
||||||
wireSectionNav();
|
wireSectionNav();
|
||||||
initPrefillPanel({
|
initPrefillPanel({
|
||||||
|
|||||||
+2
-2
@@ -8,8 +8,8 @@
|
|||||||
name="description"
|
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."
|
content="A structured worksheet that computes first crack, development, and drop from your bean — then keeps the plan next to what actually happened."
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#2A1D16" />
|
<meta name="theme-color" content="#2A1D16" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+3
-3
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Log in — Roast Planner</title>
|
<title>Log in — Roast Planner</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#A8481A" />
|
<meta name="theme-color" content="#A8481A" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -44,6 +44,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="/js/login.js"></script>
|
<script type="module" src="/js/login.js?v=__ASSET_VERSION__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+3
-3
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Set a new password — Roast Planner</title>
|
<title>Set a new password — Roast Planner</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#A8481A" />
|
<meta name="theme-color" content="#A8481A" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -46,6 +46,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="/js/reset.js"></script>
|
<script type="module" src="/js/reset.js?v=__ASSET_VERSION__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+3
-3
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Administrator setup — Roast Planner</title>
|
<title>Administrator setup — Roast Planner</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#A8481A" />
|
<meta name="theme-color" content="#A8481A" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -58,6 +58,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="/js/setup.js"></script>
|
<script type="module" src="/js/setup.js?v=__ASSET_VERSION__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+3
-3
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Create an account — Roast Planner</title>
|
<title>Create an account — Roast Planner</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css?v=__ASSET_VERSION__" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg?v=__ASSET_VERSION__" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#A8481A" />
|
<meta name="theme-color" content="#A8481A" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -55,6 +55,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="/js/signup.js"></script>
|
<script type="module" src="/js/signup.js?v=__ASSET_VERSION__"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+20
-20
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "roast-planner-static-v6";
|
const CACHE_NAME = "roast-planner-static-__ASSET_VERSION__";
|
||||||
// Only /app is precached as a navigable page: it is a data-free authenticated shell (draft
|
// 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
|
// 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
|
// 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-v6";
|
|||||||
// stale content.
|
// stale content.
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
"/app",
|
"/app",
|
||||||
"/app.css",
|
"/app.css?v=__ASSET_VERSION__",
|
||||||
"/worksheet.css",
|
"/worksheet.css?v=__ASSET_VERSION__",
|
||||||
"/manifest.webmanifest",
|
"/manifest.webmanifest?v=__ASSET_VERSION__",
|
||||||
"/icon.svg",
|
"/icon.svg?v=__ASSET_VERSION__",
|
||||||
"/js/main.js",
|
"/js/main.js?v=__ASSET_VERSION__",
|
||||||
"/js/api.js",
|
"/js/api.js?v=__ASSET_VERSION__",
|
||||||
"/js/nav.js",
|
"/js/nav.js?v=__ASSET_VERSION__",
|
||||||
"/js/toast.js",
|
"/js/toast.js?v=__ASSET_VERSION__",
|
||||||
"/js/why-panels.js",
|
"/js/why-panels.js?v=__ASSET_VERSION__",
|
||||||
"/js/lot-picker.js",
|
"/js/lot-picker.js?v=__ASSET_VERSION__",
|
||||||
"/js/prefill-ui.js",
|
"/js/prefill-ui.js?v=__ASSET_VERSION__",
|
||||||
"/js/alog-ui.js",
|
"/js/alog-ui.js?v=__ASSET_VERSION__",
|
||||||
"/js/print.js",
|
"/js/print.js?v=__ASSET_VERSION__",
|
||||||
"/shared/fields.js",
|
"/shared/fields.js?v=__ASSET_VERSION__",
|
||||||
"/shared/ledger.js",
|
"/shared/ledger.js?v=__ASSET_VERSION__",
|
||||||
"/shared/time.js",
|
"/shared/time.js?v=__ASSET_VERSION__",
|
||||||
"/shared/reference-data.js",
|
"/shared/reference-data.js?v=__ASSET_VERSION__",
|
||||||
"/shared/curve.js",
|
"/shared/curve.js?v=__ASSET_VERSION__",
|
||||||
"/shared/cupping.js",
|
"/shared/cupping.js?v=__ASSET_VERSION__",
|
||||||
];
|
];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|||||||
@@ -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}.`);
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// Browser-safe. Geometry for the roast-curve SVG grid (viewBox 0 0 714 334), matching
|
// 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.
|
// manual-roast-planner.html's worksheet box 10 grid exactly.
|
||||||
|
|
||||||
import { MACHINE } from "./reference-data.js";
|
import { MACHINE } from "./reference-data.js?v=__ASSET_VERSION__";
|
||||||
|
|
||||||
export const PLOT = { x0: 52, x1: 652, y0: 24, y1: 294, tMaxS: 900 };
|
export const PLOT = { x0: 52, x1: 652, y0: 24, y1: 294, tMaxS: 900 };
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
// Mirrors manual-roast-planner.html's worksheet box 6 (lines 1-9, totals A/C/D)
|
// 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.
|
// and box 7 (the four sanity checks) exactly. Imported by both server and browser.
|
||||||
|
|
||||||
import { parseDuration, parseRangeMidpoint } from "./time.js";
|
import { parseDuration, parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__";
|
||||||
import { YELLOW_RATIO, SANITY_BANDS } from "./reference-data.js";
|
import { YELLOW_RATIO, SANITY_BANDS } from "./reference-data.js?v=__ASSET_VERSION__";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("./fields.js").Plan} plan
|
* @param {import("./fields.js").Plan} plan
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// (Step 1 cultivar table, Step 3 processing table, Step 4 roast-level table, Step 8 machine bands,
|
// (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.
|
// Step 7 sanity bands). Keep this the single source of truth — don't retype these numbers elsewhere.
|
||||||
|
|
||||||
import { parseRangeMidpoint } from "./time.js";
|
import { parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__";
|
||||||
|
|
||||||
export const YELLOW_RATIO = 0.56;
|
export const YELLOW_RATIO = 0.56;
|
||||||
const DEFAULT_DEV_THIRD_S = 90; // "1:30", the modal third-profile-number most cultivar rows share
|
const DEFAULT_DEV_THIRD_S = 90; // "1:30", the modal third-profile-number most cultivar rows share
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
|
|||||||
assert.equal(login.status, 200);
|
assert.equal(login.status, 200);
|
||||||
assert.match(
|
assert.match(
|
||||||
login.text,
|
login.text,
|
||||||
/<script type="module" src="\/js\/login\.js"><\/script>/,
|
/<script type="module" src="\/js\/login\.js\?v=[^"]+"><\/script>/,
|
||||||
);
|
);
|
||||||
assert.equal((await anonymous.get("/signup")).status, 200);
|
assert.equal((await anonymous.get("/signup")).status, 200);
|
||||||
assert.equal((await anonymous.get("/forgot")).status, 200);
|
assert.equal((await anonymous.get("/forgot")).status, 200);
|
||||||
|
|||||||
Reference in New Issue
Block a user