// pantry-vision frontend logic (docs/project-plan.md Phase 17). Vanilla JS, no
// framework, no build step — same "vendored, dependency-free" choice as the digest/
// admin canvas SDKs' render.js.
//
// Config comes from URL query params (?api=...&token=...), set by the
// kitchen-display kiosk's own launch command, NOT hardcoded here — this file is a
// generic static asset with no secret in it, served read-only by pantry-web to
// whatever device points a browser at it.
//
// FOUR FLOWS, ONE CAMERA, ONE RULE
// --------------------------------
// Unload (book in), Consume (book out), Expired (throw away) and Edit (correct) are
// the four ways stock moves, and the first three are camera-first: hold the thing up,
// the model says what it is, a person confirms, it is written. The rule that shapes
// every one of them is server.py's — the camera proposes, the person disposes.
// Nothing here calls a write endpoint without a tap in between, including the flows
// where that costs an extra tap, because a camera that quietly books out the wrong
// brand of yoghurt produces an inventory nobody trusts, and an inventory nobody
// trusts is the same as no inventory.
"use strict";
const params = new URLSearchParams(location.search);
const API = (params.get("api") || "").replace(/\/$/, "");
const TOKEN = params.get("token") || "";
if (!API || !TOKEN) {
document.body.innerHTML =
'
pantry-vision not configured — missing ' +
"?api=&token= in the URL. See hosts/kitchen-display/configs/sway/pantry-kiosk.
";
throw new Error("pantry-vision: missing ?api=/&token= query params");
}
function api(path, options) {
options = options || {};
options.headers = Object.assign({ Authorization: `Bearer ${TOKEN}` }, options.headers || {});
return fetch(`${API}${path}`, options).then((res) => {
if (!res.ok) {
return res.json().catch(() => ({})).then((body) => {
throw new Error(body.error || `${res.status} ${res.statusText}`);
});
}
return res.json();
});
}
function postJson(path, body) {
return api(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
const $ = (id) => document.getElementById(id);
// --- Navigation ----------------------------------------------------------------
// Three tabs (Home / Inventory / Recipes) plus four flow screens reached from Home.
// "scan" is kept as an alias for the unload flow: it is the fragment
// kitchen-display-agent's "Show scan" MQTT button has been publishing since Phase 17,
// and a button in Home Assistant that stops working is a worse outcome than an
// old name living on here.
const SCREENS = ["home", "unload", "consume", "expired", "edit", "inventory", "recipes"];
const TABS = ["home", "inventory", "recipes"];
let currentScreen = "home";
function show(name) {
if (name === "scan") name = "unload";
if (!SCREENS.includes(name)) name = "home";
// Leaving a flow always releases the camera and cancels its loop: the kiosk has one
// webcam and a scan loop left running behind another screen would keep firing
// /identify at the LLM host with nobody watching the answers.
if (currentScreen !== name) stopScanner();
currentScreen = name;
document.querySelectorAll(".panel").forEach((p) => p.classList.toggle("active", p.id === name));
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === name));
// A flow screen is not a tab; keep Home lit while one is open so the tab bar never
// shows nothing selected.
if (!TABS.includes(name)) document.querySelector('.tab[data-tab="home"]').classList.add("active");
if (name === "inventory") loadInventory();
if (name === "recipes") loadRecipes();
if (name === "unload") startUnload();
if (name === "consume") startConsume();
if (name === "expired") startExpired();
if (name === "edit") loadEditInventory();
}
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => show(t.dataset.tab)));
document.querySelectorAll("[data-goto]").forEach((b) => b.addEventListener("click", () => show(b.dataset.goto)));
function toast(message, kind) {
const el = $("toast");
el.textContent = message;
el.className = kind || "";
el.hidden = false;
clearTimeout(toast._timer);
toast._timer = setTimeout(() => {
el.hidden = true;
}, 2600);
}
// --- The camera ----------------------------------------------------------------
// One , one MediaStream, moved between flows. The auto-scan loop samples a
// tiny greyscale thumbnail of each frame and only spends an /identify call when the
// picture has (a) settled and (b) actually changed since the last thing it
// identified. Both gates exist for the same reason: the vision model's latency is the
// known open risk in this phase (project-plan.md open decision #18), so the frames
// worth spending it on are the ones where somebody is holding something still, and
// the same tin should never be identified twice because nobody moved.
const SAMPLE_INTERVAL_MS = 700;
const STILL_THRESHOLD = 6; // mean per-pixel difference below which the frame is "settled"
const CHANGED_THRESHOLD = 12; // ...and above which it is a different item from the last one
const video = $("camera-preview");
const scanner = $("scanner");
const scanStatus = $("scan-status");
const cameraError = $("camera-error");
const captureNowBtn = $("capture-now");
const frameSample = $("frame-sample");
const capturedFrame = $("captured-frame");
let stream = null;
let scanTimer = null;
let scanBusy = false;
let previousSample = null;
let acceptedSample = null;
let onIdentified = null;
function mountScanner(screenId) {
const slot = document.querySelector(`#${screenId} .scanner-slot`);
if (slot && scanner.parentElement !== slot) slot.appendChild(scanner);
scanner.hidden = false;
}
function startCamera() {
if (stream) return Promise.resolve();
return navigator.mediaDevices
.getUserMedia({ video: { facingMode: "environment" }, audio: false })
.then((s) => {
stream = s;
video.srcObject = s;
cameraError.hidden = true;
})
.catch((err) => {
cameraError.textContent = `Camera unavailable: ${err.message}. See hosts/kitchen-display/README.md.`;
cameraError.hidden = false;
throw err;
});
}
function stopScanner() {
clearInterval(scanTimer);
scanTimer = null;
scanBusy = false;
onIdentified = null;
previousSample = null;
acceptedSample = null;
scanner.hidden = true;
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
video.srcObject = null;
}
}
/** A 32x24 greyscale thumbnail of the current frame, as a plain array. */
function sampleFrame() {
if (!video.videoWidth) return null;
frameSample.width = 32;
frameSample.height = 24;
const ctx = frameSample.getContext("2d", { willReadFrequently: true });
ctx.drawImage(video, 0, 0, 32, 24);
const data = ctx.getImageData(0, 0, 32, 24).data;
const grey = new Array(32 * 24);
for (let i = 0; i < grey.length; i++) {
const p = i * 4;
grey[i] = (data[p] * 299 + data[p + 1] * 587 + data[p + 2] * 114) / 1000;
}
return grey;
}
function frameDelta(a, b) {
if (!a || !b) return Infinity;
let total = 0;
for (let i = 0; i < a.length; i++) total += Math.abs(a[i] - b[i]);
return total / a.length;
}
/**
* Run the camera on `screenId` and call `handler(proposal)` the first time it sees
* something. The loop stops itself on a hit; the flow calls resumeScan() when the
* person is done with that item and ready for the next one.
*/
function startScan(screenId, handler, statusText) {
onIdentified = handler;
mountScanner(screenId);
scanStatus.textContent = "Starting camera…";
startCamera()
.then(() => {
scanStatus.textContent = statusText || "Hold an item up to the camera…";
previousSample = null;
clearInterval(scanTimer);
scanTimer = setInterval(tick, SAMPLE_INTERVAL_MS);
})
.catch(() => {
scanStatus.textContent = "";
});
}
function resumeScan(statusText) {
if (!stream || !onIdentified) return;
scanner.hidden = false;
scanStatus.textContent = statusText || "Ready for the next one…";
previousSample = null;
clearInterval(scanTimer);
scanTimer = setInterval(tick, SAMPLE_INTERVAL_MS);
}
function pauseScan() {
clearInterval(scanTimer);
scanTimer = null;
}
function tick() {
if (scanBusy) return;
const sample = sampleFrame();
if (!sample) return;
const settled = frameDelta(sample, previousSample) < STILL_THRESHOLD;
const changed = frameDelta(sample, acceptedSample) > CHANGED_THRESHOLD;
previousSample = sample;
if (!settled) return;
if (!changed) {
scanStatus.textContent = "Waiting for the next item…";
return;
}
identifyNow(sample);
}
/** Capture at full resolution and ask the server what it is. */
function identifyNow(sample) {
if (scanBusy) return;
scanBusy = true;
scanStatus.textContent = "Identifying…";
capturedFrame.width = video.videoWidth;
capturedFrame.height = video.videoHeight;
capturedFrame.getContext("2d").drawImage(video, 0, 0);
capturedFrame.toBlob(
(blob) => {
api("/identify", { method: "POST", body: blob, headers: { "Content-Type": "image/jpeg" } })
.then((proposal) => {
scanBusy = false;
if (!proposal.present) {
scanStatus.textContent = "Nothing recognised — hold the item closer.";
return;
}
acceptedSample = sample || sampleFrame();
pauseScan();
if (onIdentified) onIdentified(proposal);
})
.catch((err) => {
scanBusy = false;
pauseScan();
scanStatus.textContent = `Could not identify: ${err.message}`;
// A dead vision model must not turn into a loop that keeps photographing
// the counter at it. The flow decides what to offer instead — for unload,
// the manual form; for the others, a message and a way out.
if (onIdentified) onIdentified({ present: true, degraded: true, error: err.message });
});
},
"image/jpeg",
0.85
);
}
// "Identify now" is the escape hatch for the stillness gate: a shiny jar under a
// kitchen downlight can flicker enough to never settle, and standing there waving it
// is not an acceptable answer.
captureNowBtn.addEventListener("click", () => {
if (stream && !scanBusy) identifyNow(null);
});
// --- Shared item helpers -------------------------------------------------------
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
function urgencyClass(daysLeft) {
if (daysLeft === null || daysLeft === undefined) return "";
if (daysLeft < 0) return "urgent-expired";
if (daysLeft <= 2) return "urgent-soon";
if (daysLeft <= 7) return "urgent-week";
return "";
}
function dueLabel(days) {
if (days === null || days === undefined) return "no date";
if (days < 0) return `expired ${-days}d ago`;
if (days === 0) return "expires today";
return `${days}d left`;
}
function amountOf(row) {
const n = Number(row.amount);
return Number.isFinite(n) ? n : 0;
}
/** "12 × Eggs (Brand X)" the way every list in here writes it. */
function rowLabel(row) {
return `${amountOf(row)} × ${row.name}`;
}
// --- Unload groceries (book in) ------------------------------------------------
const confirmForm = $("confirm-form");
let unloadAdded = 0;
function startUnload() {
unloadAdded = 0;
$("unload-count").textContent = "";
confirmForm.hidden = true;
startScan("unload", onUnloadProposal, "Hold the first item up to the camera…");
}
function onUnloadProposal(proposal) {
scanner.hidden = false;
confirmForm.hidden = false;
$("confirm-status").textContent = "";
const degraded = !!proposal.degraded;
$("f-description").textContent = degraded
? "Could not identify this one — fill it in by hand."
: proposal.description || proposal.name || "";
$("f-name").value = proposal.name || "";
$("f-category").value = proposal.category || "other";
$("f-placement").value = proposal.recommended_placement || "cupboard";
$("f-date").value = proposal.best_before_date || new Date().toISOString().slice(0, 10);
$("f-date-source").textContent =
proposal.best_before_source === "label"
? "Date read off the packaging — check it."
: "Estimated from the category, not read off the packaging.";
$("f-packages").value = 1;
$("f-units").value = proposal.units_per_package || 1;
confirmForm.dataset.unitName = proposal.unit_name || "piece";
confirmForm.dataset.kind = proposal.kind || "";
confirmForm.dataset.brand = proposal.brand || "";
updateTotalLine();
// "You already have ten of these" at the only moment it can still change what
// somebody does — while the shopping is still on the counter.
const matches = proposal.stock_matches || [];
const held = matches.reduce((sum, m) => sum + amountOf(m), 0);
$("f-have").textContent = held
? `Already in stock: ${held} (${matches.length} ${matches.length === 1 ? "entry" : "entries"}).`
: proposal.stock_matches_unavailable
? "Could not check what is already in stock — Grocy did not answer."
: "";
$("f-confidence").textContent = degraded
? proposal.error || proposal.note || ""
: `Model confidence: ${proposal.confidence || "unknown"}. Review before confirming.`;
}
function updateTotalLine() {
const packs = Number($("f-packages").value) || 1;
const per = Number($("f-units").value) || 1;
const unit = confirmForm.dataset.unitName || "piece";
const total = packs * per;
$("f-total").textContent =
per > 1
? `Books in ${total} ${unit}${total === 1 ? "" : "s"} (${packs} × ${per}).`
: `Books in ${total} ${unit}${total === 1 ? "" : "s"}.`;
}
$("f-packages").addEventListener("input", updateTotalLine);
$("f-units").addEventListener("input", updateTotalLine);
$("skip-btn").addEventListener("click", () => {
confirmForm.hidden = true;
resumeScan("Skipped. Hold up the next item…");
});
confirmForm.addEventListener("submit", (event) => {
event.preventDefault();
$("confirm-status").textContent = "Adding…";
postJson("/confirm", {
name: $("f-name").value.trim(),
kind: confirmForm.dataset.kind || "",
brand: confirmForm.dataset.brand || "",
category: $("f-category").value,
placement: $("f-placement").value,
best_before_date: $("f-date").value,
quantity: Number($("f-packages").value) || 1,
units_per_package: Number($("f-units").value) || 1,
})
.then((result) => {
unloadAdded += 1;
$("unload-count").textContent = `${unloadAdded} booked in`;
$("confirm-status").textContent = "";
confirmForm.hidden = true;
toast(`Added ${result.amount} × ${$("f-name").value.trim()} — put it away!`);
resumeScan("Hold up the next item…");
})
.catch((err) => {
$("confirm-status").textContent = `Could not add: ${err.message}`;
});
});
// --- Consume article (book out) ------------------------------------------------
let consumeChoice = null;
let consumeUnitsPerPackage = 1;
function startConsume() {
$("consume-result").hidden = true;
$("consume-status").textContent = "";
startScan("consume", onConsumeProposal, "Hold up what you are about to use…");
}
function onConsumeProposal(proposal) {
$("consume-result").hidden = false;
$("consume-amount").hidden = true;
$("consume-status").textContent = "";
consumeUnitsPerPackage = proposal.units_per_package || 1;
if (proposal.degraded) {
$("consume-identified").textContent = "Could not identify that.";
$("consume-candidates").innerHTML =
'Try again, or correct the amount by hand on the Edit inventory screen.
';
addRescanButton();
return;
}
const matches = proposal.stock_matches || [];
$("consume-identified").textContent = `Looks like: ${proposal.name}`;
if (!matches.length) {
$("consume-candidates").innerHTML =
'Nothing matching that is booked in, so there is nothing to book out. ' +
"If it should be in stock, add it on the Unload screen first.
";
addRescanButton();
return;
}
// One unambiguous match goes straight to the amount step; several mean the camera
// knows the kind but not the brand, and picking one at random here would be exactly
// the silent wrong write this whole service is arranged to avoid.
const exact = matches.filter((m) => m.exact);
if (exact.length === 1) {
chooseConsumeRow(exact[0]);
return;
}
if (matches.length === 1) {
chooseConsumeRow(matches[0]);
return;
}
$("consume-candidates").innerHTML =
'Which one? (Same kind, different brands or dates.)
' +
matches
.map(
(m, i) => `
${escapeHtml(rowLabel(m))}
${escapeHtml(dueLabel(m.days_left))}
`
)
.join("");
$("consume-candidates")
.querySelectorAll("button.choice")
.forEach((btn) => btn.addEventListener("click", () => chooseConsumeRow(matches[Number(btn.dataset.index)])));
}
function addRescanButton() {
const btn = document.createElement("button");
btn.className = "big-btn secondary";
btn.textContent = "Scan again";
btn.addEventListener("click", () => {
$("consume-result").hidden = true;
resumeScan("Hold up what you are about to use…");
});
$("consume-candidates").appendChild(btn);
}
function chooseConsumeRow(row) {
consumeChoice = row;
$("consume-candidates").innerHTML = "";
$("consume-amount").hidden = false;
$("consume-chosen").textContent = `${row.name} — ${amountOf(row)} in stock, ${dueLabel(row.days_left)}`;
// "If multipack, ask how many": in stock terms a multipack is simply a row holding
// more than one unit, since everything was booked in as individual units. The quick
// buttons are the answers people actually give — one, the whole pack, or all of it.
const held = amountOf(row);
const quick = [1];
if (consumeUnitsPerPackage > 1 && consumeUnitsPerPackage <= held) quick.push(consumeUnitsPerPackage);
if (held > 1 && !quick.includes(held)) quick.push(held);
$("consume-qty").value = 1;
$("consume-qty").max = held || 1;
$("consume-quick").innerHTML = quick
.map(
(n) =>
`${
n === held ? `all ${n}` : n === consumeUnitsPerPackage && n > 1 ? `whole pack (${n})` : n
} `
)
.join("");
$("consume-quick")
.querySelectorAll(".quick-btn")
.forEach((btn) =>
btn.addEventListener("click", () => {
$("consume-qty").value = btn.dataset.amount;
})
);
}
$("consume-cancel").addEventListener("click", () => {
consumeChoice = null;
$("consume-result").hidden = true;
resumeScan("Hold up what you are about to use…");
});
$("consume-confirm").addEventListener("click", () => {
if (!consumeChoice) return;
const amount = Number($("consume-qty").value);
if (!(amount > 0)) {
$("consume-status").textContent = "Enter how many.";
return;
}
$("consume-status").textContent = "Booking out…";
postJson("/consume", { product_id: consumeChoice.product_id, amount, spoiled: false })
.then(() => {
toast(`Booked out ${amount} × ${consumeChoice.name}`);
consumeChoice = null;
$("consume-result").hidden = true;
$("consume-status").textContent = "";
resumeScan("Hold up the next thing…");
})
.catch((err) => {
$("consume-status").textContent = `Could not book out: ${err.message}`;
});
});
// --- Expired foods -------------------------------------------------------------
let expiredRows = [];
function startExpired() {
scanner.hidden = true;
$("expired-status").textContent = "";
loadExpired();
}
function loadExpired() {
const el = $("expired-list");
api("/expired")
.then((data) => {
expiredRows = data.items || [];
if (!expiredRows.length) {
el.innerHTML = 'Nothing has expired.
';
return;
}
el.innerHTML = expiredRows
.map(
(row, i) => `
${escapeHtml(rowLabel(row))}
${escapeHtml(dueLabel(row.days_left))}
Throw away
`
)
.join("");
el.querySelectorAll(".row-btn").forEach((btn) =>
btn.addEventListener("click", () => throwAway(expiredRows[Number(btn.dataset.index)]))
);
})
.catch((err) => {
el.innerHTML = `Could not load expired foods: ${escapeHtml(err.message)}
`;
});
}
// Scanning is the intended way to clear this list — you are standing at the bin with
// the thing in your hand — and the per-row button is the fallback for when the label
// is unreadable or the food no longer looks like itself.
$("expired-scan-btn").addEventListener("click", () => {
$("expired-status").textContent = "";
startScan("expired", onExpiredProposal, "Hold up what you are throwing away…");
});
function onExpiredProposal(proposal) {
if (proposal.degraded) {
$("expired-status").textContent = "Could not identify that — use the Throw away button on the row instead.";
resumeScan("Try another item…");
return;
}
const matches = (proposal.stock_matches || []).filter((m) =>
expiredRows.some((row) => row.product_id === m.product_id)
);
if (!matches.length) {
$("expired-status").textContent = `${proposal.name} is not on the expired list — nothing removed.`;
resumeScan("Hold up the next one…");
return;
}
throwAway(matches[0]);
}
function throwAway(row) {
if (!row) return;
const amount = amountOf(row) || 1;
// One tap between the camera and a write, same as everywhere else here — with the
// amount stated, because "throw away" on a row holding twelve means all twelve.
if (!window.confirm(`Throw away ${amount} × ${row.name}?`)) {
resumeScan("Hold up the next one…");
return;
}
$("expired-status").textContent = "Removing…";
postJson("/consume", { product_id: row.product_id, amount, spoiled: true })
.then(() => {
toast(`Binned ${amount} × ${row.name}`);
$("expired-status").textContent = "";
loadExpired();
resumeScan("Hold up the next one…");
})
.catch((err) => {
$("expired-status").textContent = `Could not remove: ${err.message}`;
});
}
// --- Edit inventory ------------------------------------------------------------
// The only screen that shows the folded view and its per-brand rows together: "22
// eggs" is the number the household thinks in, and "10 of brand Y" is the number you
// need to be able to correct exactly. Hence a per kind rather than two
// separate screens.
let knownLocations = [];
function loadEditInventory() {
const el = $("edit-list");
$("edit-status").textContent = "";
api("/inventory")
.then((data) => {
knownLocations = data.locations || [];
const groups = data.groups || [];
if (!groups.length) {
el.innerHTML = 'Nothing booked in yet.
';
return;
}
el.innerHTML = groups.map(renderEditGroup).join("");
wireEditRows(el);
})
.catch((err) => {
el.innerHTML = `Could not load inventory: ${escapeHtml(err.message)}
`;
});
}
function renderEditGroup(group) {
const brands = group.brand_count > 1 ? ` · ${group.brand_count} brands` : "";
return `
${escapeHtml(group.display_name || group.kind)}
${escapeHtml(String(group.total_amount))}${escapeHtml(brands)}
${escapeHtml(dueLabel(group.soonest_days_left))}
${(group.entries || []).map(renderEditRow).join("")}
`;
}
function renderEditRow(row) {
return `
${escapeHtml(row.brand || row.name)}
${escapeHtml(dueLabel(row.days_left))}
−
+
Save
${renderWhereRow(row)}`;
}
// Where a thing is, in two registers that are deliberately not merged: the location
// Grocy records (which somebody chose, and which "Move" changes) and the doorway
// camera's last sighting (which nobody chose, and which is only ever a hint — see
// docs/fridge-item-location.md). Showing the sighting as a sentence with a time in it
// is what keeps it readable as a clue rather than as a fact.
function renderWhereRow(row) {
if (!knownLocations.length && !row.last_seen) return "";
const seen = row.last_seen
? `Camera last saw something like this at ${escapeHtml(row.last_seen.location_name)}, ${escapeHtml(
relativeTime(row.last_seen.created_at)
)} — a sighting, not a fact.`
: "";
const options = knownLocations
.map(
(loc) =>
`${escapeHtml(loc.name)} `
)
.join("");
return `
${seen}
${
knownLocations.length
? `Move to
${options}
Move `
: ""
}
`;
}
function relativeTime(iso) {
const then = Date.parse(iso || "");
if (!Number.isFinite(then)) return "at an unknown time";
const minutes = Math.round((Date.now() - then) / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.round(hours / 24)}d ago`;
}
function wireEditRows(root) {
root.querySelectorAll(".edit-row").forEach((rowEl) => {
const input = rowEl.querySelector(".amount-input");
rowEl.querySelectorAll(".step-btn").forEach((btn) =>
btn.addEventListener("click", () => {
const next = (Number(input.value) || 0) + Number(btn.dataset.step);
input.value = Math.max(0, next);
// +/- write immediately: a step button that needs a second tap on Save is a
// step button people will forget to save. The freeform field waits for Save,
// because it is mid-typing until then.
saveEditRow(rowEl);
})
);
rowEl.querySelector(".save-btn").addEventListener("click", () => saveEditRow(rowEl));
});
root.querySelectorAll(".where-row").forEach((whereEl) => {
const moveBtn = whereEl.querySelector(".move-btn");
if (!moveBtn) return;
moveBtn.addEventListener("click", () => {
const productId = Number(whereEl.dataset.product);
const rowEl = root.querySelector(`.edit-row[data-product="${productId}"]`);
const amount = Number(rowEl && rowEl.querySelector(".amount-input").value);
if (!(amount > 0)) {
$("edit-status").textContent = "Nothing to move — the amount is zero.";
return;
}
$("edit-status").textContent = "Moving…";
postJson("/transfer", {
product_id: productId,
amount,
to_location_id: Number(whereEl.querySelector(".where-select").value),
})
.then((result) => {
$("edit-status").textContent = "";
toast(result.unchanged ? "Already there" : "Moved");
loadEditInventory();
})
.catch((err) => {
$("edit-status").textContent = `Could not move: ${err.message}`;
});
});
});
}
function saveEditRow(rowEl) {
const productId = Number(rowEl.dataset.product);
const amount = Number(rowEl.querySelector(".amount-input").value);
if (!(amount >= 0)) {
$("edit-status").textContent = "Amount has to be zero or more.";
return;
}
$("edit-status").textContent = "Saving…";
postJson("/adjust", { product_id: productId, amount })
.then(() => {
$("edit-status").textContent = "";
toast("Corrected");
loadEditInventory();
})
.catch((err) => {
$("edit-status").textContent = `Could not save: ${err.message}`;
});
}
// --- Inventory (read-only) -----------------------------------------------------
function loadInventory() {
const el = $("inventory-list");
api("/inventory")
.then((data) => {
const groups = data.groups || [];
if (!groups.length) {
el.innerHTML = 'Nothing in stock yet — unload some groceries!
';
return;
}
el.innerHTML = groups
.map((group) => {
const brands = group.brand_count > 1 ? `${group.brand_count} brands` : "";
return `
${escapeHtml(group.display_name || group.kind)}
×${escapeHtml(String(group.total_amount))} ${escapeHtml(brands)}
${escapeHtml(dueLabel(group.soonest_days_left))}
`;
})
.join("");
})
.catch((err) => {
el.innerHTML = `Could not load inventory: ${escapeHtml(err.message)}
`;
});
}
// --- Recipes -----------------------------------------------------------------
function loadRecipes() {
const el = $("recipes-list");
api("/recipes")
.then((data) => {
const recipes = data.recipes || [];
if (!recipes.length) {
el.innerHTML = 'No recipes in Grocy yet.
';
return;
}
el.innerHTML = recipes
.map((r) => {
const badge = r.fulfilled === true ? "✓ can make now" : r.fulfilled === false ? "missing items" : "";
return `
${escapeHtml(r.name || "")}
${escapeHtml(badge)}
`;
})
.join("");
})
.catch((err) => {
el.innerHTML = `Could not load recipes: ${escapeHtml(err.message)}
`;
});
}
// A "Show " MQTT command from HA arrives as a URL fragment reload
// (kitchen-display-agent kills and relaunches Chromium at index.html#inventory, same
// pattern as the thin client's digest-browser) — honour it on load, same as any tap.
show(location.hash ? location.hash.slice(1) : "home");