Fix report pagination and rebuild saved plan navigation
Test and deploy / test-and-deploy (push) Successful in 1m43s
Test and deploy / test-and-deploy (push) Successful in 1m43s
This commit is contained in:
1 parent
824a964f9d
commit
5f3a015ea2
20 files changed
+1480
-809
No files matched your search
+35
-226
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user