// identity's admin panel logic. Vanilla JS, no framework, no build step — same // "vendored, dependency-free" choice as every other frontend in this project. See // admin.html's top comment for why this page is the one that isn't kiosk-shaped. "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 = '

identity not configured — missing ' + "?api=&token= in the URL.

"; throw new Error("admin: missing ?api=/&token= query params"); } // Chore types the checkboxes offer. Kept in sync BY HAND with chores/check.py's // _CHORE_PROMPTS — there is no endpoint that lists them, because chores/ is a // oneshot timer job with no HTTP surface at all, and inventing one just so this // dropdown could be generated would be a lot of moving parts for a list that changes // about once a year. "litter" is deliberately absent: it can't be assigned to anyone // (see chores/README.md), so offering it here would be offering a lie. const CHORE_TYPES = ["trash", "bin_full", "dishes"]; // digest-engine's four sections, in the order they're offered. The keys are its own // section ids (synth/prompts/.md), which is also exactly what the server stores — // nothing translates between the two. The labels are the household's words for them: // "personal" is the mail/messages one, "political" the news one. Kept in sync BY HAND // with DIGEST_SECTIONS in server.py, same arrangement as CHORE_TYPES above, except the // server does validate these — an unknown key comes back as a refusal, not a silent // write. const DIGEST_SECTIONS = [ ["network", "Network"], ["household", "Household"], ["personal", "Social"], ["political", "Political / news"], ]; function api(path, options) { options = options || {}; options.headers = Object.assign({ Authorization: `Bearer ${TOKEN}` }, options.headers || {}); return fetch(`${API}${path}`, options).then((res) => res.json().then((body) => { // 409 carries a real, human-readable refusal ("that nickname collides with…"), // so it's a result to display rather than an error to throw — same convention // as register.js. if (!res.ok && res.status !== 409) throw new Error(body.error || body.message || `${res.status} ${res.statusText}`); return body; }) ); } function postJson(path, body) { return api(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } // Dates are stored as UTC ISO strings; a household admin reading "was Amir home // Tuesday evening" wants them in their own timezone, which is what toLocaleString // gives without this page needing to know what that timezone is. function fmt(iso) { if (!iso) return "—"; const d = new Date(iso); return isNaN(d) ? iso : d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); } function fmtDate(iso) { if (!iso) return "—"; const d = new Date(iso); return isNaN(d) ? iso : d.toLocaleDateString(undefined, { dateStyle: "medium" }); } function duration(fromIso, toIso) { const from = new Date(fromIso); const to = toIso ? new Date(toIso) : new Date(); const mins = Math.round((to - from) / 60000); if (isNaN(mins) || mins < 0) return ""; if (mins < 60) return `${mins}m`; const hours = Math.floor(mins / 60); return hours < 24 ? `${hours}h ${mins % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`; } // Same blob-fetch approach as register.js/dashboard.js — every identity endpoint // requires an Authorization header, and a plain has no way to send // one. See identity/README.md. // --- Colour + profile picture -------------------------------------------------------- // The palette comes from the server (GET /person-colors) rather than being repeated // here: those eight values are chosen to render exactly on a colour Pebble's 2-bit-per- // channel screen, and a second copy in this file would drift the first time somebody // "improved" one of them. Falls back to whatever the person already has if the fetch // fails, so the editor still opens. let personColors = []; function loadPersonColors() { return api("/person-colors") .then((data) => { personColors = data.colors || []; }) .catch(() => { personColors = []; }); } function renderColorSwatches() { const row = document.getElementById("edit-colors"); const current = (editing.color || "").toUpperCase(); const palette = personColors.length ? personColors : [current].filter(Boolean); // A colour set by hand outside the palette still gets a swatch, so it is visible and // reselectable rather than silently absent from its own editor. const colors = palette.includes(current) || !current ? palette : palette.concat([current]); row.innerHTML = colors .map( (color) => `` ) .join(""); row.querySelectorAll(".swatch").forEach((btn) => btn.addEventListener("click", () => { editing.color = btn.dataset.color; renderColorSwatches(); const avatar = document.getElementById("edit-avatar"); if (!editing.has_photo) avatar.style.background = editing.color; }) ); } document.getElementById("edit-photo-upload").addEventListener("click", () => { document.getElementById("edit-photo-file").click(); }); document.getElementById("edit-photo-file").addEventListener("change", (event) => { const file = event.target.files && event.target.files[0]; if (!file || !editing) return; setStatus("Uploading…"); // Raw bytes, not multipart — same shape as the level-image upload and // /register/photo. The picture is written immediately rather than waiting for Save, // because it is a file on the server, not a field in this form. api(`/people/${editing.id}/photo`, { method: "POST", body: file, headers: { "Content-Type": file.type || "image/jpeg" } }) .then((result) => { if (!result.ok) throw new Error(result.message || "Could not upload."); return refreshEditing("Picture updated."); }) .catch((err) => setStatus(err.message, true)) .finally(() => { event.target.value = ""; }); }); document.getElementById("edit-photo-clear").addEventListener("click", () => { if (!editing) return; setStatus("Removing…"); postJson(`/people/${editing.id}`, { clear_photo: true }) .then((result) => { if (!result.ok) throw new Error(result.message || "Could not remove."); return refreshEditing("Picture removed — showing their initial instead."); }) .catch((err) => setStatus(err.message, true)); }); function loadAvatar(container, personId) { fetch(`${API}/people/${personId}/photo`, { headers: { Authorization: `Bearer ${TOKEN}` } }) .then((res) => (res.ok ? res.blob() : Promise.reject())) .then((blob) => { const img = document.createElement("img"); img.src = URL.createObjectURL(blob); container.replaceChildren(img); }) .catch(() => {}); } // --- Tabs ------------------------------------------------------------------------ document.querySelectorAll(".tab").forEach((tab) => { tab.addEventListener("click", () => { document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === tab)); document.querySelectorAll(".panel").forEach((p) => { p.classList.toggle("active", p.id === `panel-${tab.dataset.panel}`); }); if (tab.dataset.panel === "access") loadAccessLog(); if (tab.dataset.panel === "floorplan") { // Loaded on open rather than at startup: it costs an HA round trip for the area // suggestions, and most visits to this page never touch the floorplan. loadFloorplan().then(loadFloorplanPresence); loadAreaSuggestions(); } }); }); // --- People list ------------------------------------------------------------------- let people = []; function personSubtitle(p) { const bits = []; if (p.nickname) bits.push(`“${p.nickname}”`); if (p.currently_home_since) bits.push(`home since ${fmt(p.currently_home_since)}`); else bits.push(`last seen ${fmtDate(p.last_visit_at)}${p.last_visit_is_estimated ? " (never recorded)" : ""}`); if (p.identifiers.length) bits.push(`${p.identifiers.length} device${p.identifiers.length === 1 ? "" : "s"}`); else bits.push("no device"); if (p.device_grants.length) bits.push(`${p.device_grants.length} right${p.device_grants.length === 1 ? "" : "s"}`); if (p.notify_on_arrival) bits.push(p.notify_deliverable ? "🔔 arrivals" : "🔔 arrivals (undeliverable)"); if (!p.announce_arrivals) bits.push("not announced"); // Only worth a line when it isn't the default (everything) — otherwise every person // would carry the same four words. if (p.digest_sections.length < DIGEST_SECTIONS.length) { bits.push(p.digest_sections.length ? `digests: ${p.digest_sections.join(", ")}` : "no digests"); } if (p.chore_exempt) bits.push("chore-exempt"); if (p.chore_assignments.length) bits.push(`chores: ${p.chore_assignments.join(", ")}`); return bits.join(" · "); } function loadPeople() { const el = document.getElementById("people-list"); return api("/people") .then((data) => { people = data.people || []; if (!people.length) { el.innerHTML = '

