Fix report pagination and rebuild saved plan navigation
Test and deploy / test-and-deploy (push) Successful in 1m43s

This commit is contained in:
snowspeeder committed 2026-09-05 15:47:44 -04:00
1 parent 824a964f9d
commit 5f3a015ea2
20 files changed
+1480 -809

No files matched your search

+35 -226
View File
@@ -32,7 +32,7 @@ let draftSyncedAt = null;
// The custom name of the currently loaded plan (server column, not part of the plan JSONB —
// see roast_plans.name). Header display prefers this over worksheet field 0.1 when set.
let currentPlanName = "";
let plans = [];
let lastDrawerOpener = null;
let deferredInstallPrompt = null;
let lotPicker = null;
@@ -189,62 +189,8 @@ function renderBlend() {
updateBlendTotal();
}
function renderActuatorPrintRows() {
const tbody = document.getElementById("actuator-rows");
tbody.replaceChildren();
state.plan.actuators.forEach((_, i) => {
const tr = document.createElement("tr");
tr.append(
document.createRange().createContextualFragment(`
<td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
<td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
<td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
`),
);
tbody.appendChild(tr);
});
}
function renderActuators() {} // Older plans retain their saved schedule for report export.
function renderActuatorTimeline() {
const wrap = document.getElementById("actuator-timeline");
wrap.replaceChildren();
state.plan.actuators.forEach((_, i) => {
const step = document.createElement("div");
step.className = "actuator-step";
step.append(
document.createRange().createContextualFragment(`
<div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
<div class="actuator-card">
<label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
<label class="field"><span class="field-label">Heat %</span><input class="field-input sm" name="actuators.${i}.heatPct"></label>
<label class="field"><span class="field-label">Fan %</span><input class="field-input sm" name="actuators.${i}.fanPct"></label>
<button type="button" class="actuator-remove" data-remove-actuator="${i}" aria-label="Remove step" ${state.plan.actuators.length <= 1 ? "disabled" : ""}>✕</button>
<label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
<label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
</div>
`),
);
wrap.appendChild(step);
});
for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
btn.addEventListener("click", () => {
const i = Number(btn.dataset.removeActuator);
if (state.plan.actuators.length <= 1) return;
state.plan.actuators.splice(i, 1);
renderActuators();
renderFormFromPlan();
recompute();
});
}
}
function renderActuators() {
renderActuatorPrintRows();
renderActuatorTimeline();
updateTempUnitUI(); // new actuator rows need their expectedBt placeholder set too
}
// ---- populate every control in the form from state.plan
export function renderFormFromPlan() {
@@ -532,7 +478,6 @@ function syncPlan(snapshot = structuredClone(state.plan)) {
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
setSaveStatus("saved");
await loadPlans();
return true;
} catch {
setSaveStatus("failed");
@@ -737,7 +682,6 @@ function wireDrawers() {
for (const [buttonId, panelId] of [
["nav-prefill", "panel-prefill"],
["nav-alog", "panel-alog"],
["nav-plans", "panel-plans"],
["nav-chat", "panel-chat"],
["nav-settings", "panel-settings"],
["nav-import-export", "panel-import-export"],
@@ -779,138 +723,13 @@ function wireDrawers() {
return { open, closeAll };
}
// The custom `name` column wins over worksheet field 0.1 wherever a plan's title is shown —
// mirrors the server's COALESCE(NULLIF(name,''), plan->'fields'->>'0.1') fallback.
function planDisplayTitle(plan) {
return plan.name || plan.plan?.fields?.["0.1"] || "Untitled plan";
}
async function loadPlans() {
if (!navigator.onLine) return;
const response = await fetch("/api/plans");
if (!response.ok) return;
plans = (await response.json()).plans || [];
const list = document.getElementById("plan-list");
list.replaceChildren(
...plans.map((plan) => {
const item = document.createElement("li");
item.className = "plan-list-row";
const button = document.createElement("button");
button.type = "button";
button.className = "plan-list-item";
button.classList.toggle("active", plan.id === remotePlanId);
const title = document.createElement("strong");
title.textContent = planDisplayTitle(plan);
const updated = document.createElement("span");
updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
button.append(title, updated);
button.addEventListener("click", () => selectPlan(plan));
const renameBtn = document.createElement("button");
renameBtn.type = "button";
renameBtn.className = "icon-btn plan-list-rename";
renameBtn.style.flexShrink = "0";
renameBtn.style.alignSelf = "center";
renameBtn.setAttribute("aria-label", "Rename plan");
renameBtn.textContent = "✎";
// A sibling of the select button, not nested inside it — stopPropagation alone
// wouldn't be enough to keep a click here from also selecting the plan otherwise.
renameBtn.addEventListener("click", async (event) => {
event.stopPropagation();
const next = prompt("Plan name", plan.name || "");
if (next === null) return;
try {
await api(`/api/plans/${plan.id}`, {
method: "PUT",
body: JSON.stringify({ name: next.trim() }),
});
} catch (error) {
showToast(error.message, "fail");
return;
}
if (plan.id === remotePlanId) {
currentPlanName = next.trim();
renderFormFromPlan();
renderLedger();
}
await loadPlans();
});
const deleteBtn = document.createElement("button");
deleteBtn.type = "button";
deleteBtn.className = "icon-btn plan-list-delete";
deleteBtn.setAttribute("aria-label", `Delete ${title.textContent}`);
deleteBtn.textContent = "✕";
// A sibling of the select button, not nested inside it — stopPropagation alone
// wouldn't be enough to keep a click here from also selecting the plan otherwise.
deleteBtn.addEventListener("click", (event) => {
event.stopPropagation();
deletePlan(plan);
});
item.append(button, renameBtn, deleteBtn);
return item;
}),
);
}
async function selectPlan(plan) {
if (!(await flushCurrentPlan())) return;
remotePlanId = plan.id;
draftSyncedAt = plan.updated_at;
currentPlanName = plan.name || "";
state.plan = { ...blankPlan(), ...plan.plan };
history.replaceState(
null,
"",
`/app?plan=${encodeURIComponent(remotePlanId)}`,
);
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
// renderFormFromPlan() sets the lot <select>'s value, but that only sticks if the option is
// already in the DOM — re-render the picker's own option list against the newly-loaded
// plan's inventory.lotId so switching to a plan referencing a different (or no) lot doesn't
// leave the dropdown showing the previous plan's selection or a blank state.
lotPicker?.renderOptions();
lotPicker?.refreshRefineSuggestion();
document.querySelector("[data-close-drawer]")?.click();
}
// Shared by newPlan() and deletePlan() (when the deleted plan was the one on screen) — resets
// the whole app to a fresh, unsynced plan.
function resetToBlankPlan() {
remotePlanId = null;
draftSyncedAt = null;
currentPlanName = "";
state.plan = blankPlan();
history.replaceState(null, "", "/app");
renderBlend();
renderActuators();
renderFormFromPlan();
recompute();
lotPicker?.renderOptions();
lotPicker?.refreshRefineSuggestion();
}
async function newPlan() {
if (!confirm("Start a new plan? Your current plan is already saved locally."))
return;
if (!(await flushCurrentPlan())) return;
resetToBlankPlan();
// Without this, the Plans drawer stays open over the now-blank form — the reset happens but
// is invisible, so it doesn't read as "starting something new" until the user closes it themselves.
document.querySelector("[data-close-drawer]")?.click();
}
async function deletePlan(plan) {
const name = planDisplayTitle(plan);
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
try {
await api(`/api/plans/${plan.id}`, { method: "DELETE" });
} catch (error) {
showToast(error.message, "fail");
return;
}
showToast("Plan deleted.");
if (plan.id === remotePlanId) resetToBlankPlan();
await loadPlans();
async function leaveForPlans(event) {
event.preventDefault();
if (!(await flushCurrentPlan())) {
showToast("Your changes are saved on this device, but sync failed. Retry before switching plans.", "fail");
return;
}
location.assign("/plans");
}
// Mobile-only "Tools ▾" disclosure in the header; a no-op on desktop where the group renders
@@ -976,7 +795,8 @@ function wireToolbar() {
}
});
document.getElementById("btn-new-plan").addEventListener("click", newPlan);
document.getElementById("nav-plans").addEventListener("click", leaveForPlans);
document.querySelector('#nav-links a[href="/plans"]')?.addEventListener("click", leaveForPlans);
document.getElementById("btn-add-blend").addEventListener("click", () => {
state.plan.blendComponents.push({
cultivar: "",
@@ -997,18 +817,7 @@ function wireToolbar() {
location.replace("/");
}
});
document.getElementById("btn-add-actuator").addEventListener("click", () => {
state.plan.actuators.push({
time: "",
heatPct: "",
fanPct: "",
expectedBt: "",
why: "",
});
renderActuators();
renderFormFromPlan();
recompute();
});
}
function wirePwa() {
@@ -1075,7 +884,7 @@ function wirePwa() {
}
function wireSectionNav() {
const links = [...document.querySelectorAll(".section-nav a")];
const links = [...document.querySelectorAll('.section-nav a[href^="#"]')];
const sections = links
.map((a) => document.querySelector(a.getAttribute("href")))
.filter(Boolean);
@@ -1119,28 +928,28 @@ async function init() {
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
await loadPlans();
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
// Prefer the local draft only when it targets this exact plan AND was last confirmed
// synced at or after the server's own updated_at — otherwise another device's newer
// edit (or a draft from before sync tracking existed) would be silently discarded.
const draftIsCurrent =
selected &&
draftRemoteId === selected.id &&
draftSyncedAtAtLoad &&
new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad);
if (selected && !draftIsCurrent) {
state.plan = { ...blankPlan(), ...selected.plan };
remotePlanId = selected.id;
draftSyncedAt = selected.updated_at;
} else if (selected) {
remotePlanId = selected.id;
} else if (!localDraft && !requestedId && plans[0]) {
state.plan = { ...blankPlan(), ...plans[0].plan };
remotePlanId = plans[0].id;
draftSyncedAt = plans[0].updated_at;
}
const requestedId = new URLSearchParams(location.search).get("plan");
let selected = null;
if (requestedId) {
const response = await fetch(`/api/plans/${encodeURIComponent(requestedId)}`);
if (!response.ok) {
showToast("That plan could not be opened. Returning to your plans.", "fail");
location.replace("/plans");
return;
}
selected = (await response.json()).plan;
} else if (!localDraft) {
const recent = await api("/api/plans?view=summary&limit=1");
if (recent.plans[0]) selected = (await api(`/api/plans/${recent.plans[0].id}`)).plan;
}
const draftIsCurrent = selected && draftRemoteId === selected.id && draftSyncedAtAtLoad && new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad);
if (selected) {
if (!draftIsCurrent) state.plan = { ...blankPlan(), ...selected.plan, fields: {...blankPlan().fields,...selected.plan.fields} };
remotePlanId = selected.id;
draftSyncedAt = selected.updated_at;
currentPlanName = selected.name || "";
}
} catch {
// The cached app shell contains no user data. A successful online sign-in records the
// last account only until logout, allowing that account's local draft to reopen offline.