feat: make roast planner mobile PWA ready

This commit is contained in:
2026-07-29 21:23:32 -04:00
parent 106b1104c3
commit 0efefb7b05
8 changed files with 197 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.pi
.pi-subagents
node_modules
npm-debug.log*
Dockerfile
README.md
+15
View File
@@ -0,0 +1,15 @@
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY public ./public
COPY server ./server
COPY shared ./shared
USER node
EXPOSE 8090
CMD ["node", "server/index.js"]
+64
View File
@@ -377,6 +377,70 @@ body.show-fids .field[data-fid]::after{ opacity:1; }
.sw-ref{ background:var(--ink-3); border-top:1px dashed var(--ink-3); background:none; height:0; border-bottom:1.5px dashed var(--ink-3); } .sw-ref{ background:var(--ink-3); border-top:1px dashed var(--ink-3); background:none; height:0; border-bottom:1.5px dashed var(--ink-3); }
.curve-wrap{ border-radius:var(--radius-md); overflow:hidden; border:1px solid var(--line); } .curve-wrap{ border-radius:var(--radius-md); overflow:hidden; border:1px solid var(--line); }
/* ─── PWA and mobile layout ───────────────────────────────────────────── */
.connection-status{
position:sticky; top:var(--header-h); z-index:29; padding:8px 14px;
background:var(--warn-bg); border-bottom:1px solid #E8D49E; color:#705318;
font-size:13px; font-weight:600; text-align:center;
}
.connection-status.hidden{ display:none; }
@media (max-width:720px){
:root{ --header-h:auto; }
body{ font-size:16px; padding-bottom:env(safe-area-inset-bottom); }
.app-header{
position:sticky; padding:10px 12px; gap:10px;
padding-top:max(10px, env(safe-area-inset-top));
}
.brand{ width:100%; justify-content:space-between; }
.brand-mark{ font-size:24px; }
.brand-text h1{ font-size:16px; }
.brand-sub{ font-size:12px; }
.section-nav{ width:100%; order:2; margin:0 -2px; }
.section-nav a{ min-height:40px; display:inline-flex; align-items:center; padding:7px 12px; }
.header-actions{
order:3; width:100%; display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:7px;
}
.header-divider{ display:none; }
.ghost-btn, .primary-btn, label.filebtn{
min-height:44px; padding:8px 6px; font-size:12px; text-align:center;
}
.autosave-chip{ grid-column:1 / -1; justify-content:center; min-height:32px; margin:0; }
.workspace-grid{ padding:14px 10px 48px; gap:14px; }
.panel-card{ margin-bottom:14px; border-radius:12px; }
.panel-head{ padding:14px; align-items:flex-start; }
.panel-head h2{ font-size:16px; padding-top:4px; }
.panel-body{ padding:14px; }
.field-grid{ grid-template-columns:1fr; gap:12px; }
.field-input, .text-input{ min-height:44px; font-size:16px; }
.field-input.sm{ font-size:16px; }
.segmented{ display:flex; width:100%; }
.seg-opt{ flex:1 1 auto; }
.seg-opt span{ min-height:40px; display:flex; align-items:center; justify-content:center; padding:6px 9px; text-align:center; white-space:normal; }
.chip-group{ gap:7px; }
.chip-opt{ flex:1 1 auto; }
.chip-opt span{ min-height:40px; justify-content:center; width:100%; padding:7px 10px; text-align:center; }
.blend-card{ padding:12px; gap:10px; }
.blend-remove, .actuator-remove, .icon-btn{ min-width:44px; min-height:44px; }
.milestone-row, .milestone-row.log-row{ padding:12px 0; }
.milestone-plan{ min-height:40px; display:flex; align-items:center; justify-content:center; }
.actuator-step{ gap:0; }
.actuator-rail{ display:none; }
.actuator-card{ padding:12px; }
.dock-card{ padding:14px; border-radius:12px; }
.checks-grid{ grid-template-columns:1fr; }
.check-tile{ min-height:76px; }
.curve-wrap{ overflow-x:auto; }
#curve-svg-live{ min-width:520px; }
.drawer{ width:100%; max-width:none; }
.drawer-head{ padding:14px; }
.drawer-body{ padding:14px; }
}
@media (prefers-reduced-motion:reduce){
*, *::before, *::after{ animation-duration:.01ms !important; animation-iteration-count:1 !important; scroll-behavior:auto !important; transition-duration:.01ms !important; }
}
/* ─── Print: hide the screen shell, show the paper worksheet ───────────── */ /* ─── Print: hide the screen shell, show the paper worksheet ───────────── */
#print-sheet{ display:none; } #print-sheet{ display:none; }
.pv{ display:none; } .pv{ display:none; }
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#FBF8F4"/>
<circle cx="256" cy="256" r="176" fill="#A8481A"/>
<path fill="#FBF8F4" d="M166 166h180v45h-135v90h116v45H211v-90h-45z"/>
<path fill="#FBEFE6" d="M256 118c43 54 71 91 71 133a71 71 0 1 1-142 0c0-42 28-79 71-133Z" opacity=".9"/>
<path fill="#A8481A" d="M233 189c-13 22-20 42-20 60a43 43 0 1 0 86 0c0-18-7-38-20-60 3 10 4 19 4 28 0 23-12 42-27 42s-27-19-27-42c0-9 1-18 4-28Z"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