Nobody registered yet.

'; return; } el.innerHTML = people .map( (p) => `` ) .join(""); el.querySelectorAll("[data-person]").forEach((btn) => { btn.addEventListener("click", () => openEditor(Number(btn.dataset.person))); }); people.forEach((p) => { if (p.has_photo) { const avatar = el.querySelector(`[data-avatar="${p.id}"]`); if (avatar) loadAvatar(avatar, p.id); } }); populatePersonSelect(); }) .catch((err) => { el.innerHTML = `

Could not load people: ${escapeHtml(err.message)}

`; }); } // --- Person editor ------------------------------------------------------------------ const editor = document.getElementById("editor"); const editStatus = document.getElementById("edit-status"); let editing = null; function setStatus(text, isError) { editStatus.textContent = text; editStatus.className = isError ? "error" : "hint"; } function openEditor(personId) { editing = people.find((p) => p.id === personId); if (!editing) return; document.getElementById("edit-title").textContent = editing.name; document.getElementById("edit-name").value = editing.name; document.getElementById("edit-nickname").value = editing.nickname || ""; document.getElementById("edit-note").value = editing.note || ""; document.getElementById("edit-chore-exempt").checked = editing.chore_exempt; document.getElementById("edit-reminder-style").value = editing.chore_reminder_style || ""; document.getElementById("edit-notify-on-arrival").checked = editing.notify_on_arrival; document.getElementById("edit-announce-arrivals").checked = editing.announce_arrivals; document.getElementById("edit-notify-topic").value = editing.notify_topic || ""; // Says out loud when a ticked box still can't deliver, rather than leaving someone // to cross-reference a checkbox against an env file to work out why nothing arrives. document.getElementById("notify-hint").innerHTML = editing.notify_deliverable ? "Pushes go out via the household ntfy server." : 'No ntfy topic reachable — set NTFY_URL and NTFY_DEFAULT_TOPIC on the server, or a topic here.'; const avatar = document.getElementById("edit-avatar"); avatar.replaceChildren(document.createTextNode(editing.initial || "👤")); avatar.style.background = editing.color || ""; if (editing.has_photo) loadAvatar(avatar, editing.id); renderColorSwatches(); document.getElementById("edit-digest-sections").innerHTML = DIGEST_SECTIONS.map( ([key, label]) => `` ).join(""); document.getElementById("edit-chore-types").innerHTML = CHORE_TYPES.map( (type) => `` ).join(""); renderIdentifiers(); renderGrants(); setStatus(""); editor.showModal(); } function renderIdentifiers() { const el = document.getElementById("edit-identifiers"); if (!editing.identifiers.length) { el.innerHTML = '

No devices — presence has to be set by hand.

'; return; } el.innerHTML = editing.identifiers .map( (i) => `
${escapeHtml(i.ha_entity_id)} added ${escapeHtml(fmtDate(i.registered_at))} via ${escapeHtml(i.registered_via_device || "?")}
` ) .join(""); el.querySelectorAll("[data-drop-identifier]").forEach((btn) => { btn.addEventListener("click", () => { api(`/people/${editing.id}/identifiers/${btn.dataset.dropIdentifier}`, { method: "DELETE" }) .then(() => refreshEditing("Device revoked.")) .catch((err) => setStatus(err.message, true)); }); }); } function renderGrants() { const el = document.getElementById("edit-grants"); if (!editing.device_grants.length) { el.innerHTML = '

No device rights.

'; return; } const now = new Date().toISOString(); el.innerHTML = editing.device_grants .map((g) => { const expired = g.expires_at && g.expires_at <= now; return `
${escapeHtml(g.ha_entity_id)} ${escapeHtml(g.permission)}${ g.expires_at ? ` · ${expired ? "expired" : "until"} ${escapeHtml(fmtDate(g.expires_at))}` : " · no expiry" }${g.note ? ` · ${escapeHtml(g.note)}` : ""}
`; }) .join(""); el.querySelectorAll("[data-drop-grant]").forEach((btn) => { btn.addEventListener("click", () => { api(`/people/${editing.id}/device-grants/${btn.dataset.dropGrant}`, { method: "DELETE" }) .then(() => refreshEditing("Right revoked.")) .catch((err) => setStatus(err.message, true)); }); }); } // Re-reads /people and re-points the open dialog at the fresh record, so a sub-action // (revoking a device, adding a grant) doesn't leave the dialog showing stale data or // force the admin to close and reopen it. function refreshEditing(message) { const id = editing.id; return loadPeople().then(() => { editing = people.find((p) => p.id === id); if (!editing) { editor.close(); return; } renderIdentifiers(); renderGrants(); if (message) setStatus(message); }); } document.getElementById("edit-close").addEventListener("click", () => editor.close()); document.getElementById("edit-save").addEventListener("click", () => { const chores = Array.from(document.querySelectorAll("[data-chore]:checked")).map((c) => c.dataset.chore); setStatus("Saving…"); // Two calls because they're two different resources, not one form: the person's own // fields, and their chore assignments. Sequenced rather than parallel so a rejected // rename (a nickname collision, say) surfaces its message instead of racing the // assignment call's own status text. postJson(`/people/${editing.id}`, { name: document.getElementById("edit-name").value.trim(), nickname: document.getElementById("edit-nickname").value.trim(), note: document.getElementById("edit-note").value.trim(), chore_exempt: document.getElementById("edit-chore-exempt").checked, chore_reminder_style: document.getElementById("edit-reminder-style").value.trim(), notify_on_arrival: document.getElementById("edit-notify-on-arrival").checked, announce_arrivals: document.getElementById("edit-announce-arrivals").checked, notify_topic: document.getElementById("edit-notify-topic").value.trim(), // Whatever swatch is currently selected — the picker mutates `editing.color` and // saves with the rest of the dialog, since it is one more column on `people`. color: editing.color || "", // Sent with the rest of the person's own fields rather than as a third call: unlike // chore assignments (their own table), this is one column on `people`, so it saves // or is refused together with everything else in the dialog. digest_sections: Array.from(document.querySelectorAll("[data-digest]:checked")).map( (c) => c.dataset.digest ), }) .then((result) => { if (!result.ok) throw new Error(result.message || "Could not save."); return postJson(`/people/${editing.id}/chore-assignments`, { chore_types: chores }); }) .then(() => refreshEditing("Saved.")) .then(() => { document.getElementById("edit-title").textContent = editing ? editing.name : ""; }) .catch((err) => setStatus(err.message, true)); }); // Deliberately sends to whatever is SAVED, not what's typed in the box — a test that // silently used unsaved input would prove a topic works and then leave a different one // stored. Save first, then test. document.getElementById("edit-test-notify").addEventListener("click", () => { setStatus("Sending test…"); postJson(`/people/${editing.id}/test-notification`, {}) .then((result) => setStatus(result.message, !result.ok)) .catch((err) => setStatus(err.message, true)); }); document.getElementById("edit-add-identifier").addEventListener("click", () => { const input = document.getElementById("edit-new-identifier"); const entityId = input.value.trim(); if (!entityId) return; postJson(`/people/${editing.id}/identifiers`, { entity_id: entityId }) .then((result) => { if (!result.ok) throw new Error(result.message); input.value = ""; return refreshEditing("Device added."); }) .catch((err) => setStatus(err.message, true)); }); document.getElementById("grant-add").addEventListener("click", () => { const entity = document.getElementById("grant-entity"); const permission = document.getElementById("grant-permission"); const expires = document.getElementById("grant-expires"); if (!entity.value.trim()) return; postJson(`/people/${editing.id}/device-grants`, { entity_id: entity.value.trim(), permission: permission.value.trim() || "operate", // A date input gives "2026-08-01"; the grant expires at the START of that day, so // "until 1 Aug" means the last usable day is 31 Jul. Sent explicitly as UTC // midnight rather than left for the server to interpret a bare date. expires_at: expires.value ? `${expires.value}T00:00:00Z` : null, }) .then((result) => { if (!result.ok) throw new Error(result.message); entity.value = ""; permission.value = ""; expires.value = ""; return refreshEditing("Right granted."); }) .catch((err) => setStatus(err.message, true)); }); document.getElementById("edit-delete").addEventListener("click", () => { if (!confirm(`Delete ${editing.name} completely? Their devices, rights and visit history go too.`)) return; api(`/people/${editing.id}`, { method: "DELETE" }) .then(() => { editor.close(); loadPeople(); }) .catch((err) => setStatus(err.message, true)); }); // --- Prune --------------------------------------------------------------------------- // The two-step shape here is deliberate and matches the backend: /prune/candidates // SELECTS, POST /people/prune DELETES the exact ids that came back and stayed ticked. // The filter never gets re-run at delete time, so someone who walks in the door // between "Select all" and "Delete selected" can't be swept up by a filter that // silently re-evaluated. See prune_people()'s docstring in server.py. const pruneList = document.getElementById("prune-list"); const pruneDelete = document.getElementById("prune-delete"); const pruneStatus = document.getElementById("prune-status"); function selectedPruneIds() { return Array.from(pruneList.querySelectorAll("input[type=checkbox]:checked")).map((c) => Number(c.value)); } function syncPruneButton() { const n = selectedPruneIds().length; pruneDelete.disabled = n === 0; pruneDelete.textContent = n ? `Delete ${n} selected` : "Delete selected"; } document.getElementById("prune-select").addEventListener("click", () => { const date = document.getElementById("prune-date").value; if (!date) { document.getElementById("prune-summary").textContent = "Pick a date first."; return; } pruneStatus.textContent = ""; api(`/prune/candidates?last_visit_before=${encodeURIComponent(date)}`) .then((data) => { const summary = document.getElementById("prune-summary"); if (!data.count) { summary.textContent = `Nobody has a last visit before ${fmtDate(date)}.`; pruneList.innerHTML = ""; syncPruneButton(); return; } summary.textContent = `${data.count} match${data.count === 1 ? "" : "es"} — untick anyone you want to keep.`; pruneList.innerHTML = data.candidates .map( (p) => `` ) .join(""); pruneList.querySelectorAll("input").forEach((c) => c.addEventListener("change", syncPruneButton)); syncPruneButton(); }) .catch((err) => { document.getElementById("prune-summary").innerHTML = `${escapeHtml(err.message)}`; }); }); pruneDelete.addEventListener("click", () => { const ids = selectedPruneIds(); if (!ids.length) return; const names = ids.map((id) => (people.find((p) => p.id === id) || {}).name || id); if (!confirm(`Delete ${ids.length} record(s)?\n\n${names.join("\n")}\n\nThis cannot be undone.`)) return; pruneStatus.textContent = "Deleting…"; postJson("/people/prune", { person_ids: ids }) .then((result) => { pruneStatus.textContent = `Deleted ${result.deleted.length} record(s).`; pruneList.innerHTML = ""; document.getElementById("prune-summary").textContent = ""; syncPruneButton(); loadPeople(); }) .catch((err) => { pruneStatus.innerHTML = `${escapeHtml(err.message)}`; }); }); // --- History -------------------------------------------------------------------------- function populatePersonSelect() { const select = document.getElementById("history-person"); const current = select.value; select.innerHTML = '' + people.map((p) => ``).join(""); select.value = current; } function loadHistory() { const personId = document.getElementById("history-person").value; const sinceDate = document.getElementById("history-since").value; const since = sinceDate ? `${sinceDate}T00:00:00Z` : ""; const query = since ? `?since=${encodeURIComponent(since)}` : ""; const visitPath = personId ? `/people/${personId}/visits${query}` : `/visits${query}`; const el = document.getElementById("visit-list"); el.innerHTML = '

Loading…

'; api(visitPath) .then((data) => { const visits = data.visits || []; if (!visits.length) { el.innerHTML = '

No visits recorded in that range.

'; return; } el.innerHTML = visits .map( (v) => `
${escapeHtml(v.name)} ${escapeHtml(fmt(v.arrived_at))} → ${ v.ongoing ? "still home" : escapeHtml(fmt(v.departed_at)) } · ${escapeHtml(duration(v.arrived_at, v.departed_at))} · via ${escapeHtml(v.source)}${ v.close_reason === "timed_out" ? " · departure never observed" : "" }
` ) .join(""); }) .catch((err) => { el.innerHTML = `

