840 lines
30 KiB
JavaScript
840 lines
30 KiB
JavaScript
// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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");
|