SmartestHome/identity/frontend/dashboard.js

207 lines
8.0 KiB
JavaScript

// 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 =
'<p class="error" style="padding:24px">identity not configured — missing ' +
"?identity_api=&identity_token= in the URL.</p>";
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 = '<span class="hint">No weather data yet</span>';
suggestion.hidden = true;
return;
}
hero.innerHTML =
`<span class="weather-temp">${escapeHtml(data.temperature || "")}</span>` +
`<span class="weather-condition">${escapeHtml(data.condition || "")}</span>`;
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 = '<span class="hint">Weather unavailable</span>';
});
}
// --- 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 plain <img src="..."> can't be used against a bearer-token-gated endpoint.
function loadAvatar(imgContainer, personId) {
fetch(`${IDENTITY_API}/people/${personId}/photo`, { headers: { Authorization: `Bearer ${IDENTITY_TOKEN}` } })
.then((res) => (res.ok ? res.blob() : Promise.reject()))
.then((blob) => {
const img = document.createElement("img");
img.src = URL.createObjectURL(blob);
imgContainer.replaceChildren(img);
})
.catch(() => {});
}
function loadPresence() {
const el = document.getElementById("presence-list");
identityApi("/presence")
.then((data) => {
const people = data.people || [];
if (!people.length) {
el.innerHTML = '<p class="hint">Nobody registered yet.</p>';
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
? ""
: `<button type="button" class="big-btn secondary" data-mark="${p.id}" data-home="1" style="min-height:40px;min-width:0;padding:0 12px;font-size:13px;margin-left:6px">Home</button>
<button type="button" class="big-btn secondary" data-mark="${p.id}" data-home="0" style="min-height:40px;min-width:0;padding:0 12px;font-size:13px;margin-left:6px">Away</button>`;
return `<div class="card">
<span class="avatar" data-person="${p.id}">👤</span>
<span class="card-name">${escapeHtml(p.name)}</span>
<span class="card-meta ${statusClass(p)}">${escapeHtml(statusLabel(p))}</span>
${manualButtons}
</div>`;
})
.join("");
el.querySelectorAll("[data-mark]").forEach((btn) => {
btn.addEventListener("click", () => markPresenceRequest(Number(btn.dataset.mark), btn.dataset.home === "1"));
});
people.forEach((p) => {
if (p.has_photo) {
const avatarEl = el.querySelector(`.avatar[data-person="${p.id}"]`);
if (avatarEl) loadAvatar(avatarEl, p.id);
}
});
})
.catch((err) => {
el.innerHTML = `<p class="error">Could not load presence: ${escapeHtml(err.message)}</p>`;
});
}
// --- Running low -----------------------------------------------------------------
function loadShoppingList() {
const el = document.getElementById("shopping-list");
if (!PANTRY_API || !PANTRY_TOKEN) {
el.innerHTML = '<p class="hint">pantry-vision not configured for this display.</p>';
return;
}
pantryApi("/shopping-list")
.then((data) => {
const items = data.items || [];
if (!items.length) {
el.innerHTML = '<p class="hint">Nothing running low.</p>';
return;
}
el.innerHTML = items
.map(
(item) =>
`<div class="card">
<span class="card-name">${escapeHtml(item.name)}</span>
<span class="card-meta">need ${escapeHtml(String(item.amount_missing ?? ""))}</span>
</div>`
)
.join("");
})
.catch((err) => {
el.innerHTML = `<p class="error">Could not load shopping list: ${escapeHtml(err.message)}</p>`;
});
}
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);