// hosts/door-panel/'s dashboard logic. Vanilla JS, no framework, no build step — // same "vendored, dependency-free" choice as every other kiosk frontend in this // project. See dashboard.html's top comment for the two-backend split. "use strict"; const params = new URLSearchParams(location.search); const IDENTITY_API = (params.get("identity_api") || "").replace(/\/$/, ""); const IDENTITY_TOKEN = params.get("identity_token") || ""; const PANTRY_API = (params.get("pantry_api") || "").replace(/\/$/, ""); const PANTRY_TOKEN = params.get("pantry_token") || ""; if (!IDENTITY_API || !IDENTITY_TOKEN) { document.body.innerHTML = '
identity not configured — missing ' + "?identity_api=&identity_token= in the URL.
"; throw new Error("dashboard: missing identity query params"); } function identityApi(path) { return fetch(`${IDENTITY_API}${path}`, { headers: { Authorization: `Bearer ${IDENTITY_TOKEN}` } }).then((res) => { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); }); } function pantryApi(path) { return fetch(`${PANTRY_API}${path}`, { headers: { Authorization: `Bearer ${PANTRY_TOKEN}` } }).then((res) => { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); }); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } // --- Weather + clothing -------------------------------------------------------- // A plain lookup table, not an LLM call — this is a deterministic-enough problem // (temperature + a couple of condition keywords) that a round trip to Ollama would // only add latency and a failure mode for no real gain. See ../README.md. function clothingSuggestion(tempC, condition) { const cond = (condition || "").toLowerCase(); const layers = tempC === null ? null : tempC < 0 ? "Heavy coat, hat, gloves" : tempC < 10 ? "Warm coat or jacket" : tempC < 16 ? "Light jacket or sweater" : tempC < 21 ? "Light layers" : "T-shirt weather"; const wet = /rain|drizzle|shower|storm|snow|sleet/.test(cond) ? " — bring an umbrella/waterproofs" : ""; return layers ? layers + wet : null; } function loadWeather() { identityApi("/weather") .then((data) => { const hero = document.getElementById("weather-hero"); const suggestion = document.getElementById("clothing-suggestion"); if (!data.available) { hero.innerHTML = 'No weather data yet'; suggestion.hidden = true; return; } hero.innerHTML = `${escapeHtml(data.temperature || "")}` + `${escapeHtml(data.condition || "")}`; const match = /(-?\d+(\.\d+)?)/.exec(data.temperature || ""); const tempC = match ? parseFloat(match[1]) : null; const text = clothingSuggestion(tempC, data.condition); if (text) { suggestion.textContent = text; suggestion.hidden = false; } else { suggestion.hidden = true; } }) .catch(() => { document.getElementById("weather-hero").innerHTML = 'Weather unavailable'; }); } // --- Who's home ------------------------------------------------------------------ // Three states, not two: home / away / unknown. "Unknown" covers both an // unreachable HA and a device-less person (grandmother, a guest) who was never // manually marked either way — see identity/server.py's presence() docstring for // why defaulting that case to "away" would be actively wrong, not just vague. function statusLabel(p) { if (p.home === true) return p.room ? `Home — ${p.room}` : "Home"; if (p.home === false) return "Away"; return "Unknown"; } function statusClass(p) { if (p.home === true) return "status-home"; if (p.home === false) return "status-away"; return ""; } function markPresenceRequest(personId, home) { return fetch(`${IDENTITY_API}/presence/manual`, { method: "POST", headers: { Authorization: `Bearer ${IDENTITY_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ person_id: personId, home }), }).then(loadPresence); } // Same blob-fetch approach as register.js's loadAvatar — see that file's comment // for why a plainNobody registered yet.
'; return; } el.innerHTML = people .map((p) => { // Device-less people (has_device === false) get manual Home/Away buttons // right here — the only way their presence is ever set, since there's no // phone to track. See identity/README.md's "no device" section. const manualButtons = p.has_device ? "" : ` `; return `Could not load presence: ${escapeHtml(err.message)}
`; }); } // --- Running low ----------------------------------------------------------------- function loadShoppingList() { const el = document.getElementById("shopping-list"); if (!PANTRY_API || !PANTRY_TOKEN) { el.innerHTML = 'pantry-vision not configured for this display.
'; return; } pantryApi("/shopping-list") .then((data) => { const items = data.items || []; if (!items.length) { el.innerHTML = 'Nothing running low.
'; return; } el.innerHTML = items .map( (item) => `Could not load shopping list: ${escapeHtml(err.message)}
`; }); } function refreshAll() { loadWeather(); loadPresence(); loadShoppingList(); } refreshAll(); // An ambient always-on display, not an interactive app — poll rather than needing a // tap to refresh. 60s matches the weather MQTT topic's own realistic update cadence // (an HA automation on a state-change trigger, not a fast poll itself). setInterval(refreshAll, 60000);