Could not load visits: ${escapeHtml(err.message)}

`; }); const copEl = document.getElementById("copresence-list"); const copQuery = new URLSearchParams(); if (personId) copQuery.set("person_id", personId); if (since) copQuery.set("since", since); api(`/co-presence?${copQuery.toString()}`) .then((data) => { const overlaps = data.overlaps || []; if (!overlaps.length) { copEl.innerHTML = '

Nobody overlapped in that range.

'; return; } copEl.innerHTML = overlaps .map( (o) => `
${escapeHtml(o.people.map((p) => p.name).join(" + "))} ${escapeHtml(fmt(o.from))} → ${ o.ongoing ? "now" : escapeHtml(fmt(o.until)) } · ${escapeHtml(duration(o.from, o.ongoing ? null : o.until))}
` ) .join(""); }) .catch((err) => { copEl.innerHTML = `

Could not load co-presence: ${escapeHtml(err.message)}

`; }); } document.getElementById("history-load").addEventListener("click", loadHistory); // --- Access log ------------------------------------------------------------------------ function loadAccessLog() { const el = document.getElementById("access-list"); api("/device-access/events?limit=200") .then((data) => { const events = data.events || []; if (!events.length) { el.innerHTML = '

Nothing has asked yet.

'; return; } el.innerHTML = events .map( (e) => `
${escapeHtml(e.name || `person ${e.person_id}`)} → ${escapeHtml(e.ha_entity_id)} ${escapeHtml(fmt(e.created_at))} · ${escapeHtml(e.permission)} · ${escapeHtml(e.reason)}${ e.requested_via ? ` · via ${escapeHtml(e.requested_via)}` : "" } ${e.allowed ? "allowed" : "denied"}
` ) .join(""); }) .catch((err) => { el.innerHTML = `

