SmartestHome/pantry-vision/frontend/app.js

840 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// 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 =
'<p class="error" style="padding:24px">pantry-vision not configured — missing ' +
"?api=&token= in the URL. See hosts/kitchen-display/configs/sway/pantry-kiosk.</p>";
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 <video>, 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 =
'<p class="hint">Try again, or correct the amount by hand on the Edit inventory screen.</p>';
addRescanButton();
return;
}
const matches = proposal.stock_matches || [];
$("consume-identified").textContent = `Looks like: ${proposal.name}`;
if (!matches.length) {
$("consume-candidates").innerHTML =
'<p class="hint">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.</p>";
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 =
'<p class="hint">Which one? (Same kind, different brands or dates.)</p>' +
matches
.map(
(m, i) => `<button class="card choice" data-index="${i}">
<span class="card-name">${escapeHtml(rowLabel(m))}</span>
<span class="card-due">${escapeHtml(dueLabel(m.days_left))}</span>
</button>`
)
.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) =>
`<button type="button" class="quick-btn" data-amount="${n}">${
n === held ? `all ${n}` : n === consumeUnitsPerPackage && n > 1 ? `whole pack (${n})` : n
}</button>`
)
.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 = '<p class="hint">Nothing has expired. </p>';
return;
}
el.innerHTML = expiredRows
.map(
(row, i) => `<div class="card urgent-expired">
<span class="card-name">${escapeHtml(rowLabel(row))}</span>
<span class="card-due">${escapeHtml(dueLabel(row.days_left))}</span>
<button class="row-btn" data-index="${i}">Throw away</button>
</div>`
)
.join("");
el.querySelectorAll(".row-btn").forEach((btn) =>
btn.addEventListener("click", () => throwAway(expiredRows[Number(btn.dataset.index)]))
);
})
.catch((err) => {
el.innerHTML = `<p class="error">Could not load expired foods: ${escapeHtml(err.message)}</p>`;
});
}
// 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 <details> 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 = '<p class="hint">Nothing booked in yet.</p>';
return;
}
el.innerHTML = groups.map(renderEditGroup).join("");
wireEditRows(el);
})
.catch((err) => {
el.innerHTML = `<p class="error">Could not load inventory: ${escapeHtml(err.message)}</p>`;
});
}
function renderEditGroup(group) {
const brands = group.brand_count > 1 ? ` · ${group.brand_count} brands` : "";
return `<details class="group ${urgencyClass(group.soonest_days_left)}">
<summary>
<span class="card-name">${escapeHtml(group.display_name || group.kind)}</span>
<span class="card-amount">${escapeHtml(String(group.total_amount))}${escapeHtml(brands)}</span>
<span class="card-due">${escapeHtml(dueLabel(group.soonest_days_left))}</span>
</summary>
${(group.entries || []).map(renderEditRow).join("")}
</details>`;
}
function renderEditRow(row) {
return `<div class="edit-row" data-product="${row.product_id}">
<span class="card-name">${escapeHtml(row.brand || row.name)}</span>
<span class="card-due">${escapeHtml(dueLabel(row.days_left))}</span>
<button class="step-btn" data-step="-1"></button>
<input class="amount-input" type="number" min="0" step="1" value="${escapeHtml(String(amountOf(row)))}">
<button class="step-btn" data-step="1">+</button>
<button class="row-btn save-btn">Save</button>
</div>
${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) =>
`<option value="${loc.id}"${loc.id === row.location_id ? " selected" : ""}>${escapeHtml(loc.name)}</option>`
)
.join("");
return `<div class="where-row" data-product="${row.product_id}">
<span class="hint">${seen}</span>
${
knownLocations.length
? `<label class="where-move">Move to
<select class="where-select">${options}</select>
</label>
<button class="row-btn move-btn">Move</button>`
: ""
}
</div>`;
}
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 = '<p class="hint">Nothing in stock yet — unload some groceries!</p>';
return;
}
el.innerHTML = groups
.map((group) => {
const brands = group.brand_count > 1 ? `${group.brand_count} brands` : "";
return `<div class="card ${urgencyClass(group.soonest_days_left)}">
<span class="card-name">${escapeHtml(group.display_name || group.kind)}</span>
<span class="card-amount">×${escapeHtml(String(group.total_amount))} ${escapeHtml(brands)}</span>
<span class="card-due">${escapeHtml(dueLabel(group.soonest_days_left))}</span>
</div>`;
})
.join("");
})
.catch((err) => {
el.innerHTML = `<p class="error">Could not load inventory: ${escapeHtml(err.message)}</p>`;
});
}
// --- Recipes -----------------------------------------------------------------
function loadRecipes() {
const el = $("recipes-list");
api("/recipes")
.then((data) => {
const recipes = data.recipes || [];
if (!recipes.length) {
el.innerHTML = '<p class="hint">No recipes in Grocy yet.</p>';
return;
}
el.innerHTML = recipes
.map((r) => {
const badge = r.fulfilled === true ? "✓ can make now" : r.fulfilled === false ? "missing items" : "";
return `<div class="card">
<span class="card-name">${escapeHtml(r.name || "")}</span>
<span class="card-due">${escapeHtml(badge)}</span>
</div>`;
})
.join("");
})
.catch((err) => {
el.innerHTML = `<p class="error">Could not load recipes: ${escapeHtml(err.message)}</p>`;
});
}
// A "Show <screen>" 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");