+7
View File
@@ -4,6 +4,12 @@
<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>Roast Planner</title> <title>Roast Planner</title>
<meta name="theme-color" content="#A8481A">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.svg">
<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">
</head> </head>
@@ -41,6 +47,7 @@
</div> </div>
</header> </header>
<div class="connection-status hidden" id="connection-status" role="status" aria-live="polite"></div>
<div class="drawer-overlay hidden" id="drawer-overlay"></div> <div class="drawer-overlay hidden" id="drawer-overlay"></div>
<aside class="drawer hidden" id="panel-prefill" aria-hidden="true"> <aside class="drawer hidden" id="panel-prefill" aria-hidden="true">
+22
View File
@@ -494,6 +494,27 @@ function wireToolbar() {
}); });
} }
function wirePwa() {
const status = document.getElementById("connection-status");
const renderConnection = () => {
const offline = !navigator.onLine;
status.classList.toggle("hidden", !offline);
status.textContent = offline ? "Offline — changes continue saving on this device." : "";
};
window.addEventListener("online", renderConnection);
window.addEventListener("offline", renderConnection);
renderConnection();
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch((error) => {
console.warn("Service worker registration failed:", error);
});
});
}
}
function wireSectionNav() { function wireSectionNav() {
const links = [...document.querySelectorAll(".section-nav a")]; const links = [...document.querySelectorAll(".section-nav a")];
const sections = links const sections = links
@@ -523,6 +544,7 @@ function init() {
wireForm(); wireForm();
wireDrawers(); wireDrawers();
wireToolbar(); wireToolbar();
wirePwa();
wireSectionNav(); wireSectionNav();
initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute }); initPrefillPanel({ state, renderBlendRows: renderBlend, renderActuatorRows: renderActuators, renderFormFromPlan, recompute });
initAlogPanel({ state, recompute }); initAlogPanel({ state, recompute });
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Roast Planner",
"short_name": "Roast Planner",
"description": "Plan, record, and compare coffee roasts.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#FBF8F4",
"theme_color": "#A8481A",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+57
View File
@@ -0,0 +1,57 @@
const CACHE_NAME = "roast-planner-static-v1";
const APP_SHELL = [
"/",
"/index.html",
"/app.css",
"/manifest.webmanifest",
"/icon.svg",
"/js/main.js",
"/js/prefill-ui.js",
"/js/alog-ui.js",
"/js/print.js",
"/shared/fields.js",
"/shared/ledger.js",
"/shared/time.js",
"/shared/reference-data.js",
"/shared/curve.js",
];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))),
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const { request } = event;
let url;
try {
url = new URL(request.url);
} catch {
return;
}
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
if (request.mode === "navigate") {
event.respondWith(fetch(request).catch(() => caches.match("/index.html")));
return;
}
event.respondWith(
caches.match(request).then((cached) => {
const update = fetch(request)
.then((response) => {
if (response.ok) caches.open(CACHE_NAME).then((cache) => cache.put(request, response.clone()));
return response;
})
.catch(() => cached);
return cached ?? update;
}),
);
});