Could not load access log: ${escapeHtml(err.message)}

`; }); } // --- Floorplan editor ------------------------------------------------------------------ // Rooms are polygons in NORMALISED 0..1 coordinates (see the schema comment in // server.py): the plan has to render on a laptop now and possibly a wall panel later, // and pixel coordinates would be correct on exactly one of them. Everything below // converts to the SVG's fixed 1000x700 viewBox only at draw time. const FP = { W: 1000, H: 700 }; let fpLevels = []; let fpLevelId = null; let fpSelected = null; // the room being edited (a COPY — see selectRoom) let fpDraft = null; // points of a room currently being drawn let fpLive = null; // latest /floorplan/presence payload const svg = document.getElementById("fp-canvas"); const fpStatus = document.getElementById("fp-status"); function fpSetStatus(text, isError) { fpStatus.textContent = text || ""; fpStatus.className = isError ? "error" : "hint"; } function toNorm(evt) { // Uses the SVG's own coordinate space rather than clientX/clientY arithmetic, so the // mapping stays correct however the element is scaled, scrolled, or letterboxed by // preserveAspectRatio. const pt = svg.createSVGPoint(); pt.x = evt.clientX; pt.y = evt.clientY; const local = pt.matrixTransform(svg.getScreenCTM().inverse()); return [Math.min(1, Math.max(0, local.x / FP.W)), Math.min(1, Math.max(0, local.y / FP.H))]; } function svgEl(tag, attrs) { const el = document.createElementNS("http://www.w3.org/2000/svg", tag); Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v)); return el; } function pointsAttr(points) { return points.map(([x, y]) => `${x * FP.W},${y * FP.H}`).join(" "); } function currentLevel() { return fpLevels.find((l) => l.id === fpLevelId) || null; } function renderFloorplan() { const level = currentLevel(); const roomsG = document.getElementById("fp-rooms"); const handlesG = document.getElementById("fp-handles"); const draftG = document.getElementById("fp-draft"); roomsG.replaceChildren(); handlesG.replaceChildren(); draftG.replaceChildren(); const bg = document.getElementById("fp-bg"); if (level && level.has_image) { // Blob-fetched like every other image here — the endpoint is token-gated and an // has no way to send an Authorization header. fetch(`${API}/floorplan/levels/${level.id}/image`, { headers: { Authorization: `Bearer ${TOKEN}` } }) .then((res) => (res.ok ? res.blob() : Promise.reject())) .then((blob) => bg.setAttribute("href", URL.createObjectURL(blob))) .catch(() => bg.removeAttribute("href")); } else { bg.removeAttribute("href"); } if (!level) return; const liveRooms = {}; if (fpLive) { (fpLive.levels || []).forEach((l) => (l.rooms || []).forEach((r) => (liveRooms[r.id] = r))); } const showLive = document.getElementById("fp-live").checked; level.rooms.forEach((room) => { const occupants = ((liveRooms[room.id] || {}).occupants) || []; const occupied = showLive && occupants.length > 0; const selected = fpSelected && fpSelected.id === room.id; roomsG.appendChild( svgEl("polygon", { points: pointsAttr(room.points), class: `fp-room${occupied ? " occupied" : ""}${selected ? " selected" : ""}`, fill: room.color || "#6ea8fe", "data-room": room.id, }) ); // Label at the average of the vertices. A true centroid would still sit outside an // L-shaped room, so the extra maths buys nothing a human wouldn't just drag anyway. const cx = (room.points.reduce((s, p) => s + p[0], 0) / room.points.length) * FP.W; const cy = (room.points.reduce((s, p) => s + p[1], 0) / room.points.length) * FP.H; const label = svgEl("text", { x: cx, y: cy, class: "fp-label", "text-anchor": "middle" }); label.textContent = room.name; roomsG.appendChild(label); if (occupied) { const who = svgEl("text", { x: cx, y: cy + 22, class: "fp-occupants", "text-anchor": "middle" }); who.textContent = occupants.map((o) => o.name).join(", "); roomsG.appendChild(who); } if (!room.ha_area_id) { // A drawn room with no HA area can never light up, and looking at a plan where // one room never reacts is a confusing way to discover that. const warn = svgEl("text", { x: cx, y: cy + 40, class: "fp-warn", "text-anchor": "middle" }); warn.textContent = "no HA area"; roomsG.appendChild(warn); } }); roomsG.querySelectorAll("[data-room]").forEach((poly) => { poly.addEventListener("click", (e) => { if (fpDraft) return; // don't hijack clicks meant for the polygon being drawn e.stopPropagation(); selectRoom(Number(poly.dataset.room)); }); }); // Vertex handles for the selected room — dragging one is how a wall gets nudged. if (fpSelected) { fpSelected.points.forEach((point, index) => { const handle = svgEl("circle", { cx: point[0] * FP.W, cy: point[1] * FP.H, r: 8, class: "fp-handle", }); handle.addEventListener("pointerdown", (e) => { e.stopPropagation(); handle.setPointerCapture(e.pointerId); const move = (ev) => { fpSelected.points[index] = toNorm(ev); renderFloorplan(); }; handle.addEventListener("pointermove", move); handle.addEventListener( "pointerup", () => { handle.removeEventListener("pointermove", move); fpSetStatus("Moved a corner — Save room to keep it."); }, { once: true } ); }); handlesG.appendChild(handle); }); } if (fpDraft && fpDraft.length) { draftG.appendChild(svgEl("polyline", { points: pointsAttr(fpDraft), class: "fp-draft-line" })); fpDraft.forEach((p) => draftG.appendChild(svgEl("circle", { cx: p[0] * FP.W, cy: p[1] * FP.H, r: 6, class: "fp-draft-point" })) ); } } function renderRoomList() { const el = document.getElementById("fp-room-list"); const level = currentLevel(); if (!level || !level.rooms.length) { el.innerHTML = '

No rooms on this level yet — draw one.

'; return; } el.innerHTML = level.rooms .map( (r) => `` ) .join(""); el.querySelectorAll("[data-pick]").forEach((b) => b.addEventListener("click", () => selectRoom(Number(b.dataset.pick))) ); } function selectRoom(roomId) { const level = currentLevel(); const room = level && level.rooms.find((r) => r.id === roomId); if (!room) return; // Edited on a deep copy: dragging handles mutates points as you go, and abandoning // an edit has to leave the stored room untouched. fpSelected = JSON.parse(JSON.stringify(room)); document.getElementById("fp-room-editor").hidden = false; document.getElementById("fp-room-name").value = room.name; document.getElementById("fp-room-area").value = room.ha_area_id || ""; document.getElementById("fp-room-color").value = room.color || "#6ea8fe"; document.getElementById("fp-editor-title").textContent = room.name; fpSetStatus(""); renderFloorplan(); } function clearSelection() { fpSelected = null; document.getElementById("fp-room-editor").hidden = true; document.getElementById("fp-editor-title").textContent = "Rooms"; renderFloorplan(); } function loadFloorplan(keepSelection) { return api("/floorplan") .then((data) => { fpLevels = data.levels || []; if (!fpLevels.some((l) => l.id === fpLevelId)) fpLevelId = fpLevels.length ? fpLevels[0].id : null; const select = document.getElementById("fp-level"); select.innerHTML = fpLevels.map((l) => ``).join(""); if (fpLevelId) select.value = fpLevelId; if (!keepSelection) clearSelection(); renderRoomList(); renderFloorplan(); }) .catch((err) => fpSetStatus(err.message, true)); } function loadFloorplanPresence() { if (!document.getElementById("fp-live").checked) { fpLive = null; document.getElementById("fp-unplaced").textContent = ""; renderFloorplan(); return Promise.resolve(); } return api("/floorplan/presence") .then((data) => { fpLive = data; const bits = []; if ((data.unplaced || []).length) { bits.push( "Home but not on the plan: " + data.unplaced .map((p) => escapeHtml(p.name) + (p.reported_area ? ` (area “${escapeHtml(p.reported_area)}”)` : "")) .join(", ") ); } if ((data.unmapped_areas || []).length) { bits.push( "Areas HA is reporting that no room claims: " + data.unmapped_areas.map(escapeHtml).join(", ") + " — draw them, or map an existing room to them." ); } document.getElementById("fp-unplaced").innerHTML = bits.join("
"); renderFloorplan(); }) .catch(() => {}); } function loadAreaSuggestions() { api("/floorplan/areas") .then((data) => { document.getElementById("fp-areas").innerHTML = (data.areas || []) .map((a) => `