<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Home Desk — Hearth</title>
<!-- Standalone build of HomeDesk.dc.html: boot.js fetches the product's files
     at runtime, which a bundler cannot follow, so every file is inlined below in
     the order boot.js loads them. Image URLs that live only as strings in the
     product's code are lifted into meta tags; at runtime window.__resources
     holds the inlined blob URLs. -->
<meta name="ext-resource-dependency" content="../../assets/img/avatar-f-2.png" data-resource-id="r001">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-m-13.jpg" data-resource-id="r002">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-40-02.jpg" data-resource-id="r003">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-40-03.jpg" data-resource-id="r004">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-f-9.jpg" data-resource-id="r005">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-80-06.jpg" data-resource-id="r006">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-m-18.jpg" data-resource-id="r007">
<meta name="ext-resource-dependency" content="../../assets/templates/logos/01-logo-demo.svg" data-resource-id="r008">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-f-7.jpg" data-resource-id="r009">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-f-11.jpg" data-resource-id="r010">
<meta name="ext-resource-dependency" content="../../assets/img/avatar-f-13.jpg" data-resource-id="r011">
<script>window.__resources = Object.assign({"r001":"../../assets/img/avatar-f-2.png","r002":"../../assets/img/avatar-m-13.jpg","r003":"../../assets/img/avatar-40-02.jpg","r004":"../../assets/img/avatar-40-03.jpg","r005":"../../assets/img/avatar-f-9.jpg","r006":"../../assets/img/avatar-80-06.jpg","r007":"../../assets/img/avatar-m-18.jpg","r008":"../../assets/templates/logos/01-logo-demo.svg","r009":"../../assets/img/avatar-f-7.jpg","r010":"../../assets/img/avatar-f-11.jpg","r011":"../../assets/img/avatar-f-13.jpg"}, window.__resources || {});</script>
<link rel="stylesheet" href="../../styles.css">
<link rel="stylesheet" href="./kit.css">
<style>html,body{margin:0;padding:0}body{background:var(--sidebar)}</style>
<template id="__bundler_thumbnail" data-bg-color="#f7f7f6">
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg"><rect width="1200" height="800" fill="#f7f7f6"/><rect x="110" y="110" width="200" height="580" rx="16" fill="#e6e6e4"/><path d="M600 200 L860 400 L820 400 L820 620 L640 620 L640 480 L560 480 L560 620 L380 620 L380 400 L340 400 Z" fill="#26262b"/><g fill="#26262b" opacity="0.8"><rect x="900" y="200" width="190" height="26" rx="6"/><rect x="900" y="260" width="190" height="26" rx="6"/><rect x="900" y="320" width="130" height="26" rx="6"/></g></svg>
</template>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.min.js" crossorigin="anonymous"></script>
<script src="../../_ds_bundle.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" data-presets="react" data-file="lib/contracts.js">
(function(){
/* Contract only — permanent machinery, copied into every product folder so that
   deleting the product deletes it too. No domain value may appear in this file:
   if an unrelated product could not use a value with the same meaning, it
   belongs in enums.js or nav.js beside it. These copies start byte-identical
   between products and stay that way — a fix here is a fix everywhere, never a
   local edit.

   Shapes
     Tone          "neutral" | "info" | "success" | "warning" | "danger"
     StatusValue   { id, label, tone }
     StatusField   { entity, field, means, values: StatusValue[] }
     LabelledValue { value, label }
     Route         { path, label, icon?, group?, parent?, badge?,
                     offSidebar?, menuOnly?, roles? }
*/

const TONES = ["neutral", "info", "success", "warning", "danger"];

/* Classification values render through this — never through a Badge. */
function labelOf(list, value) {
  const hit = (list || []).find((i) => i.value === value);
  return hit ? hit.label : value;
}

/* The one place a status id becomes a label + tone. */
function statusValue(values, id) {
  return (values || []).find((v) => v.id === id) || null;
}

function indexRoutes(nav) {
  const index = {};
  (nav || []).forEach((r) => { index[r.path] = r; });
  return index;
}

function matchRoute(path, index) {
  if (index[path]) return index[path];
  return Object.values(index).find((r) =>
    r.path.indexOf(":") > -1 &&
    new RegExp("^" + r.path.replace(/:[^/]+/g, "[^/]+") + "$").test(path)
  ) || null;
}

/* Breadcrumbs read the index, never the nav tree, and never invent a segment:
   an unresolved path returns [] so the screen — not the route table — is fixed. */
function breadcrumb(path, index) {
  const route = matchRoute(path, index);
  if (!route) return [];
  const trail = [route];
  let parent = route.parent;
  while (parent && index[parent]) { trail.unshift(index[parent]); parent = index[parent].parent; }
  return trail.map((s) => ({ label: s.label, path: s.path, isNamespace: !!s.menuOnly }));
}

Object.assign(window, { TONES, labelOf, statusValue, indexRoutes, matchRoute, breadcrumb });

})();
</script>
<script type="text/babel" data-presets="react" data-file="enums.js">
(function(){
/* Hearth — this residence's status vocabulary.
   A value not listed here does not exist. No screen may invent a status, pass a
   string literal into a Badge, or assign a tone: StatusBadge reads these lists
   and nothing else does.

   Everything computed from other fields is a DERIVED FLAG, not a status: a door
   unlocked while the alarm is away, a filter past its hours, a bill past its
   date, a routine naming a device that has been removed. Those live in the
   *_FLAGS blocks with the clock injected, and a record carries one status and
   any number of flags.

   Tone grouping, one question, product-wide: CAN THE HOUSE BE LEFT ALONE.
     danger   — a failure event: something is not working, or is actually wrong
     warning  — a deliberate state that wants a person: a decision, a setup, a date
     info     — running, doing its job, nobody owed anything
     success  — settled, closed, or secure
     neutral  — parked on purpose, or historic
*/

/* Whether the house can rely on a device. Deliberately NOT here: on and off —
   that is the device's control value, which every row renders as a control
   rather than a badge, and "low battery", which is derived from the level. */
const DEVICE_STATUS = [
  { id: "online", label: "Online", tone: "info" },
  { id: "offline", label: "Offline", tone: "danger" },
  { id: "setup_pending", label: "Needs setting up", tone: "warning" },
  { id: "updating", label: "Updating", tone: "neutral" },
  { id: "retired", label: "Out of the house", tone: "neutral" },
];

/* What a heating zone has been told to do. A zone is never "cold" — that is the
   temperature it reports, read against the target. */
const ZONE_STATUS = [
  { id: "comfort", label: "Comfort", tone: "info" },
  { id: "setback", label: "Setback", tone: "neutral" },
  { id: "boost", label: "Boost", tone: "warning" },
  { id: "frost", label: "Frost protection", tone: "neutral" },
  { id: "off", label: "Off", tone: "neutral" },
];

/* The alarm. triggered is danger — it is an event, not a state somebody chose;
   fault is danger for the same reason. */
const ALARM_STATUS = [
  { id: "disarmed", label: "Disarmed", tone: "neutral" },
  { id: "armed_home", label: "Armed — home", tone: "info" },
  { id: "armed_away", label: "Armed — away", tone: "info" },
  { id: "triggered", label: "Triggered", tone: "danger" },
  { id: "fault", label: "Fault", tone: "danger" },
];

/* A door, window or gate as its sensor reports it. Whether that is a PROBLEM is
   derived: an open window is fine in the afternoon and not fine when the house
   is armed away. */
const ENTRY_STATUS = [
  { id: "locked", label: "Closed and locked", tone: "success" },
  { id: "unlocked", label: "Closed, unlocked", tone: "warning" },
  { id: "open", label: "Open", tone: "warning" },
  { id: "unknown", label: "Sensor offline", tone: "danger" },
];

/* A piece of work on the house. Deliberately absent: "overdue" — derived from
   the due date, and for a filter from the hours it has actually run. */
const JOB_STATUS = [
  { id: "planned", label: "Planned", tone: "warning" },
  { id: "quoted", label: "Quoted", tone: "warning" },
  { id: "scheduled", label: "Scheduled", tone: "info" },
  { id: "in_progress", label: "In progress", tone: "info" },
  { id: "done", label: "Done", tone: "success" },
  { id: "cancelled", label: "Cancelled", tone: "neutral" },
];

/* Money owed for running the house. "overdue" is derived from the due date. */
const BILL_STATUS = [
  { id: "estimated", label: "Estimated", tone: "neutral" },
  { id: "due", label: "Due", tone: "warning" },
  { id: "paid", label: "Paid", tone: "success" },
  { id: "disputed", label: "Disputed", tone: "danger" },
];

/* A way into the house that is not a person: a keypad code, a handed-out key, a
   contractor's window of access. "Expiring" and "never used" are derived. */
const ACCESS_STATUS = [
  { id: "active", label: "Active", tone: "info" },
  { id: "scheduled", label: "Starts later", tone: "neutral" },
  { id: "suspended", label: "Suspended", tone: "warning" },
  { id: "revoked", label: "Revoked", tone: "neutral" },
];

/* Somebody's standing in the household. */
const MEMBER_STATUS = [
  { id: "invited", label: "Invited", tone: "warning" },
  { id: "active", label: "Active", tone: "success" },
  { id: "suspended", label: "Suspended", tone: "warning" },
  { id: "removed", label: "Removed", tone: "neutral" },
];

/* A routine the house runs on its own. "Broken" is not here: it is derived from
   whether the devices the routine names are still in the house. */
const ROUTINE_STATUS = [
  { id: "on", label: "On", tone: "info" },
  { id: "paused", label: "Paused", tone: "neutral" },
  { id: "draft", label: "Unfinished", tone: "warning" },
];

/* Somebody in the house asking for something the rules do not allow them. */
const REQUEST_STATUS = [
  { id: "pending", label: "Waiting on you", tone: "warning" },
  { id: "granted", label: "Granted", tone: "success" },
  { id: "denied", label: "Denied", tone: "neutral" },
];

/* ── Classification lists — read through labelOf, never through a Badge ───── */
const ROOMS = [
  { value: "kitchen", label: "Kitchen" },
  { value: "hall", label: "Hall" },
  { value: "living", label: "Drawing room" },
  { value: "study", label: "Study" },
  { value: "main_bed", label: "Main bedroom" },
  { value: "milo_bed", label: "Milo's room" },
  { value: "iris_bed", label: "Iris's room" },
  { value: "utility", label: "Utility" },
  { value: "garage", label: "Garage" },
  { value: "garden", label: "Garden" },
];
const ZONES = [
  { value: "ground", label: "Ground floor" },
  { value: "first", label: "First floor" },
  { value: "study", label: "Study" },
  { value: "water", label: "Hot water" },
  { value: "annexe", label: "Annexe" },
];
const DEVICE_KINDS = [
  { value: "light", label: "Light" },
  { value: "lock", label: "Lock" },
  { value: "thermostat", label: "Thermostat" },
  { value: "sensor", label: "Sensor" },
  { value: "camera", label: "Camera" },
  { value: "appliance", label: "Appliance" },
  { value: "meter", label: "Meter" },
  { value: "blind", label: "Blind" },
  { value: "speaker", label: "Speaker" },
];
const ENTRY_KINDS = [
  { value: "door", label: "Door" },
  { value: "window", label: "Window" },
  { value: "gate", label: "Gate" },
  { value: "garage", label: "Garage door" },
];
const HOUSEHOLD_ROLES = [
  { value: "owner", label: "Owner" },
  { value: "adult", label: "Adult" },
  { value: "child", label: "Child" },
  { value: "staff", label: "Household staff" },
  { value: "guest", label: "Guest" },
];
const ACCESS_KINDS = [
  { value: "code", label: "Keypad code" },
  { value: "key", label: "Physical key" },
  { value: "window", label: "Timed access" },
];
const JOB_KINDS = [
  { value: "service", label: "Service visit" },
  { value: "repair", label: "Repair" },
  { value: "consumable", label: "Consumable" },
  { value: "inspection", label: "Inspection" },
  { value: "garden", label: "Grounds" },
];
const TRADES = [
  { value: "heating", label: "Heating and plumbing" },
  { value: "electrical", label: "Electrical" },
  { value: "garden", label: "Grounds and garden" },
  { value: "cleaning", label: "Cleaning" },
  { value: "security", label: "Security" },
  { value: "chimney", label: "Chimney and flues" },
];
const BILL_KINDS = [
  { value: "energy", label: "Electricity" },
  { value: "water", label: "Water" },
  { value: "council", label: "Council tax" },
  { value: "insurance", label: "Insurance" },
  { value: "service", label: "Service contract" },
  { value: "broadband", label: "Broadband" },
];
const EVENT_KINDS = [
  { value: "device", label: "Device" },
  { value: "security", label: "Security" },
  { value: "climate", label: "Climate" },
  { value: "routine", label: "Routine" },
  { value: "person", label: "Person" },
  { value: "upkeep", label: "Upkeep" },
  { value: "money", label: "Money" },
];
const TRIGGER_KINDS = [
  { value: "time", label: "At a time" },
  { value: "presence", label: "When somebody arrives or leaves" },
  { value: "sensor", label: "When a sensor changes" },
  { value: "sunset", label: "Around sunset" },
];

/* What a connection test came back with. Not derived — it is what the hub
   reported when it last asked. Whether that answer is STALE is derived, from the
   clock against the policy window. */
const TEST_STATUS = [
  { id: "pass", label: "Answered", tone: "success" },
  { id: "slow", label: "Slow to answer", tone: "warning" },
  { id: "fail", label: "No answer", tone: "danger" },
  { id: "untested", label: "Never tested", tone: "neutral" },
];

/* What a camera caught. A clip is classified once, by the camera, and every
   surface reads the label from here. */
const CLIP_KINDS = [
  { value: "person", label: "A person" },
  { value: "vehicle", label: "A vehicle" },
  { value: "parcel", label: "A delivery" },
  { value: "animal", label: "An animal" },
  { value: "motion", label: "Movement" },
];

/* Signal strength is a reading in dBm, so the BAND is derived from it — three
   bands declared once, and every bar, badge and count reads this one function. */
const SIGNAL_BANDS = [
  { value: "good", label: "Strong", bars: 3 },
  { value: "fair", label: "Fair", bars: 2 },
  { value: "weak", label: "Weak", bars: 1 },
];
const signalBand = (dbm) => (dbm == null ? null : dbm >= -55 ? "good" : dbm >= -70 ? "fair" : "weak");

/* A verdict's ATTENTION LEVEL. Not a status — no record carries it; it is the
   tone a verdict function returns, named once here so the badge that shows it
   never carries a literal. Colour lands on a 6px dot rather than on the headline:
   a sentence in red is a shout, and the same sentence with a dotted badge beside
   it is a reading. */
const VERDICT_TONES = [
  { id: "danger", label: "Wrong now" },
  { id: "warning", label: "Wants you" },
  { id: "info", label: "Running" },
  { id: "success", label: "Settled" },
];

/* ── The household's own policy — every number the house acts on ─────────── */
const HOUSE_POLICY = {
  awaySetbackC: 15.5,          /* what the house holds when nobody is home and awake */
  nightSetbackC: 17,           /* what it holds once everybody is asleep */
  frostC: 7,
  boostMinutes: 60,
  darkAfter: "18:20",          /* sunset today — an unlocked door reads differently after it */
  quietFrom: "21:30",
  quietTo: "06:45",
  lowBatteryPercent: 20,
  jobDueSoonDays: 14,
  accessExpirySoonDays: 7,
  standingChargePence: 62,     /* per day */
  testStaleDays: 7,            /* after this a connection test is worth running again */
  weakSignalDbm: -70,          /* the band boundary signalBand reads */
};

/* What each role may do. Screens read these maps, never a name or a role label. */
const ROLE_CAN_UNLOCK = { owner: true, adult: true, child: false, staff: true, guest: false };
const ROLE_CAN_ARM = { owner: true, adult: true, child: false, staff: true, guest: false };
const ROLE_CAN_SPEND = { owner: true, adult: true, child: false, staff: false, guest: false };
const ROLE_CAN_GRANT_ACCESS = { owner: true, adult: false, child: false, staff: false, guest: false };
const ROLE_CAN_EDIT_ROUTINES = { owner: true, adult: true, child: false, staff: false, guest: false };
/* Watching the house is a privacy decision, so it is a role rule like any other:
   the children and a guest do not get the live view or the clips. */
const ROLE_CAN_WATCH = { owner: true, adult: true, child: false, staff: false, guest: false };
const ROLE_CAN_PAIR = { owner: true, adult: true, child: false, staff: false, guest: false };

/* ── Derived flags — computed at render, never stored, clock injected ─────── */
const DAY_MS = 864e5;
const HOUR_MS = 36e5;
const MINUTE_MS = 6e4;

/* Minutes since midnight, so a policy window can be compared to a clock. */
const minutesOfDay = (at) => { const d = new Date(at); return d.getHours() * 60 + d.getMinutes(); };
const parseClock = (text) => { const [h, m] = String(text).split(":").map(Number); return h * 60 + m; };
const isDark = (now) => minutesOfDay(now) >= parseClock(HOUSE_POLICY.darkAfter) || minutesOfDay(now) < 7 * 60;

const DEVICE_FLAGS = {
  /* One act-ability predicate, read by the row, the tab count and the nav badge. */
  needsYou: (d) => d.status === "offline" || d.status === "setup_pending" || (d.battery != null && d.battery <= 0),
  lowBattery: (d) => d.battery != null && d.battery > 0 && d.battery <= HOUSE_POLICY.lowBatteryPercent,
  /* Recessed rather than disabled: nobody can act on it, and the row says why. */
  recessed: (d) => d.status === "updating" || d.status === "retired",
  drawing: (d) => d.status === "online" && !!d.on && (d.watts || 0) > 0,
};

/* A camera is a device, so its status is DEVICE_STATUS. What is peculiar to a
   camera is whether it is WATCHING, and that is derived from three facts at once:
   it is online, it is set to record, and its shutter is open. */
const CAMERA_FLAGS = {
  watching: (c) => !!c.device && c.device.status === "online" && c.recording && !c.privacy,
  blind: (c) => !c.device || c.device.status !== "online",
  shuttered: (c) => !!c.privacy && !!c.device && c.device.status === "online",
  recessed: (c) => !c.device || c.device.status !== "online",
};

/* The radio link to a device, read by the connection test and by every signal
   bar. Weak, behind and stale are all derived — nothing writes them down. */
const LINK_FLAGS = {
  weak: (link) => !!link && signalBand(link.dbm) === "weak",
  behind: (link) => !!link && !!link.firmware && link.firmware !== link.latest,
  stale: (link, now) => !link || !link.testedAt || now - link.testedAt > HOUSE_POLICY.testStaleDays * DAY_MS,
  answered: (link) => !!link && link.result === "pass",
};

const ENTRY_FLAGS = {
  /* An entry is insecure against the alarm and the clock, never on its own: an
     open window at four in the afternoon is a window; the same window with the
     house armed away is the reason the alarm exists. */
  insecure: (entry, alarm, now) => {
    if (entry.status === "unknown") return true;
    if (entry.status === "locked") return false;
    if (alarm === "armed_away" || alarm === "triggered") return true;
    if (entry.kind === "window") return alarm === "armed_home" && entry.floor === "ground";
    return isDark(now);
  },
  wouldBlockArming: (entry) => entry.status === "open" || entry.status === "unknown",
};

const ZONE_FLAGS = {
  /* Heating into an open window is the one thing a heating screen must catch. */
  heatingWithWindowOpen: (zone, entries) => zone.status !== "off" && zone.reportedC < zone.targetC && entries.some((e) => e.zone === zone.id && e.status === "open"),
  belowTarget: (zone) => zone.reportedC < zone.targetC - 0.4,
  overridden: (zone) => !!zone.overrideUntil,
  callingForHeat: (zone) => zone.status !== "off" && zone.reportedC < zone.targetC - 0.2,
};

const JOB_FLAGS = {
  overdue: (job, now) => job.status !== "done" && job.status !== "cancelled" && !!job.dueAt && job.dueAt < now,
  dueSoon: (job, now) => job.status !== "done" && job.status !== "cancelled" && !!job.dueAt && job.dueAt >= now && job.dueAt - now <= HOUSE_POLICY.jobDueSoonDays * DAY_MS,
  /* A consumable's real due date is the hours it has run, not the calendar. */
  consumableSpent: (job) => job.kind === "consumable" && job.lifeHours != null && job.ranHours != null && job.ranHours >= job.lifeHours,
  lifeLeft: (job) => (job.lifeHours ? Math.max(0, 1 - job.ranHours / job.lifeHours) : null),
  needsYou: (job, now) => JOB_FLAGS.overdue(job, now) || job.status === "quoted" || JOB_FLAGS.consumableSpent(job),
};

const BILL_FLAGS = {
  overdue: (bill, now) => bill.status === "due" && bill.dueAt < now,
  aboveForecast: (bill) => bill.forecastMinor != null && bill.amountMinor > bill.forecastMinor,
};

const ACCESS_FLAGS = {
  expiringSoon: (grant, now) => grant.status === "active" && !!grant.endsAt && grant.endsAt - now <= HOUSE_POLICY.accessExpirySoonDays * DAY_MS && grant.endsAt >= now,
  expired: (grant, now) => grant.status === "active" && !!grant.endsAt && grant.endsAt < now,
  neverUsed: (grant) => grant.status === "active" && !grant.lastUsedAt,
};

const ROUTINE_FLAGS = {
  /* Derived, so removing a device breaks its routines with nothing written on
     them, and every count that reads this moves by itself. */
  broken: (routine, devices) => routine.deviceIds.some((id) => !devices.some((d) => d.id === id && d.status !== "retired")),
  needsYou: (routine, devices) => routine.status === "draft" || ROUTINE_FLAGS.broken(routine, devices),
  recessed: (routine) => routine.status === "paused",
};

const REQUEST_FLAGS = { waiting: (r) => r.status === "pending" };

const MEMBER_FLAGS = {
  /* Awake is derived from that person's own bedtime and wake preferences against
     the clock — the house never stores whether somebody is awake. */
  isAwake: (member, now) => {
    const minute = minutesOfDay(now);
    const bed = parseClock(member.prefs.bedtime);
    const wake = parseClock(member.prefs.wake);
    return bed > wake ? minute < bed && minute >= wake : minute < bed || minute >= wake;
  },
  countsForComfort: (member, now) => member.status === "active" && member.home && MEMBER_FLAGS.isAwake(member, now),
};

/* ── Transitions — one function per entity, called by every surface ────────
   The set derives from the current state; a transition that does not apply is
   omitted, one that exists but this person may not take is disabled with the
   reason stated (§1.2). One transition is primary: the record's normal path. */
function deviceTransitions(device, viewer) {
  const may = ROLE_CAN_UNLOCK[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Locks and doors are an adult's to move. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (device.status === "setup_pending") return [{ id: "set_up", label: "Set up device", primary: true }, { id: "remove", label: "Remove device", destructive: true }];
  if (device.status === "offline") return [{ id: "retry", label: "Retry connection", primary: true }, { id: "remove", label: "Remove device", destructive: true }];
  if (device.status === "updating") return [];
  if (device.status === "retired") return [{ id: "restore", label: "Switch back on", primary: true }];
  const out = [];
  if (device.kind === "lock") out.push(gate({ id: device.on ? "unlock" : "lock", label: device.on ? "Unlock" : "Lock", primary: true }));
  else out.push({ id: device.on ? "turn_off" : "turn_on", label: device.on ? "Turn off" : "Turn on", primary: true });
  if (DEVICE_FLAGS.lowBattery(device) || (device.battery != null && device.battery <= 0)) out.push({ id: "battery_replaced", label: "Mark battery replaced" });
  out.push({ id: "retire", label: "Switch out of the house" });
  out.push({ id: "remove", label: "Remove device", destructive: true });
  return out;
}

/* A camera's controls, derived from what it is doing. Privacy and the live view
   are gated on the role rather than hidden, so a child sees why (§1.2). */
function cameraTransitions(camera, viewer) {
  const may = ROLE_CAN_WATCH[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "The live view and the shutters are an adult's. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (!camera.device || camera.device.status === "setup_pending") return [gate({ id: "commission_camera", label: "Set the camera up", primary: true })];
  if (camera.device.status === "offline") return [gate({ id: "test_device", label: "Test the connection", primary: true })];
  if (camera.device.status === "updating") return [];
  const out = [];
  if (camera.privacy) out.push(gate({ id: "privacy_off", label: "Open the shutter", primary: true }));
  else out.push(gate({ id: "clip_now", label: "Record a clip", primary: true }));
  if (camera.talk) out.push(gate({ id: "talk", label: "Talk" }));
  if (camera.floodlight) out.push(gate({ id: "floodlight", label: camera.floodlightOn ? "Floodlight off" : "Floodlight on" }));
  if (!camera.privacy) out.push(gate({ id: "privacy_on", label: "Close the shutter" }));
  return out;
}

function jobTransitions(job, viewer) {
  const may = ROLE_CAN_SPEND[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Committing money is the owner's or another adult's decision. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (job.status === "planned") return [gate({ id: "request_quote", label: "Ask for a quote", primary: true }), gate({ id: "book", label: "Book a visit" }), { id: "cancel_job", label: "Cancel job" }];
  if (job.status === "quoted") return [gate({ id: "accept_quote", label: "Accept quote and book", primary: true }), { id: "cancel_job", label: "Cancel job" }];
  if (job.status === "scheduled") return [{ id: "start_job", label: "Mark as started", primary: true }, gate({ id: "reschedule", label: "Move the visit" }), { id: "cancel_job", label: "Cancel job" }];
  if (job.status === "in_progress") return [{ id: "complete_job", label: "Mark as done", primary: true }];
  return [];
}

function billTransitions(bill, viewer) {
  const may = ROLE_CAN_SPEND[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Paying a bill is the owner's or another adult's decision. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (bill.status === "due") return [gate({ id: "pay", label: "Mark as paid", primary: true }), gate({ id: "dispute", label: "Query the bill" })];
  if (bill.status === "estimated") return [gate({ id: "confirm_reading", label: "Send a meter reading", primary: true })];
  if (bill.status === "disputed") return [gate({ id: "resolve_dispute", label: "Close the query", primary: true })];
  return [];
}

function accessTransitions(grant, viewer) {
  const may = ROLE_CAN_GRANT_ACCESS[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Only the owner hands out access. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (grant.status === "active") return [gate({ id: "suspend_access", label: "Suspend access", primary: true }), gate({ id: "revoke_access", label: "Revoke access", destructive: true })];
  if (grant.status === "suspended") return [gate({ id: "restore_access", label: "Restore access", primary: true }), gate({ id: "revoke_access", label: "Revoke access", destructive: true })];
  if (grant.status === "scheduled") return [gate({ id: "revoke_access", label: "Revoke access", destructive: true })];
  return [];
}

function routineTransitions(routine, devices, viewer) {
  const may = ROLE_CAN_EDIT_ROUTINES[viewer.role];
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Routines are changed by an adult in the household. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
  if (routine.status === "draft") return [gate({ id: "finish_routine", label: "Finish routine", primary: true }), gate({ id: "delete_routine", label: "Delete routine", destructive: true })];
  if (ROUTINE_FLAGS.broken(routine, devices)) return [gate({ id: "fix_routine", label: "Fix routine", primary: true }), gate({ id: "pause_routine", label: "Pause routine" })];
  if (routine.status === "paused") return [gate({ id: "resume_routine", label: "Resume routine", primary: true }), gate({ id: "delete_routine", label: "Delete routine", destructive: true })];
  return [gate({ id: "run_now", label: "Run now", primary: true }), gate({ id: "pause_routine", label: "Pause routine" }), gate({ id: "delete_routine", label: "Delete routine", destructive: true })];
}

function memberTransitions(member, viewer) {
  const may = viewer.role === "owner";
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Only the owner changes somebody's standing in the household." });
  if (member.id === viewer.id) return [];
  if (member.status === "invited") return [gate({ id: "resend_invite", label: "Resend invitation", primary: true }), gate({ id: "remove_member", label: "Remove from household", destructive: true })];
  if (member.status === "active") return [gate({ id: "suspend_member", label: "Suspend access" }), gate({ id: "remove_member", label: "Remove from household", destructive: true })];
  if (member.status === "suspended") return [gate({ id: "restore_member", label: "Restore access", primary: true }), gate({ id: "remove_member", label: "Remove from household", destructive: true })];
  return [];
}

function requestTransitions(request, viewer) {
  if (request.status !== "pending") return [];
  const may = viewer.role === "owner" || viewer.role === "adult";
  const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "An adult in the household answers a request." });
  return [gate({ id: "grant", label: "Grant", primary: true }), gate({ id: "deny", label: "Deny" })];
}

/* Registry — what a status field means, for the reader of this file. */
const STATUS_FIELDS = [
  { entity: "device", field: "status", means: "whether the house can rely on it", values: DEVICE_STATUS },
  { entity: "zone", field: "status", means: "what a heating zone has been told to do", values: ZONE_STATUS },
  { entity: "house", field: "alarm", means: "what the alarm is set to", values: ALARM_STATUS },
  { entity: "entry", field: "status", means: "how a door, window or gate is reported", values: ENTRY_STATUS },
  { entity: "job", field: "status", means: "where a piece of work has got to", values: JOB_STATUS },
  { entity: "bill", field: "status", means: "whether money is owed for it", values: BILL_STATUS },
  { entity: "grant", field: "status", means: "whether a way in still works", values: ACCESS_STATUS },
  { entity: "member", field: "status", means: "somebody's standing in the household", values: MEMBER_STATUS },
  { entity: "routine", field: "status", means: "whether the house runs it", values: ROUTINE_STATUS },
  { entity: "request", field: "status", means: "whether somebody is owed an answer", values: REQUEST_STATUS },
  { entity: "link", field: "result", means: "what the last connection test came back with", values: TEST_STATUS },
];

/* Coverage — values with no seeded fixture: alarm.triggered and alarm.fault
   (both one arming away), zone.frost, member.removed, request.denied,
   routine.paused, bill.paid has fixtures. Each is reachable in the running
   product, so no screen claims a count for one that is not. */

Object.assign(window, {
  DEVICE_STATUS, ZONE_STATUS, ALARM_STATUS, ENTRY_STATUS, JOB_STATUS, BILL_STATUS, ACCESS_STATUS, MEMBER_STATUS, ROUTINE_STATUS, REQUEST_STATUS,
  TEST_STATUS, CLIP_KINDS, SIGNAL_BANDS, signalBand, VERDICT_TONES,
  ROOMS, ZONES, DEVICE_KINDS, ENTRY_KINDS, HOUSEHOLD_ROLES, ACCESS_KINDS, JOB_KINDS, TRADES, BILL_KINDS, EVENT_KINDS, TRIGGER_KINDS,
  HOUSE_POLICY, ROLE_CAN_UNLOCK, ROLE_CAN_ARM, ROLE_CAN_SPEND, ROLE_CAN_GRANT_ACCESS, ROLE_CAN_EDIT_ROUTINES, ROLE_CAN_WATCH, ROLE_CAN_PAIR,
  DAY_MS, HOUR_MS, MINUTE_MS, minutesOfDay, parseClock, isDark,
  DEVICE_FLAGS, ENTRY_FLAGS, ZONE_FLAGS, JOB_FLAGS, BILL_FLAGS, ACCESS_FLAGS, ROUTINE_FLAGS, REQUEST_FLAGS, MEMBER_FLAGS, CAMERA_FLAGS, LINK_FLAGS,
  deviceTransitions, cameraTransitions, jobTransitions, billTransitions, accessTransitions, routineTransitions, memberTransitions, requestTransitions,
  STATUS_FIELDS,
});

})();
</script>
<script type="text/babel" data-presets="react" data-file="nav.js">
(function(){
/* Hearth — the route table.
   Deliberately NOT routes: the ⌘K palette, the notifications panel, the room
   controls, the live camera view, the pairing flow, the connection test, every
   filter bar and tab strip, the arm/disarm sheet, the schedule editor, the code
   sheet, every confirm dialog and every staged dialog. They are states of a
   screen. A breadcrumb that wants one of them is a bug in the screen, not a
   missing route.

   TEN top-level destinations over two groups of five. This is a house with five
   people in it, not a business: the sidebar carries what somebody standing in
   the hall would want, and the register-keeping — the bills, the handed-out
   codes, the trade contacts, the whole log — hangs off the destination it
   belongs to rather than taking a line of its own. Every one of those four is
   still a registered route with a screen behind it; each is reached from its
   parent, which is where a person looks for it.

   The first group is what the house is DOING: the verdict, the rooms and their
   controls, the cameras, the heating, the doors. The second is what keeps it
   running: the devices and their radio, the routines, the people, the
   electricity, the work owed to the building.

   There is no /rooms/:id and no /cameras/:id. Rooms holds every room and its
   controls at once, which is what a house is; the camera screen focuses one
   camera as a STATE, because a camera has no fact a card cannot carry.

   Parameterised routes exist where the thing has its own controls, its own
   history and its own numbers: a device, a person, a service provider, a job of
   work. Rooms, cameras, zones, entries, codes and bills do not — each is a row
   with a control, complete where it stands.

   Settings is one namespace of five screens off the sidebar, reached from the
   account menu, with a tab strip on the page derived from this table. The auth
   namespace carries sign-in and the whole password reset flow; signup, MFA and
   lockout are not built, so nothing links to them. */

const HEARTH_GROUPS = ["The house", "Running it"];

const HEARTH_NAV = [
  /* The house — what it is doing right now, and what you would reach for. */
  { path: "/", label: "Today", icon: "sun", group: "The house" },
  { path: "/rooms", label: "Rooms", icon: "door-open", group: "The house" },
  { path: "/cameras", label: "Cameras", icon: "video", group: "The house", badge: "camerasBlind" },
  { path: "/climate", label: "Climate", icon: "thermometer", group: "The house" },
  { path: "/security", label: "Security", icon: "shield", group: "The house", badge: "entriesInsecure" },

  /* Running it — the kit, the rules, the people and the money. */
  { path: "/devices", label: "Devices", icon: "plug", group: "Running it", badge: "devicesNeedingYou" },
  { path: "/routines", label: "Routines", icon: "workflow", group: "Running it" },
  { path: "/people", label: "People", icon: "users", group: "Running it", badge: "requestsWaiting" },
  { path: "/energy", label: "Energy", icon: "zap", group: "Running it" },
  { path: "/upkeep", label: "Upkeep", icon: "wrench", group: "Running it" },

  /* Registered, reached from their parent rather than the sidebar. Each is a
     register somebody consults occasionally, and each already has one obvious
     place a person would look for it. */
  { path: "/spend", label: "Bills", parent: "/energy", offSidebar: true },
  { path: "/access", label: "Access", parent: "/people", offSidebar: true },
  { path: "/providers", label: "Providers", parent: "/upkeep", offSidebar: true },
  { path: "/log", label: "Log", parent: "/", offSidebar: true },

  /* Records. */
  { path: "/devices/:id", label: "Device", parent: "/devices", offSidebar: true },
  { path: "/people/:id", label: "Person", parent: "/people", offSidebar: true },
  { path: "/providers/:id", label: "Provider", parent: "/providers", offSidebar: true },
  { path: "/upkeep/:id", label: "Job", parent: "/upkeep", offSidebar: true },
  { path: "/account/profile", label: "Your profile", offSidebar: true },

  /* Settings — one namespace, five screens, a tab strip on the page. */
  { path: "/settings", label: "Residence", offSidebar: true, group: "Settings" },
  { path: "/settings/tariff", label: "Tariff and meters", parent: "/settings", offSidebar: true, group: "Settings" },
  { path: "/settings/security", label: "Alarm and response", parent: "/settings", offSidebar: true, group: "Settings" },
  { path: "/settings/notifications", label: "Notifications", parent: "/settings", offSidebar: true, group: "Settings" },
  { path: "/settings/data", label: "Data and privacy", parent: "/settings", offSidebar: true, group: "Settings" },

  /* Auth namespace — excluded from the shell nav; no product chrome. */
  { path: "/signin", label: "Sign in", offSidebar: true },
  { path: "/forgot-password", label: "Forgot password", offSidebar: true },
  { path: "/forgot-password/sent", label: "Reset link sent", parent: "/forgot-password", offSidebar: true },
  { path: "/reset-password", label: "Set a new password", offSidebar: true },
  { path: "/reset-password/expired", label: "Reset link expired", parent: "/reset-password", offSidebar: true },
  { path: "/reset-password/done", label: "Password changed", parent: "/reset-password", offSidebar: true },
];

const HEARTH_ROUTES = indexRoutes(HEARTH_NAV);

/* Projections the shell renders — derived by filtering the tree, never retyped. */
const HEARTH_SIDEBAR = HEARTH_GROUPS.map((group) => ({
  label: group,
  items: HEARTH_NAV.filter((r) => !r.offSidebar && r.group === group),
}));

const HEARTH_SETTINGS_NAV = HEARTH_NAV.filter((r) => r.group === "Settings");

/* The account menu carries registered routes plus sign out (§6.2). Settings is
   one entry: its own tab strip carries the five screens inside it. */
const HEARTH_ACCOUNT_MENU = [HEARTH_ROUTES["/account/profile"], HEARTH_ROUTES["/settings"]];

/* Paths that render without product chrome (AGENTS §7.1). */
const HEARTH_AUTH_PATHS = ["/signin", "/forgot-password", "/forgot-password/sent", "/reset-password", "/reset-password/expired", "/reset-password/done"];

const resolvePath = (pattern, params) => pattern.replace(/:([^/]+)/g, (_, key) => (params && params[key] != null ? params[key] : ":" + key));

/* The preview menu's page list — this table read whole and grouped, with the
   parameterised routes resolved against the fixture so each link lands on a real
   record rather than on ":id". */
const HEARTH_PAGES = (data) => {
  const device = data.devices.find((d) => d.status === "online") || data.devices[0];
  const person = data.members[0];
  const provider = data.providers[0];
  const job = data.jobs[0];
  return templatePages(HEARTH_NAV, HEARTH_AUTH_PATHS, {
    "/devices/:id": device ? "/devices/" + device.id : null,
    "/people/:id": person ? "/people/" + person.id : null,
    "/providers/:id": provider ? "/providers/" + provider.id : null,
    "/upkeep/:id": job ? "/upkeep/" + job.id : null,
  });
};

Object.assign(window, {
  HEARTH_GROUPS, HEARTH_NAV, HEARTH_ROUTES, HEARTH_SIDEBAR, HEARTH_SETTINGS_NAV,
  HEARTH_ACCOUNT_MENU, HEARTH_AUTH_PATHS, HEARTH_PAGES, resolvePath,
});

})();
</script>
<script type="text/babel" data-presets="react" data-file="fixtures.js">
(function(){
/* Hearth — one fixture for the whole product.
   Every screen reads these arrays; no screen writes a second literal to match.
   The clock is frozen so the derived flags are reproducible: Tuesday 6 October
   2026, 19:10 at Blackthorn House. Dark since 18:20, the peak tariff window shut
   ten minutes ago, one child is still up, and the back door is unlocked.

   The log, the requests and the notifications are authored AGAINST these
   records: an entry that names a door, a filter or a quote agrees with that
   record's status and timeline at the moment it was written. */

const HEARTH_NOW = Date.parse("2026-10-06T19:10:00Z");
const minsAgo = (m) => HEARTH_NOW - m * MINUTE_MS;
const hoursAgo = (h) => HEARTH_NOW - h * HOUR_MS;
const hoursOn = (h) => HEARTH_NOW + h * HOUR_MS;
const daysAgo = (d) => HEARTH_NOW - d * DAY_MS;
const daysOn = (d) => HEARTH_NOW + d * DAY_MS;
const dayAt = (offset, hour, minute) => {
  const d = new Date(HEARTH_NOW + offset * DAY_MS);
  d.setHours(hour, minute || 0, 0, 0);
  return d.getTime();
};

const RESIDENCE = {
  name: "Blackthorn House",
  short: "Blackthorn",
  address: "Blackthorn House, Bovingdon Lane, Marlow SL7",
  built: 1908,
  bedrooms: 6,
  floorAreaSqm: 412,
  timezone: "Europe/London",
  epc: "D",
  responseNumber: "0800 118 4400",
  insurer: "Cranmore Private Clients",
  policyNumber: "CPC-8841-22",
};

const HEARTH_SIGNED_IN = "mem_01";

/* The alarm, held on the house rather than on a device: it is one state the
   whole product reads. */
const HOUSE_STATE = {
  alarm: "disarmed",
  alarmChangedAt: hoursAgo(9.5),
  alarmChangedById: "mem_01",
  lastArmedAwayAt: daysAgo(4),
  responseCalled: false,
};

/* ── The household ───────────────────────────────────────────────────────── */
const MEMBERS = [
  { id: "mem_01", name: "Sofia Brandt", role: "owner", status: "active", home: true, avatar: (window.__resources["r001"]), email: "sofia@blackthorn.house", phone: "07700 900 141", since: daysAgo(2190), lastSeenAt: minsAgo(4), where: "study",
    prefs: { comfortC: 20.5, bedtime: "23:00", wake: "06:30", lightsOnArrival: true, notifyAlarm: true, notifyDeliveries: true } },
  { id: "mem_02", name: "Adam Brandt", role: "adult", status: "active", home: false, avatar: (window.__resources["r002"]), email: "adam@blackthorn.house", phone: "07700 900 208", since: daysAgo(2190), lastSeenAt: hoursAgo(11), where: null,
    prefs: { comfortC: 19.5, bedtime: "23:30", wake: "06:00", lightsOnArrival: true, notifyAlarm: true, notifyDeliveries: false } },
  { id: "mem_03", name: "Milo Brandt", role: "child", status: "active", home: true, avatar: (window.__resources["r003"]), email: "milo@blackthorn.house", phone: null, since: daysAgo(1460), lastSeenAt: minsAgo(12), where: "milo_bed",
    prefs: { comfortC: 20, bedtime: "20:30", wake: "07:00", lightsOnArrival: false, notifyAlarm: false, notifyDeliveries: false } },
  { id: "mem_04", name: "Iris Brandt", role: "child", status: "active", home: true, avatar: (window.__resources["r004"]), email: null, phone: null, since: daysAgo(1460), lastSeenAt: minsAgo(25), where: "living",
    prefs: { comfortC: 21, bedtime: "19:30", wake: "06:45", lightsOnArrival: false, notifyAlarm: false, notifyDeliveries: false } },
  { id: "mem_05", name: "Renate Kohl", role: "staff", status: "active", home: true, avatar: (window.__resources["r005"]), email: "renate.kohl@example.com", phone: "07700 900 377", since: daysAgo(980), lastSeenAt: minsAgo(40), where: "kitchen",
    prefs: { comfortC: 19, bedtime: "22:30", wake: "06:00", lightsOnArrival: false, notifyAlarm: false, notifyDeliveries: true } },
  { id: "mem_06", name: "Dee Osman", role: "adult", status: "invited", home: false, avatar: (window.__resources["r006"]), email: "dee.osman@example.com", phone: "07700 900 512", since: daysAgo(2), lastSeenAt: null, where: null,
    prefs: { comfortC: 20, bedtime: "23:00", wake: "07:30", lightsOnArrival: true, notifyAlarm: false, notifyDeliveries: false } },
  { id: "mem_07", name: "Tomas Iversen", role: "guest", status: "suspended", home: false, avatar: (window.__resources["r007"]), email: "tomas.iversen@example.com", phone: null, since: daysAgo(620), lastSeenAt: daysAgo(96), where: null,
    prefs: { comfortC: 20, bedtime: "00:30", wake: "08:00", lightsOnArrival: false, notifyAlarm: false, notifyDeliveries: false },
    suspendedNote: "Access suspended when the au-pair arrangement ended in July. The record is kept so the log still reads." },
];

/* ── Heating zones ───────────────────────────────────────────────────────
   targetC is stored only where the zone has its own setpoint (hot water). For
   the living zones the target is DERIVED from who is home and awake — see
   metrics.comfortTarget — so raising somebody's comfort preference moves the
   house. */
const ZONES_STATE = [
  { id: "ground", status: "comfort", reportedC: 19.8, humidity: 48, radiators: 7, overrideUntil: null, targetC: null, floor: "ground" },
  { id: "first", status: "setback", reportedC: 18.1, humidity: 52, radiators: 8, overrideUntil: null, targetC: null, floor: "first" },
  { id: "study", status: "boost", reportedC: 20.4, humidity: 45, radiators: 2, overrideUntil: hoursOn(0.6), targetC: null, floor: "ground" },
  { id: "water", status: "comfort", reportedC: 47, humidity: null, radiators: 0, overrideUntil: null, targetC: 50, floor: null },
  { id: "annexe", status: "frost", reportedC: 9.4, humidity: 61, radiators: 3, overrideUntil: null, targetC: null, floor: "annexe" },
];

/* ── Doors, windows and gates ───────────────────────────────────────────── */
const ENTRIES = [
  { id: "ent_01", label: "Front door", kind: "door", room: "hall", zone: "ground", floor: "ground", status: "locked", changedAt: hoursAgo(9.5), byId: "mem_01", deviceId: "dev_04" },
  { id: "ent_02", label: "Back door", kind: "door", room: "kitchen", zone: "ground", floor: "ground", status: "unlocked", changedAt: minsAgo(38), byId: "mem_05", deviceId: "dev_05" },
  { id: "ent_03", label: "French doors", kind: "door", room: "living", zone: "ground", floor: "ground", status: "locked", changedAt: hoursAgo(26), byId: "mem_02", deviceId: "dev_06" },
  { id: "ent_04", label: "Utility door", kind: "door", room: "utility", zone: "ground", floor: "ground", status: "locked", changedAt: hoursAgo(5), byId: "mem_05", deviceId: null },
  { id: "ent_05", label: "Kitchen window", kind: "window", room: "kitchen", zone: "ground", floor: "ground", status: "locked", changedAt: hoursAgo(3), byId: null, deviceId: "dev_18" },
  { id: "ent_06", label: "Bathroom window", kind: "window", room: "main_bed", zone: "first", floor: "first", status: "open", changedAt: hoursAgo(2.2), byId: null, deviceId: "dev_19" },
  { id: "ent_07", label: "Study window", kind: "window", room: "study", zone: "study", floor: "ground", status: "locked", changedAt: daysAgo(1), byId: null, deviceId: "dev_20" },
  { id: "ent_08", label: "Milo's window", kind: "window", room: "milo_bed", zone: "first", floor: "first", status: "unknown", changedAt: hoursAgo(31), byId: null, deviceId: "dev_21" },
  { id: "ent_09", label: "Side gate", kind: "gate", room: "garden", zone: null, floor: "ground", status: "locked", changedAt: hoursAgo(6), byId: "mem_05", deviceId: "dev_22" },
  { id: "ent_10", label: "Garage door", kind: "garage", room: "garage", zone: null, floor: "ground", status: "locked", changedAt: hoursAgo(11), byId: "mem_02", deviceId: "dev_23" },
];

/* ── Devices ─────────────────────────────────────────────────────────────
   watts is what the device is drawing now; battery is null on anything mains
   powered. runtimeHours exists on the two things whose consumables are counted
   in hours rather than days, and the upkeep screen reads it. */
const DEVICES = [
  { id: "dev_01", name: "Kitchen ceiling", kind: "light", room: "kitchen", status: "online", on: true, watts: 34, battery: null, lastSeenAt: minsAgo(1), addedAt: daysAgo(700) },
  { id: "dev_02", name: "Kitchen island", kind: "light", room: "kitchen", status: "online", on: true, watts: 22, battery: null, lastSeenAt: minsAgo(1), addedAt: daysAgo(700) },
  { id: "dev_03", name: "Hall lantern", kind: "light", room: "hall", status: "online", on: true, watts: 18, battery: null, lastSeenAt: minsAgo(2), addedAt: daysAgo(700) },
  { id: "dev_04", name: "Front door lock", kind: "lock", room: "hall", status: "online", on: true, watts: 0, battery: 74, lastSeenAt: minsAgo(3), addedAt: daysAgo(540) },
  { id: "dev_05", name: "Back door lock", kind: "lock", room: "kitchen", status: "online", on: false, watts: 0, battery: 61, lastSeenAt: minsAgo(2), addedAt: daysAgo(540) },
  { id: "dev_06", name: "French door lock", kind: "lock", room: "living", status: "online", on: true, watts: 0, battery: 18, lastSeenAt: minsAgo(4), addedAt: daysAgo(540) },
  { id: "dev_07", name: "Drawing room lamps", kind: "light", room: "living", status: "online", on: true, watts: 41, battery: null, lastSeenAt: minsAgo(2), addedAt: daysAgo(690) },
  { id: "dev_08", name: "Drawing room blinds", kind: "blind", room: "living", status: "online", on: true, watts: 0, battery: 88, lastSeenAt: hoursAgo(1), addedAt: daysAgo(400) },
  { id: "dev_09", name: "Study desk light", kind: "light", room: "study", status: "online", on: true, watts: 12, battery: null, lastSeenAt: minsAgo(1), addedAt: daysAgo(690) },
  { id: "dev_10", name: "Study heater", kind: "appliance", room: "study", status: "online", on: true, watts: 780, battery: null, lastSeenAt: minsAgo(1), addedAt: daysAgo(120) },
  { id: "dev_11", name: "Boiler controller", kind: "thermostat", room: "utility", status: "online", on: true, watts: 6, battery: null, lastSeenAt: minsAgo(1), addedAt: daysAgo(900), runtimeHours: 1310 },
  { id: "dev_12", name: "Ground floor thermostat", kind: "thermostat", room: "hall", status: "online", on: true, watts: 2, battery: 55, lastSeenAt: minsAgo(2), addedAt: daysAgo(900) },
  { id: "dev_13", name: "First floor thermostat", kind: "thermostat", room: "main_bed", status: "online", on: true, watts: 2, battery: 43, lastSeenAt: minsAgo(3), addedAt: daysAgo(900) },
  { id: "dev_14", name: "Ventilation unit", kind: "appliance", room: "utility", status: "online", on: true, watts: 62, battery: null, lastSeenAt: minsAgo(1), addedAt: daysAgo(430), runtimeHours: 3980 },
  { id: "dev_15", name: "Immersion heater", kind: "appliance", room: "utility", status: "online", on: false, watts: 0, battery: null, lastSeenAt: minsAgo(5), addedAt: daysAgo(900) },
  { id: "dev_16", name: "Washing machine", kind: "appliance", room: "utility", status: "online", on: true, watts: 410, battery: null, lastSeenAt: minsAgo(1), addedAt: daysAgo(260) },
  { id: "dev_17", name: "Oven", kind: "appliance", room: "kitchen", status: "online", on: false, watts: 0, battery: null, lastSeenAt: minsAgo(2), addedAt: daysAgo(1200) },
  { id: "dev_18", name: "Kitchen window sensor", kind: "sensor", room: "kitchen", status: "online", on: true, watts: 0, battery: 67, lastSeenAt: minsAgo(3), addedAt: daysAgo(500) },
  { id: "dev_19", name: "Bathroom window sensor", kind: "sensor", room: "main_bed", status: "online", on: true, watts: 0, battery: 51, lastSeenAt: minsAgo(2), addedAt: daysAgo(500) },
  { id: "dev_20", name: "Study window sensor", kind: "sensor", room: "study", status: "online", on: true, watts: 0, battery: 72, lastSeenAt: minsAgo(4), addedAt: daysAgo(500) },
  { id: "dev_21", name: "Milo's window sensor", kind: "sensor", room: "milo_bed", status: "offline", on: false, watts: 0, battery: 0, lastSeenAt: hoursAgo(31), addedAt: daysAgo(500) },
  { id: "dev_22", name: "Side gate lock", kind: "lock", room: "garden", status: "online", on: true, watts: 0, battery: 34, lastSeenAt: minsAgo(9), addedAt: daysAgo(310) },
  { id: "dev_23", name: "Garage door", kind: "appliance", room: "garage", status: "online", on: true, watts: 4, battery: null, lastSeenAt: hoursAgo(11), addedAt: daysAgo(820) },
  { id: "dev_24", name: "Drive camera", kind: "camera", room: "garden", status: "online", on: true, watts: 8, battery: null, lastSeenAt: minsAgo(1), addedAt: daysAgo(380) },
  { id: "dev_25", name: "Back garden camera", kind: "camera", room: "garden", status: "online", on: true, watts: 8, battery: null, lastSeenAt: minsAgo(1), addedAt: daysAgo(380) },
  { id: "dev_26", name: "Gate camera", kind: "camera", room: "garden", status: "setup_pending", on: false, watts: 0, battery: null, lastSeenAt: null, addedAt: daysAgo(3) },
  { id: "dev_27", name: "Porch camera", kind: "camera", room: "hall", status: "updating", on: true, watts: 7, battery: null, lastSeenAt: minsAgo(6), addedAt: daysAgo(380) },
  { id: "dev_28", name: "Electricity meter", kind: "meter", room: "utility", status: "online", on: true, watts: 0, battery: null, lastSeenAt: minsAgo(1), addedAt: daysAgo(1100) },
  { id: "dev_29", name: "Water meter", kind: "meter", room: "utility", status: "online", on: true, watts: 0, battery: null, lastSeenAt: minsAgo(2), addedAt: daysAgo(1100) },
  { id: "dev_30", name: "Kitchen speaker", kind: "speaker", room: "kitchen", status: "online", on: false, watts: 3, battery: null, lastSeenAt: minsAgo(14), addedAt: daysAgo(600) },
  { id: "dev_31", name: "Milo's lamp", kind: "light", room: "milo_bed", status: "online", on: true, watts: 9, battery: null, lastSeenAt: minsAgo(3), addedAt: daysAgo(300) },
  { id: "dev_32", name: "Iris's lamp", kind: "light", room: "iris_bed", status: "online", on: false, watts: 0, battery: null, lastSeenAt: minsAgo(30), addedAt: daysAgo(300) },
  { id: "dev_33", name: "Garden lights", kind: "light", room: "garden", status: "online", on: true, watts: 46, battery: null, lastSeenAt: minsAgo(50), addedAt: daysAgo(420) },
  { id: "dev_34", name: "Garage strip light", kind: "light", room: "garage", status: "retired", on: false, watts: 0, battery: null, lastSeenAt: daysAgo(9), addedAt: daysAgo(820) },
];

/* ── The radio link, one record per device ───────────────────────────────
   This is what a connection test reads: how loud the device is (dBm), how long
   it took to answer, the firmware it is running against the firmware available,
   and when the hub last asked. Nothing here is a judgement — weak, behind and
   stale are all derived in enums.js from these readings and the clock, so a
   device becomes a problem without anybody writing on it. */
const HUB = {
  name: "Hearth hub", room: "utility", channel: 11, firmware: "5.2.0", latest: "5.2.2",
  uptimeHours: 812, testedAt: hoursAgo(20), devices: 33,
};

const DEVICE_LINK = {
  dev_01: { dbm: -44, latencyMs: 12, firmware: "3.4.1", latest: "3.4.1", testedAt: hoursAgo(20), result: "pass" },
  dev_02: { dbm: -47, latencyMs: 14, firmware: "3.4.1", latest: "3.4.1", testedAt: hoursAgo(20), result: "pass" },
  dev_03: { dbm: -52, latencyMs: 18, firmware: "3.4.0", latest: "3.4.1", testedAt: hoursAgo(20), result: "pass" },
  dev_04: { dbm: -58, latencyMs: 26, firmware: "2.9.4", latest: "2.9.4", testedAt: hoursAgo(20), result: "pass" },
  dev_05: { dbm: -61, latencyMs: 31, firmware: "2.9.4", latest: "2.9.4", testedAt: hoursAgo(20), result: "pass" },
  dev_06: { dbm: -66, latencyMs: 44, firmware: "2.9.1", latest: "2.9.4", testedAt: hoursAgo(20), result: "slow" },
  dev_07: { dbm: -49, latencyMs: 15, firmware: "3.4.1", latest: "3.4.1", testedAt: hoursAgo(20), result: "pass" },
  dev_08: { dbm: -63, latencyMs: 38, firmware: "1.8.2", latest: "1.8.2", testedAt: hoursAgo(20), result: "pass" },
  dev_09: { dbm: -55, latencyMs: 17, firmware: "3.4.1", latest: "3.4.1", testedAt: hoursAgo(20), result: "pass" },
  dev_10: { dbm: -57, latencyMs: 22, firmware: "1.2.0", latest: "1.2.0", testedAt: hoursAgo(20), result: "pass" },
  dev_11: { dbm: -41, latencyMs: 9, firmware: "6.1.3", latest: "6.1.3", testedAt: hoursAgo(20), result: "pass" },
  dev_12: { dbm: -50, latencyMs: 16, firmware: "6.1.3", latest: "6.1.3", testedAt: hoursAgo(20), result: "pass" },
  dev_13: { dbm: -68, latencyMs: 52, firmware: "6.1.0", latest: "6.1.3", testedAt: hoursAgo(20), result: "slow" },
  dev_14: { dbm: -43, latencyMs: 11, firmware: "4.0.7", latest: "4.0.7", testedAt: hoursAgo(20), result: "pass" },
  dev_15: { dbm: -45, latencyMs: 13, firmware: "4.0.7", latest: "4.0.7", testedAt: hoursAgo(20), result: "pass" },
  dev_16: { dbm: -46, latencyMs: 19, firmware: "2.2.1", latest: "2.2.1", testedAt: hoursAgo(20), result: "pass" },
  dev_17: { dbm: -48, latencyMs: 21, firmware: "2.2.1", latest: "2.2.1", testedAt: hoursAgo(20), result: "pass" },
  dev_18: { dbm: -54, latencyMs: 24, firmware: "1.4.6", latest: "1.4.6", testedAt: hoursAgo(20), result: "pass" },
  dev_19: { dbm: -71, latencyMs: 68, firmware: "1.4.6", latest: "1.4.6", testedAt: hoursAgo(20), result: "slow" },
  dev_20: { dbm: -59, latencyMs: 28, firmware: "1.4.6", latest: "1.4.6", testedAt: hoursAgo(20), result: "pass" },
  dev_21: { dbm: null, latencyMs: null, firmware: "1.4.3", latest: "1.4.6", testedAt: hoursAgo(20), result: "fail" },
  dev_22: { dbm: -78, latencyMs: 96, firmware: "2.9.4", latest: "2.9.4", testedAt: hoursAgo(20), result: "slow" },
  dev_23: { dbm: -74, latencyMs: 81, firmware: "1.1.9", latest: "1.1.9", testedAt: hoursAgo(20), result: "slow" },
  dev_24: { dbm: -62, latencyMs: 34, firmware: "8.3.2", latest: "8.3.2", testedAt: hoursAgo(20), result: "pass" },
  dev_25: { dbm: -73, latencyMs: 77, firmware: "8.3.2", latest: "8.3.2", testedAt: hoursAgo(20), result: "slow" },
  dev_26: { dbm: null, latencyMs: null, firmware: null, latest: "8.3.2", testedAt: null, result: "untested" },
  dev_27: { dbm: -51, latencyMs: 20, firmware: "8.3.0", latest: "8.3.2", testedAt: hoursAgo(20), result: "pass" },
  dev_28: { dbm: -40, latencyMs: 8, firmware: "5.0.1", latest: "5.0.1", testedAt: hoursAgo(20), result: "pass" },
  dev_29: { dbm: -42, latencyMs: 10, firmware: "5.0.1", latest: "5.0.1", testedAt: hoursAgo(20), result: "pass" },
  dev_30: { dbm: -53, latencyMs: 23, firmware: "7.2.4", latest: "7.2.4", testedAt: hoursAgo(20), result: "pass" },
  dev_31: { dbm: -64, latencyMs: 41, firmware: "3.4.1", latest: "3.4.1", testedAt: hoursAgo(20), result: "pass" },
  dev_32: { dbm: -65, latencyMs: 43, firmware: "3.4.1", latest: "3.4.1", testedAt: hoursAgo(20), result: "pass" },
  dev_33: { dbm: -76, latencyMs: 88, firmware: "3.3.8", latest: "3.4.1", testedAt: hoursAgo(20), result: "slow" },
  dev_34: { dbm: null, latencyMs: null, firmware: "3.2.0", latest: "3.4.1", testedAt: daysAgo(9), result: "untested" },
};

/* Devices the hub can hear but the house has not taken in yet — what the pairing
   flow starts from, rather than a blank form. */
const DISCOVERED = [
  { id: "dsc_01", announced: "Dimmable bulb, E27", suggestKind: "light", suggestRoom: "main_bed", dbm: -57, foundAt: minsAgo(3) },
  { id: "dsc_02", announced: "Contact sensor", suggestKind: "sensor", suggestRoom: "iris_bed", dbm: -66, foundAt: minsAgo(9) },
  { id: "dsc_03", announced: "Outdoor camera, 2K", suggestKind: "camera", suggestRoom: "garden", dbm: -72, foundAt: hoursAgo(2) },
];

/* ── Cameras ─────────────────────────────────────────────────────────────
   One record per camera, holding only what a camera has and a light does not: a
   shutter, a floodlight, two-way audio, and whether it is set to record. Whether
   it is WATCHING is derived from these and the device's own status. */
const CAMERAS_STATE = [
  { deviceId: "dev_24", view: "The drive and the gates", privacy: false, recording: true, talk: true, floodlight: true, floodlightOn: false, nightVision: true, resolution: "2K", clipSeconds: 30 },
  { deviceId: "dev_27", view: "The porch and the front door", privacy: false, recording: true, talk: true, floodlight: false, floodlightOn: false, nightVision: true, resolution: "2K", clipSeconds: 20 },
  { deviceId: "dev_25", view: "The back lawn to the annexe", privacy: true, recording: true, talk: false, floodlight: true, floodlightOn: false, nightVision: true, resolution: "1080p", clipSeconds: 30 },
  { deviceId: "dev_26", view: "The side gate and the passage", privacy: false, recording: false, talk: false, floodlight: false, floodlightOn: false, nightVision: true, resolution: "2K", clipSeconds: 30 },
];

/* What the cameras caught, newest first, authored against the log: the car in
   the drive is the electrician leaving, and the porch clips are Milo and Renate. */
const CLIPS = [
  { id: "clp_01", deviceId: "dev_27", at: minsAgo(22), kind: "person", seconds: 14, note: "Milo at the front door, then went round the back" },
  { id: "clp_02", deviceId: "dev_24", at: minsAgo(41), kind: "vehicle", seconds: 26, note: "A van turned in the drive and reversed out" },
  { id: "clp_03", deviceId: "dev_27", at: hoursAgo(4.5), kind: "person", seconds: 11, note: "Milo let himself in on the front door code" },
  { id: "clp_04", deviceId: "dev_24", at: hoursAgo(6), kind: "vehicle", seconds: 34, note: "Marlow Electrical arrived for the gate motor" },
  { id: "clp_05", deviceId: "dev_27", at: hoursAgo(10.5), kind: "person", seconds: 9, note: "Renate arrived" },
  { id: "clp_06", deviceId: "dev_24", at: hoursAgo(13), kind: "animal", seconds: 6, note: "A fox across the gravel" },
  { id: "clp_07", deviceId: "dev_27", at: hoursAgo(28), kind: "parcel", seconds: 18, note: "A delivery left in the porch" },
];

/* ── Energy ──────────────────────────────────────────────────────────────
   One row per day, oldest first, today last and incomplete. offPeakShare is the
   share of that day's import taken inside a cheap window; cost is never stored —
   costOf applies the tariff, once, in metrics.js. */
const TARIFF = {
  name: "Octave Flux — three rate",
  supplier: "Octave Energy",
  standingChargePence: HOUSE_POLICY.standingChargePence,
  exportPencePerKwh: 15,
  windows: [
    { id: "off_peak", label: "Off-peak", from: "00:30", to: "05:30", pencePerKwh: 8.4 },
    { id: "peak", label: "Peak", from: "16:00", to: "19:00", pencePerKwh: 41.2 },
    { id: "standard", label: "Standard", from: null, to: null, pencePerKwh: 24.8 },
  ],
};

const ENERGY_DAYS = [
  { at: daysAgo(13), kwh: 41.2, offPeakShare: 0.34, solarKwh: 9.1, exportKwh: 2.4 },
  { at: daysAgo(12), kwh: 38.6, offPeakShare: 0.31, solarKwh: 11.4, exportKwh: 3.8 },
  { at: daysAgo(11), kwh: 44.9, offPeakShare: 0.29, solarKwh: 6.2, exportKwh: 0.9 },
  { at: daysAgo(10), kwh: 52.4, offPeakShare: 0.41, solarKwh: 4.8, exportKwh: 0.4 },
  { at: daysAgo(9), kwh: 47.1, offPeakShare: 0.38, solarKwh: 7.7, exportKwh: 1.2 },
  { at: daysAgo(8), kwh: 39.8, offPeakShare: 0.36, solarKwh: 10.2, exportKwh: 3.1 },
  { at: daysAgo(7), kwh: 36.4, offPeakShare: 0.33, solarKwh: 12.6, exportKwh: 4.6 },
  { at: daysAgo(6), kwh: 48.8, offPeakShare: 0.27, solarKwh: 5.4, exportKwh: 0.6 },
  { at: daysAgo(5), kwh: 55.2, offPeakShare: 0.44, solarKwh: 3.1, exportKwh: 0 },
  { at: daysAgo(4), kwh: 51.6, offPeakShare: 0.42, solarKwh: 4.4, exportKwh: 0.2 },
  { at: daysAgo(3), kwh: 43.7, offPeakShare: 0.35, solarKwh: 8.8, exportKwh: 1.9 },
  { at: daysAgo(2), kwh: 46.9, offPeakShare: 0.3, solarKwh: 6.9, exportKwh: 1.1 },
  { at: daysAgo(1), kwh: 58.3, offPeakShare: 0.46, solarKwh: 2.8, exportKwh: 0 },
  { at: daysAgo(0), kwh: 44.1, offPeakShare: 0.39, solarKwh: 6.8, exportKwh: 0.7, partial: true },
];

const BATTERY = { capacityKwh: 9.6, chargePercent: 34, mode: "tariff", chargedTodayKwh: 6.2, dischargedTodayKwh: 4.1, cycles: 412 };
const SOLAR = { peakKwp: 4.2, todayKwh: 6.8, nowKw: 0, bestTodayKw: 2.4, bestAt: dayAt(0, 12, 40) };

/* ── Water — seven days, and the overnight figure a leak shows up in ────── */
const WATER_DAYS = [
  { at: daysAgo(6), litres: 612, overnightLitres: 4 },
  { at: daysAgo(5), litres: 703, overnightLitres: 6 },
  { at: daysAgo(4), litres: 588, overnightLitres: 5 },
  { at: daysAgo(3), litres: 641, overnightLitres: 38 },
  { at: daysAgo(2), litres: 664, overnightLitres: 44 },
  { at: daysAgo(1), litres: 719, overnightLitres: 51 },
  { at: daysAgo(0), litres: 508, overnightLitres: 49, partial: true },
];

/* ── Money ───────────────────────────────────────────────────────────────
   amountMinor is pence. forecastMinor is what the house expected to pay, so a
   bill above it is a derived finding rather than a number nobody checks. */
const BILLS = [
  { id: "bil_01", kind: "energy", supplier: "Octave Energy", period: "September", amountMinor: 41280, forecastMinor: 36500, status: "due", issuedAt: daysAgo(6), dueAt: daysOn(8), paidAt: null, note: "Includes the standing charge for 30 days." },
  { id: "bil_02", kind: "water", supplier: "Thames Valley Water", period: "Jul–Sep", amountMinor: 18640, forecastMinor: 15200, status: "due", issuedAt: daysAgo(11), dueAt: daysAgo(2), paidAt: null, note: "Measured, not estimated — the overnight flow is in this period." },
  { id: "bil_03", kind: "council", supplier: "Buckinghamshire Council", period: "October", amountMinor: 31700, forecastMinor: 31700, status: "due", issuedAt: daysAgo(4), dueAt: daysOn(3), paidAt: null, note: "Band G, tenth instalment of twelve." },
  { id: "bil_04", kind: "insurance", supplier: "Cranmore Private Clients", period: "Year to Aug 2027", amountMinor: 142000, forecastMinor: 138000, status: "paid", issuedAt: daysAgo(48), dueAt: daysAgo(20), paidAt: daysAgo(24), note: "Renewed with the same excess." },
  { id: "bil_05", kind: "service", supplier: "Wexler Heating", period: "Annual cover", amountMinor: 39600, forecastMinor: 39600, status: "paid", issuedAt: daysAgo(90), dueAt: daysAgo(76), paidAt: daysAgo(80), note: "Covers the boiler service and one call-out." },
  { id: "bil_06", kind: "broadband", supplier: "Verity Fibre", period: "October", amountMinor: 6900, forecastMinor: 6900, status: "paid", issuedAt: daysAgo(5), dueAt: daysOn(9), paidAt: daysAgo(5), note: "Paid by standing order." },
  { id: "bil_07", kind: "energy", supplier: "Octave Energy", period: "August", amountMinor: 33900, forecastMinor: 34800, status: "paid", issuedAt: daysAgo(36), dueAt: daysAgo(8), paidAt: daysAgo(30), note: null },
  { id: "bil_08", kind: "water", supplier: "Thames Valley Water", period: "Apr–Jun", amountMinor: 21400, forecastMinor: 15600, status: "disputed", issuedAt: daysAgo(80), dueAt: daysAgo(52), paidAt: null, note: "Queried in July: the reading jumped 40% with nobody in the house for a fortnight." },
];

const CONTRACTS = [
  { id: "con_01", trade: "heating", providerId: "prv_01", name: "Boiler and radiators, annual cover", monthlyMinor: 3300, status: "active", startedAt: daysAgo(730), endsAt: daysOn(275), note: "One service a year, call-outs included, parts at cost." },
  { id: "con_02", trade: "garden", providerId: "prv_03", name: "Grounds, fortnightly", monthlyMinor: 24000, status: "active", startedAt: daysAgo(1100), endsAt: daysOn(56), note: "Two people, half a day, March to November." },
  { id: "con_03", trade: "cleaning", providerId: "prv_04", name: "Housekeeping, three days a week", monthlyMinor: 96000, status: "active", startedAt: daysAgo(980), endsAt: null, note: "Renate's hours; reviewed each April." },
  { id: "con_04", trade: "security", providerId: "prv_05", name: "Alarm monitoring and response", monthlyMinor: 4200, status: "active", startedAt: daysAgo(400), endsAt: daysOn(12), note: "Renewal quote is due — the premium rises 14% unless the cameras are commissioned." },
];

/* ── Upkeep ──────────────────────────────────────────────────────────────
   A consumable's real due date is the hours it has run, which is read off the
   device, so a filter becomes overdue without anybody writing on the job. */
const JOBS = [
  { id: "job_01", title: "Ventilation filters", kind: "consumable", providerId: null, status: "planned", dueAt: daysAgo(11), scheduledAt: null, costMinor: 8400, quoteMinor: null, deviceId: "dev_14", lifeHours: 3600, ranHours: 3980, room: "utility", note: "Two G4 panels and one F7. The unit has run 380 hours past the change interval." },
  { id: "job_02", title: "Boiler service", kind: "service", providerId: "prv_01", status: "scheduled", dueAt: daysOn(9), scheduledAt: dayAt(3, 8, 30), costMinor: 0, quoteMinor: null, deviceId: "dev_11", lifeHours: null, ranHours: null, room: "utility", note: "Included in the annual cover. Renate will let them in." },
  { id: "job_03", title: "Electrical installation report", kind: "inspection", providerId: "prv_02", status: "planned", dueAt: daysAgo(34), scheduledAt: null, costMinor: 0, quoteMinor: 42000, deviceId: null, lifeHours: null, ranHours: null, room: null, note: "The last EICR expired in September. Insurance asks for one every five years." },
  { id: "job_04", title: "Gutters and downpipes", kind: "repair", providerId: "prv_06", status: "quoted", dueAt: daysOn(21), scheduledAt: null, costMinor: 0, quoteMinor: 68000, deviceId: null, lifeHours: null, ranHours: null, room: null, note: "Quote holds for 30 days. The rear downpipe is loose at the second bracket." },
  { id: "job_05", title: "Side gate motor", kind: "repair", providerId: "prv_02", status: "in_progress", dueAt: daysAgo(1), scheduledAt: hoursAgo(6), costMinor: 21000, quoteMinor: 21000, deviceId: "dev_22", lifeHours: null, ranHours: null, room: "garden", note: "Stripped and cleaned; waiting on a limit switch, back on Thursday." },
  { id: "job_06", title: "Chimney sweep, drawing room flue", kind: "service", providerId: "prv_06", status: "planned", dueAt: daysOn(38), scheduledAt: null, costMinor: 0, quoteMinor: null, deviceId: null, lifeHours: null, ranHours: null, room: "living", note: "Before the fire is lit for the winter." },
  { id: "job_07", title: "Water softener salt", kind: "consumable", providerId: null, status: "planned", dueAt: daysOn(6), scheduledAt: null, costMinor: 2600, quoteMinor: null, deviceId: null, lifeHours: null, ranHours: null, room: "utility", note: "Four blocks. The last delivery was eight weeks ago." },
  { id: "job_08", title: "Commission the gate camera", kind: "service", providerId: "prv_05", status: "planned", dueAt: daysOn(11), scheduledAt: null, costMinor: 0, quoteMinor: null, deviceId: "dev_26", lifeHours: null, ranHours: null, room: "garden", note: "The monitoring renewal is priced on the cameras being live." },
  { id: "job_09", title: "Lawn autumn treatment", kind: "garden", providerId: "prv_03", status: "done", dueAt: daysAgo(9), scheduledAt: daysAgo(9), costMinor: 14000, quoteMinor: 14000, deviceId: null, lifeHours: null, ranHours: null, room: "garden", note: "Scarified and fed. Next treatment in March." },
  { id: "job_10", title: "Replace bathroom window sensor", kind: "repair", providerId: null, status: "cancelled", dueAt: daysAgo(20), scheduledAt: null, costMinor: 0, quoteMinor: null, deviceId: "dev_19", lifeHours: null, ranHours: null, room: "main_bed", note: "Cancelled — the contacts were only dirty, and it has reported cleanly since." },
];

const PROVIDERS = [
  { id: "prv_01", name: "Wexler Heating", trade: "heating", contact: "Jan Wexler", phone: "01628 900 141", email: "jan@wexlerheating.example", since: daysAgo(1460), hourlyMinor: 8500, keyHolder: false, note: "Fits our boiler; holds the service history back to 2019." },
  { id: "prv_02", name: "Marlow Electrical", trade: "electrical", contact: "Ade Salako", phone: "01628 900 288", email: "office@marlowelectrical.example", since: daysAgo(900), hourlyMinor: 9200, keyHolder: false, note: "Did the annexe rewire. Holds the gate motor parts." },
  { id: "prv_03", name: "Hale and Sons", trade: "garden", contact: "Peter Hale", phone: "07700 900 640", email: "peter@haleandsons.example", since: daysAgo(1100), hourlyMinor: 4800, keyHolder: true, note: "Two people, fortnightly. Has the side gate code." },
  { id: "prv_04", name: "Renate Kohl", trade: "cleaning", contact: "Renate Kohl", phone: "07700 900 377", email: "renate.kohl@example.com", since: daysAgo(980), hourlyMinor: 3200, keyHolder: true, note: "Also a member of the household in Hearth, so her code is on the access list." },
  { id: "prv_05", name: "Sentinel Response", trade: "security", contact: "Control room", phone: "0800 118 4400", email: "control@sentinelresponse.example", since: daysAgo(400), hourlyMinor: 0, keyHolder: false, note: "Monitors the alarm. Calls Sofia, then Adam, then the police." },
  { id: "prv_06", name: "Bovingdon Property Care", trade: "chimney", contact: "Lorna Pike", phone: "01628 900 512", email: "hello@bovingdoncare.example", since: daysAgo(320), hourlyMinor: 6400, keyHolder: false, note: "Roof, gutters and flues. Scaffold-free up to two storeys." },
];

/* ── Ways in that are not a person ──────────────────────────────────────── */
const GRANTS = [
  { id: "grn_01", label: "Housekeeping", kind: "code", holder: "Renate Kohl", providerId: "prv_04", entryIds: ["ent_02", "ent_04"], status: "active", startsAt: daysAgo(980), endsAt: null, lastUsedAt: hoursAgo(10.5), uses: 412, note: "Weekdays, 07:00–15:00." },
  { id: "grn_02", label: "Grounds team", kind: "code", holder: "Hale and Sons", providerId: "prv_03", entryIds: ["ent_09"], status: "active", startsAt: daysAgo(1100), endsAt: daysOn(4), lastUsedAt: daysAgo(4), uses: 96, note: "Side gate only. Expires with the growing season." },
  { id: "grn_03", label: "Milo's code", kind: "code", holder: "Milo Brandt", providerId: null, entryIds: ["ent_01"], status: "active", startsAt: daysAgo(400), endsAt: null, lastUsedAt: hoursAgo(4.5), uses: 188, note: "Front door only, and it does not disarm the alarm." },
  { id: "grn_04", label: "Dog walker", kind: "key", holder: "Bea Ndiaye", providerId: null, entryIds: ["ent_04"], status: "active", startsAt: daysAgo(30), endsAt: null, lastUsedAt: null, uses: 0, note: "One key, utility door. Handed over but never used — the arrangement never started." },
  { id: "grn_05", label: "Gate motor repair", kind: "window", holder: "Marlow Electrical", providerId: "prv_02", entryIds: ["ent_09", "ent_10"], status: "active", startsAt: daysAgo(1), endsAt: hoursOn(3), lastUsedAt: hoursAgo(6), uses: 3, note: "Opened for job 05; closes tonight." },
  { id: "grn_06", label: "Grocery delivery", kind: "code", holder: "Fensham Farm", providerId: null, entryIds: ["ent_09"], status: "scheduled", startsAt: dayAt(1, 7, 0), endsAt: dayAt(1, 11, 0), lastUsedAt: null, uses: 0, note: "Wednesday morning drop into the side passage." },
  { id: "grn_07", label: "Former au pair", kind: "code", holder: "Tomas Iversen", providerId: null, entryIds: ["ent_01", "ent_02"], status: "suspended", startsAt: daysAgo(620), endsAt: null, lastUsedAt: daysAgo(96), uses: 640, note: "Suspended in July rather than revoked, so the log still reads." },
  { id: "grn_08", label: "Kitchen fitters", kind: "window", holder: "Ellis Joinery", providerId: null, entryIds: ["ent_02"], status: "revoked", startsAt: daysAgo(210), endsAt: daysAgo(180), lastUsedAt: daysAgo(182), uses: 44, note: "Revoked when the kitchen was signed off." },
];

/* ── Routines ────────────────────────────────────────────────────────────
   A routine stores the person or the policy, never the time, so moving Milo's
   bedtime moves "Milo's bedtime" with it. */
const ROUTINES = [
  { id: "rou_01", name: "Evening lights", trigger: "sunset", status: "on", deviceIds: ["dev_03", "dev_07", "dev_33"], memberId: null, offsetMinutes: -20, lastRunAt: minsAgo(70), runs: 1240, note: "Hall, drawing room and the garden path, twenty minutes before sunset." },
  { id: "rou_02", name: "Milo's bedtime", trigger: "time", status: "on", deviceIds: ["dev_31"], memberId: "mem_03", offsetMinutes: 0, lastRunAt: daysAgo(1), runs: 396, note: "Reads Milo's own bedtime preference, so changing it moves this." },
  { id: "rou_03", name: "Iris's bedtime", trigger: "time", status: "on", deviceIds: ["dev_32"], memberId: "mem_04", offsetMinutes: 0, lastRunAt: daysAgo(1), runs: 396, note: "Reads Iris's own bedtime preference." },
  { id: "rou_04", name: "Nobody home", trigger: "presence", status: "on", deviceIds: ["dev_12", "dev_13"], memberId: null, offsetMinutes: 15, lastRunAt: hoursAgo(11), runs: 820, note: "Drops both floors to the away setback a quarter of an hour after the last person leaves." },
  { id: "rou_05", name: "Somebody home", trigger: "presence", status: "on", deviceIds: ["dev_03", "dev_12"], memberId: null, offsetMinutes: 0, lastRunAt: hoursAgo(4.5), runs: 814, note: "Hall light and comfort heating when the first person arrives, if they asked for lights." },
  { id: "rou_06", name: "Charge the battery off-peak", trigger: "time", status: "on", deviceIds: ["dev_28"], memberId: null, offsetMinutes: 0, lastRunAt: hoursAgo(14), runs: 190, note: "Runs at the start of the off-peak window, from the tariff rather than a stored time." },
  { id: "rou_07", name: "Gate camera clip", trigger: "sensor", status: "draft", deviceIds: ["dev_26", "dev_22"], memberId: null, offsetMinutes: 0, lastRunAt: null, runs: 0, note: "Waiting on the gate camera being commissioned; no clip length chosen yet." },
  { id: "rou_08", name: "Garage light on entry", trigger: "sensor", status: "on", deviceIds: ["dev_34", "dev_23"], memberId: null, offsetMinutes: 0, lastRunAt: daysAgo(9), runs: 210, note: "Has not run since the strip light was switched out of the house." },
];

const REQUESTS = [
  { id: "req_01", memberId: "mem_03", askedAt: minsAgo(22), kind: "unlock", subject: "ent_02", body: "Can I unlock the back door? My bike is in the passage and it is raining.", status: "pending", answeredAt: null, answeredById: null, reason: null },
  { id: "req_02", memberId: "mem_04", askedAt: minsAgo(50), kind: "lights", subject: "dev_32", body: "Please can my lamp stay on until eight tonight.", status: "pending", answeredAt: null, answeredById: null, reason: null },
  { id: "req_03", memberId: "mem_03", askedAt: hoursAgo(27), kind: "climate", subject: "first", body: "My room was cold this morning — can the upstairs come on earlier?", status: "granted", answeredAt: hoursAgo(25), answeredById: "mem_01", reason: "Weekday warm-up moved to 06:20." },
];

/* ── The log — authored against the records above ────────────────────────── */
const EVENTS = [
  { id: "evt_01", at: minsAgo(4), kind: "person", actorId: "mem_01", subject: { type: "member", id: "mem_01" }, line: "Sofia is in the study", note: null },
  { id: "evt_02", at: minsAgo(22), kind: "person", actorId: "mem_03", subject: { type: "request", id: "req_01" }, line: "Milo asked to unlock the back door", note: "Waiting on an answer" },
  { id: "evt_03", at: minsAgo(38), kind: "security", actorId: "mem_05", subject: { type: "entry", id: "ent_02" }, line: "Back door unlocked", note: "Renate took the recycling out" },
  { id: "evt_04", at: minsAgo(50), kind: "person", actorId: "mem_04", subject: { type: "request", id: "req_02" }, line: "Iris asked for her lamp to stay on", note: null },
  { id: "evt_05", at: minsAgo(70), kind: "routine", actorId: null, subject: { type: "routine", id: "rou_01" }, line: "Evening lights ran", note: "Hall, drawing room, garden path" },
  { id: "evt_06", at: hoursAgo(2.2), kind: "security", actorId: null, subject: { type: "entry", id: "ent_06" }, line: "Bathroom window opened", note: "Still open" },
  { id: "evt_07", at: hoursAgo(3), kind: "climate", actorId: "mem_01", subject: { type: "zone", id: "study" }, line: "Study boosted for an hour", note: "Extended once" },
  { id: "evt_08", at: hoursAgo(4.5), kind: "person", actorId: "mem_03", subject: { type: "entry", id: "ent_01" }, line: "Milo let himself in", note: "Front door code" },
  { id: "evt_09", at: hoursAgo(6), kind: "upkeep", actorId: null, subject: { type: "job", id: "job_05" }, line: "Marlow Electrical arrived for the gate motor", note: "Timed access opened at the side gate" },
  { id: "evt_10", at: hoursAgo(9.5), kind: "security", actorId: "mem_01", subject: { type: "house", id: "alarm" }, line: "Alarm disarmed", note: "Sofia, front door" },
  { id: "evt_11", at: hoursAgo(10.5), kind: "person", actorId: "mem_05", subject: { type: "grant", id: "grn_01" }, line: "Renate arrived", note: "Housekeeping code, back door" },
  { id: "evt_12", at: hoursAgo(11), kind: "routine", actorId: null, subject: { type: "routine", id: "rou_04" }, line: "Nobody home ran", note: "Both floors dropped to 15.5°" },
  { id: "evt_13", at: hoursAgo(14), kind: "routine", actorId: null, subject: { type: "routine", id: "rou_06" }, line: "Battery charged off-peak", note: "6.2 kWh at 8.4p" },
  { id: "evt_14", at: hoursAgo(31), kind: "device", actorId: null, subject: { type: "device", id: "dev_21" }, line: "Milo's window sensor stopped reporting", note: "Battery flat" },
  { id: "evt_15", at: daysAgo(1), kind: "money", actorId: null, subject: { type: "bill", id: "bil_01" }, line: "Electricity bill for September arrived", note: "£412.80, above the forecast" },
  { id: "evt_16", at: daysAgo(3), kind: "device", actorId: "mem_01", subject: { type: "device", id: "dev_26" }, line: "Gate camera added", note: "Not set up yet" },
  { id: "evt_17", at: daysAgo(9), kind: "upkeep", actorId: null, subject: { type: "job", id: "job_09" }, line: "Lawn autumn treatment done", note: "Hale and Sons, £140" },
  { id: "evt_18", at: daysAgo(9), kind: "device", actorId: "mem_02", subject: { type: "device", id: "dev_34" }, line: "Garage strip light switched out of the house", note: "Failed ballast" },
];

const NOTIFICATIONS = [
  { id: "ntf_01", title: "Back door has been unlocked 38 minutes", body: "It is dark, and the alarm is disarmed.", at: minsAgo(38), route: "/security" },
  { id: "ntf_02", title: "Milo is waiting on an answer", body: "He asked to unlock the back door.", at: minsAgo(22), route: "/people" },
  { id: "ntf_03", title: "Ventilation filters are 380 hours past due", body: "The unit has run 3,980 hours of a 3,600-hour interval.", at: hoursAgo(20), route: "/upkeep" },
  { id: "ntf_04", title: "Water bill is two days past its due date", body: "£186.40 to Thames Valley Water, and the overnight flow is still running.", at: daysAgo(2), route: "/spend" },
];

const NOTIFICATION_PREFS = {
  quietFrom: HOUSE_POLICY.quietFrom,
  quietTo: HOUSE_POLICY.quietTo,
  rows: [
    { id: "alarm", label: "The alarm is triggered, or faults", push: true, sms: true, email: false },
    { id: "insecure", label: "A door or window is insecure after dark", push: true, sms: false, email: false },
    { id: "request", label: "Somebody in the house asks for something", push: true, sms: false, email: false },
    { id: "upkeep", label: "A job or a consumable falls overdue", push: false, sms: false, email: true },
    { id: "money", label: "A bill arrives above its forecast", push: false, sms: false, email: true },
    { id: "arrival", label: "Somebody arrives or leaves", push: false, sms: false, email: false },
  ],
};

/* Who the monitoring company rings, in order, and what the house is allowed to
   remember. Both are edited in Settings and read by the screens that act on
   them. */
const RESPONSE = {
  order: ["mem_01", "mem_02", "mem_05"],
  autoArm: true,
  autoArmAt: "23:45",
  notifyPolice: true,
};

const DATA_PREFS = {
  keepLogDays: 90,
  keepClipsDays: 14,
  shareWithProviders: true,
  presenceHistory: true,
};

const HEARTH_RESET_LINK = { email: "sofia@blackthorn.house", issuedAt: minsAgo(25), expiresInMinutes: 60 };

const PASSWORD_RULES = [
  { id: "length", label: "At least 12 characters", test: (v) => (v || "").length >= 12 },
  { id: "case", label: "An upper and a lower case letter", test: (v) => /[a-z]/.test(v || "") && /[A-Z]/.test(v || "") },
  { id: "number", label: "A number", test: (v) => /\d/.test(v || "") },
  { id: "name", label: "Not your name or the house's name", test: (v) => !!v && !/sofia|brandt|blackthorn/i.test(v) },
];

Object.assign(window, {
  HEARTH_NOW, RESIDENCE, HEARTH_SIGNED_IN, HOUSE_STATE, MEMBERS, ZONES_STATE, ENTRIES, DEVICES,
  HUB, DEVICE_LINK, DISCOVERED, CAMERAS_STATE, CLIPS,
  TARIFF, ENERGY_DAYS, BATTERY, SOLAR, WATER_DAYS, BILLS, CONTRACTS, JOBS, PROVIDERS, GRANTS,
  ROUTINES, REQUESTS, EVENTS, NOTIFICATIONS, NOTIFICATION_PREFS, RESPONSE, DATA_PREFS, HEARTH_RESET_LINK, PASSWORD_RULES,
  minsAgo, hoursAgo, hoursOn, daysAgo, daysOn, dayAt,
});

})();
</script>
<script type="text/babel" data-presets="react" data-file="lib/ui-runtime.js">
(function(){
/* Shared render runtime — copied per product, domain-free. Status tone, money,
   time and the floating layer live here so no screen assigns a tone or spells a
   duration its own way.

   Local addition, reported in README: Floating / FloatingTip. These portal to the
   document in viewport coordinates, reposition on scroll and resize, flip when
   there is no room, and clamp to the viewport. The design system's own Menu,
   Popover and Tooltip now do all of that too — these remain because they carry a
   different API the products lean on: one element as the trigger, a render
   callback that receives `close`, and no compound Trigger/Popup pair. Reach for
   the design system's overlays in new work. */

const { Badge } = window.OriginDesignSystem_7c998b;

const TONE_VARIANT = { neutral: "secondary", info: "info", success: "success", warning: "warning", danger: "destructive" };

/* The only place a status becomes a label and a colour. */
function StatusBadge({ values, value, size = "sm" }) {
  const found = statusValue(values, value);
  if (!found) { console.warn("[ui-runtime] value not in enum:", value); return null; }
  return <Badge size={size} variant={TONE_VARIANT[found.tone]}>{found.label}</Badge>;
}

const moneyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const formatMoney = (cents) => moneyFormatter.format(cents / 100);
const formatPercent = (ratio) => (ratio === null ? "—" : (ratio * 100).toFixed(1) + "%");
const formatCount = (n) => new Intl.NumberFormat("en-US").format(n);

/* One format rule per column. Distance is absolute: three days ago and three
   days ahead format alike. Direction is never carried by the format. */
function formatWhen(ts, now) {
  if (!ts) return "Never";
  const d = Math.abs(ts - now);
  const date = new Date(ts);
  if (d < 24 * 3600e3) return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
  if (d < 7 * 24 * 3600e3) return date.toLocaleDateString("en-GB", { weekday: "short" }) + " " + date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
  return date.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
}
const formatAbsolute = (ts) => (ts ? new Date(ts).toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "");

/* One spelling for every countdown in the product. Direction is in the words. */
function formatCountdown(ts, now) {
  if (!ts) return "No deadline";
  const diff = ts - now;
  const abs = Math.abs(diff);
  const unit = abs >= 36 * 3600e3 ? Math.round(abs / 864e5) : Math.round(abs / 3600e3);
  const noun = abs >= 36 * 3600e3 ? (unit === 1 ? "day" : "days") : (unit === 1 ? "hour" : "hours");
  return unit + " " + noun + (diff >= 0 ? " left" : " over");
}

function Icon({ name, size = 16 }) {
  const host = React.useRef(null);
  React.useEffect(() => {
    if (!host.current || !window.lucide) return;
    host.current.innerHTML = "";
    const el = document.createElement("i");
    el.setAttribute("data-lucide", name);
    host.current.appendChild(el);
    window.lucide.createIcons({ attrs: { width: size, height: size } });
  }, [name, size]);
  return <span className="fd-icon" ref={host} aria-hidden="true" style={{ width: size, height: size }} />;
}

function useFloating(open, anchorRef, panelRef, align, side) {
  const [pos, setPos] = React.useState({ top: -9999, left: -9999 });
  const place = React.useCallback(() => {
    const host = anchorRef.current, panel = panelRef.current;
    if (!host || !panel) return;
    /* Measure the TRIGGER, not the wrapper. The wrapper is inline-flex and static,
       so an absolutely-positioned trigger inside it — a chart bar, a pinned badge —
       does not sit where the wrapper does, and measuring the wrapper puts every
       overlay in the same wrong place. */
    const anchor = host.firstElementChild || host;
    const a = anchor.getBoundingClientRect(), p = panel.getBoundingClientRect();
    const m = 8;
    let top, left;
    if (side === "right") {
      left = a.right + 6;
      top = a.top + (a.height - p.height) / 2;
    } else {
      top = a.bottom + 4;
      if (top + p.height > window.innerHeight - m) {
        const above = a.top - 4 - p.height;
        top = above > m ? above : Math.max(m, window.innerHeight - m - p.height);
      }
      left = align === "end" ? a.right - p.width : align === "center" ? a.left + (a.width - p.width) / 2 : a.left;
    }
    left = Math.min(Math.max(m, left), Math.max(m, window.innerWidth - m - p.width));
    top = Math.min(Math.max(m, top), Math.max(m, window.innerHeight - m - p.height));
    setPos({ top: top, left: left });
  }, [align, side, anchorRef, panelRef]);
  React.useLayoutEffect(() => { if (open) place(); }, [open, place]);
  React.useEffect(() => {
    if (!open) return undefined;
    const onMove = () => place();
    window.addEventListener("scroll", onMove, true);
    window.addEventListener("resize", onMove);
    return () => { window.removeEventListener("scroll", onMove, true); window.removeEventListener("resize", onMove); };
  }, [open, place]);
  return pos;
}

function Floating({ trigger, children, align = "end", width, role = "menu", panelClass = "o-menu", label }) {
  const [open, setOpen] = React.useState(false);
  const anchorRef = React.useRef(null), panelRef = React.useRef(null);
  const pos = useFloating(open, anchorRef, panelRef, align, "bottom");
  React.useEffect(() => {
    if (!open) return undefined;
    const away = (e) => {
      if (anchorRef.current && anchorRef.current.contains(e.target)) return;
      if (panelRef.current && panelRef.current.contains(e.target)) return;
      setOpen(false);
    };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("mousedown", away);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", away); document.removeEventListener("keydown", onKey); };
  }, [open]);
  const close = () => setOpen(false);
  return (
    <span ref={anchorRef} className="fd-floating-anchor">
      {React.cloneElement(trigger, { onClick: () => setOpen(!open), "aria-expanded": open, "aria-haspopup": role === "menu" ? "menu" : "dialog" })}
      {open ? ReactDOM.createPortal(
        <div ref={panelRef} role={role} aria-label={label} className={"o-popup " + panelClass} style={{ position: "fixed", top: pos.top, left: pos.left, zIndex: 60, minWidth: width || "12rem" }}>
          {typeof children === "function" ? children(close) : children}
        </div>, document.body) : null}
    </span>
  );
}

function FloatingTip({ label, side = "right", children }) {
  const [open, setOpen] = React.useState(false);
  const anchorRef = React.useRef(null), panelRef = React.useRef(null);
  const pos = useFloating(open, anchorRef, panelRef, "center", side);
  return (
    <span ref={anchorRef} className="fd-floating-anchor" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)}>
      {children}
      {open ? ReactDOM.createPortal(
        <span ref={panelRef} role="tooltip" className="o-tooltip" style={{ position: "fixed", top: pos.top, left: pos.left, zIndex: 70, whiteSpace: "nowrap" }}>{label}</span>,
        document.body) : null}
    </span>
  );
}

Object.assign(window, { StatusBadge, TONE_VARIANT, formatMoney, formatPercent, formatCount, formatWhen, formatAbsolute, formatCountdown, Icon, Floating, FloatingTip });

})();
</script>
<script type="text/babel" data-presets="react" data-file="metrics.js">
(function(){
/* Hearth — every number on every screen comes from here.
   The rule this file exists to keep: a figure, a section count and a nav badge
   are one function called three times. If a screen cannot name the array a
   number came from, the number is wrong.

   Nothing here formats and nothing here assigns a tone — ui-runtime.js does the
   first, enums.js the second. */

const memberById = (members, id) => members.find((m) => m.id === id) || null;
const memberName = (members, id) => { const m = memberById(members, id); return m ? m.name : "The house"; };
const deviceById = (devices, id) => devices.find((d) => d.id === id) || null;
const deviceName = (devices, id) => { const d = deviceById(devices, id); return d ? d.name : "A device that has been removed"; };
const providerById = (providers, id) => providers.find((p) => p.id === id) || null;
const providerName = (providers, id) => { const p = providerById(providers, id); return p ? p.name : "Nobody booked"; };
const entryById = (entries, id) => entries.find((e) => e.id === id) || null;
const roomName = (value) => labelOf(ROOMS, value);
const zoneName = (value) => labelOf(ZONES, value);
const initialsOf = (name) => (name || "").split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase();

/* ── The household ───────────────────────────────────────────────────────── */
const activeMembers = (members) => members.filter((m) => m.status === "active");
const membersHome = (members) => activeMembers(members).filter((m) => m.home);
const membersAwake = (members, now) => membersHome(members).filter((m) => MEMBER_FLAGS.isAwake(m, now));
const membersAsleep = (members, now) => membersHome(members).filter((m) => !MEMBER_FLAGS.isAwake(m, now));
/* The family's name, derived: the surname the most people in the household share.
   Nothing stores it, so a household of Osmans would say Osman without an edit. */
function householdName(members) {
  const counts = {};
  activeMembers(members).forEach((m) => {
    const last = (m.name || "").trim().split(/\s+/).pop();
    if (last) counts[last] = (counts[last] || 0) + 1;
  });
  return Object.keys(counts).sort((a, b) => counts[b] - counts[a])[0] || RESIDENCE.short;
}

/* CHAIN ONE — preferences to heating.
   The warmest comfort preference among the people who are home AND awake. With
   nobody to be comfortable the house holds the away setback; with everybody
   asleep it holds the night setback. Nothing stores this number. */
function comfortTarget(members, now) {
  const counting = activeMembers(members).filter((m) => MEMBER_FLAGS.countsForComfort(m, now));
  if (counting.length) return { c: Math.max.apply(null, counting.map((m) => m.prefs.comfortC)), because: "warmest preference among " + counting.length + (counting.length === 1 ? " person" : " people") + " home and awake", who: counting };
  const home = membersHome(members);
  if (home.length) return { c: HOUSE_POLICY.nightSetbackC, because: "everybody home is asleep", who: home };
  return { c: HOUSE_POLICY.awaySetbackC, because: "nobody is home", who: [] };
}

/* Whose preference is setting the target — the one person the number is from. */
function comfortSetter(members, now) {
  const target = comfortTarget(members, now);
  return target.who.slice().sort((a, b) => b.prefs.comfortC - a.prefs.comfortC)[0] || null;
}

/* A zone's effective target: its mode decides which number applies, and the
   comfort number itself is the chain above. */
function zoneTarget(zone, members, now) {
  if (zone.targetC != null) return zone.targetC;
  if (zone.status === "off") return null;
  if (zone.status === "frost") return HOUSE_POLICY.frostC;
  if (zone.status === "setback") return membersHome(members).length ? HOUSE_POLICY.nightSetbackC : HOUSE_POLICY.awaySetbackC;
  const base = comfortTarget(members, now).c;
  return zone.status === "boost" ? base + 1.5 : base;
}

/* Zones with their derived target attached, which is what every zone surface
   renders — the raw ZONES_STATE is never drawn directly. */
const zoneRows = (data, now) => data.zones.map((zone) => Object.assign({}, zone, { targetC: zoneTarget(zone, data.members, now) }));
const zonesCallingForHeat = (data, now) => zoneRows(data, now).filter((z) => ZONE_FLAGS.callingForHeat(z));
const zonesHeatingWithWindowOpen = (data, now) => zoneRows(data, now).filter((z) => ZONE_FLAGS.heatingWithWindowOpen(z, data.entries));

/* ── Security ────────────────────────────────────────────────────────────── */
const entriesInsecure = (data, now) => data.entries.filter((e) => ENTRY_FLAGS.insecure(e, data.house.alarm, now));
const entriesOpen = (entries) => entries.filter((e) => e.status === "open");
const entriesBlockingArming = (entries) => entries.filter((e) => ENTRY_FLAGS.wouldBlockArming(e));
/* Deliberately gone: camerasLive over the device list. "Watching" is three facts
   at once — online, recording, shutter open — and it is derived in cameraRows
   below, so every camera count in the product reads the same predicate. */

/* One sentence for the whole house, read by Today, the security screen and the
   shell's own summary. It names the predicate, never a count of mixed things. */
function securityVerdict(data, now) {
  const insecure = entriesInsecure(data, now);
  const cameras = camerasNotWatching(cameraRows(data));
  if (data.house.alarm === "triggered") return { tone: "danger", line: "The alarm is going", detail: "Sentinel Response has been notified." };
  if (data.house.alarm === "fault") return { tone: "danger", line: "The alarm has faulted", detail: "It is not watching the house until it is cleared." };
  if (insecure.length) {
    return {
      tone: "warning",
      line: insecure.length === 1 ? insecure[0].label + " is not secure" : insecure.length + " ways in are not secure",
      detail: insecure.map((e) => e.label + " — " + statusValue(ENTRY_STATUS, e.status).label.toLowerCase()).join(", ") + ".",
    };
  }
  if (cameras.length) return { tone: "info", line: "Everything is shut", detail: cameras.length + (cameras.length === 1 ? " camera is" : " cameras are") + " not watching." };
  return { tone: "success", line: "Everything is shut", detail: camerasWatching(cameraRows(data)).length + " cameras watching, alarm " + statusValue(ALARM_STATUS, data.house.alarm).label.toLowerCase() + "." };
}

/* ── Devices ─────────────────────────────────────────────────────────────── */
const devicesInHouse = (devices) => devices.filter((d) => d.status !== "retired");
const devicesNeedingYou = (devices) => devices.filter((d) => DEVICE_FLAGS.needsYou(d));
const devicesLowBattery = (devices) => devices.filter((d) => DEVICE_FLAGS.lowBattery(d));
const devicesOn = (devices) => devicesInHouse(devices).filter((d) => d.on && d.status === "online");
const devicesInRoom = (devices, room) => devices.filter((d) => d.room === room && d.status !== "retired");
const devicesDrawing = (devices) => devices.filter((d) => DEVICE_FLAGS.drawing(d));
/* Live draw is summed off the same array the rows render. */
const liveWatts = (devices) => devicesDrawing(devices).reduce((n, d) => n + d.watts, 0);
const wattsInRoom = (devices, room) => devicesInRoom(devices, room).filter((d) => DEVICE_FLAGS.drawing(d)).reduce((n, d) => n + d.watts, 0);

/* ── The link, and the connection test ───────────────────────────────────
   A device's readings are one record; everything a screen says about the link is
   derived from it. The counts here are what the test card reports, and each one
   names the array it filtered. */
const linkOf = (links, id) => links[id] || null;
const bandOf = (links, id) => signalBand((linkOf(links, id) || {}).dbm);
const linkRows = (data) => devicesInHouse(data.devices).map((d) => Object.assign({}, d, { link: linkOf(data.links, d.id), band: bandOf(data.links, d.id) }));
const devicesWeakSignal = (data) => linkRows(data).filter((d) => LINK_FLAGS.weak(d.link));
const devicesBehind = (data) => linkRows(data).filter((d) => LINK_FLAGS.behind(d.link));
const devicesUnanswered = (data) => linkRows(data).filter((d) => d.link && d.link.result === "fail");
const devicesSlow = (data) => linkRows(data).filter((d) => d.link && d.link.result === "slow");
const averageLatency = (data) => {
  const answered = linkRows(data).filter((d) => d.link && d.link.latencyMs != null);
  return answered.length ? Math.round(answered.reduce((n, d) => n + d.link.latencyMs, 0) / answered.length) : 0;
};

/* One sentence for the whole network, read by the devices screen and the test
   card. It names the predicate it counted, never a total of mixed things. */
function linkVerdict(data, now) {
  const rows = linkRows(data);
  const failed = devicesUnanswered(data);
  const slow = devicesSlow(data);
  const behind = devicesBehind(data);
  const stale = LINK_FLAGS.stale(data.hub, now);
  if (failed.length) return {
    tone: "danger",
    line: failed.length === 1 ? failed[0].name + " is not answering" : failed.length + " devices are not answering",
    detail: failed.length === 1
      ? "Last heard " + formatWhen(failed[0].lastSeenAt, now) + "."
      : failed.map((d) => d.name + " (last heard " + (d.lastSeenAt ? formatWhen(d.lastSeenAt, now) : "never") + ")").join(", ") + ".",
  };
  if (slow.length) return { tone: "warning", line: slow.length + (slow.length === 1 ? " device is" : " devices are") + " slow to answer", detail: "Everything else answered inside " + averageLatency(data) + "ms on average." };
  if (behind.length) return { tone: "info", line: "Everything answered", detail: behind.length + (behind.length === 1 ? " device is" : " devices are") + " behind on firmware." };
  if (stale) return { tone: "info", line: "Everything answered", detail: "The last test was " + formatWhen(data.hub.testedAt, now) + " — worth running again." };
  return { tone: "success", line: "All " + rows.length + " devices answered", detail: "Average " + averageLatency(data) + "ms, nothing behind on firmware." };
}

/* ── Cameras ─────────────────────────────────────────────────────────────
   A camera row is its own record joined to its device, so the live view, the
   tiles and the counts all read one array. Nothing stores "watching". */
function cameraRows(data) {
  return data.cameras.map((camera) => {
    const device = deviceById(data.devices, camera.deviceId);
    const row = Object.assign({}, camera, { device: device, id: camera.deviceId, name: device ? device.name : "A camera that has been removed", link: linkOf(data.links, camera.deviceId) });
    return Object.assign(row, { watching: CAMERA_FLAGS.watching(row), blind: CAMERA_FLAGS.blind(row), shuttered: CAMERA_FLAGS.shuttered(row), recessed: CAMERA_FLAGS.recessed(row) });
  });
}
const camerasWatching = (rows) => rows.filter((c) => c.watching);
const camerasNotWatching = (rows) => rows.filter((c) => !c.watching);
const camerasShuttered = (rows) => rows.filter((c) => c.shuttered);
const clipsNewestFirst = (clips) => clips.slice().sort((a, b) => b.at - a.at);
const clipsToday = (clips, now) => clipsNewestFirst(clips).filter((c) => new Date(c.at).toDateString() === new Date(now).toDateString());
const clipsFor = (clips, deviceId) => clipsNewestFirst(clips).filter((c) => c.deviceId === deviceId);
const clipsOfKind = (clips, kind) => clips.filter((c) => c.kind === kind);

/* Why the cameras are or are not covering the house — one sentence, over the
   same array the tiles render. */
function cameraVerdict(data, now) {
  const rows = cameraRows(data);
  const watching = camerasWatching(rows);
  const shuttered = camerasShuttered(rows);
  const blind = rows.filter((c) => c.blind);
  if (blind.length) return { tone: "warning", line: watching.length + " of " + rows.length + " cameras watching", detail: blind.map((c) => c.name + " — " + statusValue(DEVICE_STATUS, c.device ? c.device.status : "offline").label.toLowerCase()).join(", ") + (shuttered.length ? ". " + shuttered.map((c) => c.name).join(", ") + (shuttered.length === 1 ? " has its shutter" : " have their shutters") + " closed on purpose." : ".") };
  if (shuttered.length) return { tone: "info", line: watching.length + " of " + rows.length + " cameras watching", detail: shuttered.map((c) => c.name).join(", ") + " " + (shuttered.length === 1 ? "has its shutter" : "have their shutters") + " closed on purpose." };
  return { tone: "success", line: "All " + rows.length + " cameras watching", detail: clipsToday(data.clips, now).length + " clips today, kept " + DATA_PREFS.keepClipsDays + " days." };
}

/* ── Rooms ───────────────────────────────────────────────────────────────
Rooms with everything a room card renders, derived: its devices, what they are
   drawing, the zone it sits in and the temperature that zone reports. */
function roomRows(data, now) {
  const zones = zoneRows(data, now);
  return ROOMS.map((room) => {
    const devices = devicesInRoom(data.devices, room.value);
    const entry = data.entries.filter((e) => e.room === room.value);
    const zone = zones.find((z) => data.entries.some((e) => e.room === room.value && e.zone === z.id)) || null;
    return {
      room: room.value, label: room.label, devices: devices, entries: entry, zone: zone,
      on: devices.filter((d) => d.on && d.status === "online"),
      watts: wattsInRoom(data.devices, room.value),
      needsYou: devices.filter((d) => DEVICE_FLAGS.needsYou(d)),
      people: activeMembers(data.members).filter((m) => m.home && m.where === room.value),
      insecure: entry.filter((e) => ENTRY_FLAGS.insecure(e, data.house.alarm, now)),
    };
  });
}

/* ── Energy ──────────────────────────────────────────────────────────────
   CHAIN TWO — usage and the tariff to money. costOf is the only place a rate is
   applied, so no screen can invent a price. */
const tariffWindow = (id) => TARIFF.windows.find((w) => w.id === id);
function windowAt(now) {
  const minute = minutesOfDay(now);
  const inside = TARIFF.windows.filter((w) => w.from).find((w) => {
    const from = parseClock(w.from), to = parseClock(w.to);
    return from <= to ? minute >= from && minute < to : minute >= from || minute < to;
  });
  return inside || tariffWindow("standard");
}
function nextWindow(now) {
  const minute = minutesOfDay(now);
  const starts = TARIFF.windows.filter((w) => w.from).map((w) => ({ window: w, at: parseClock(w.from) }));
  const ahead = starts.filter((s) => s.at > minute).sort((a, b) => a.at - b.at)[0];
  const first = starts.slice().sort((a, b) => a.at - b.at)[0];
  const next = ahead || first;
  const minutesAway = ahead ? ahead.at - minute : 24 * 60 - minute + first.at;
  return { window: next.window, minutesAway: minutesAway, at: now + minutesAway * MINUTE_MS };
}
/* A day's cost: the off-peak share at the off-peak rate, the rest at standard,
   plus the standing charge. Peak is priced separately where a screen has the
   half-hourly detail; a day only carries its off-peak share. */
function costOf(kwh, offPeakShare) {
  const off = kwh * offPeakShare;
  const rest = kwh - off;
  return Math.round(off * tariffWindow("off_peak").pencePerKwh + rest * tariffWindow("standard").pencePerKwh + HOUSE_POLICY.standingChargePence);
}
const dayCost = (day) => costOf(day.kwh, day.offPeakShare);
const energyToday = (days) => days[days.length - 1];
const energyComplete = (days) => days.filter((d) => !d.partial);
const kwhSum = (days) => days.reduce((n, d) => n + d.kwh, 0);
const costSum = (days) => days.reduce((n, d) => n + dayCost(d), 0);
const solarSum = (days) => days.reduce((n, d) => n + d.solarKwh, 0);
const exportSum = (days) => days.reduce((n, d) => n + d.exportKwh, 0);
const exportValue = (days) => Math.round(exportSum(days) * TARIFF.exportPencePerKwh);
const averageDayKwh = (days) => (energyComplete(days).length ? kwhSum(energyComplete(days)) / energyComplete(days).length : 0);
const worstDay = (days) => energyComplete(days).slice().sort((a, b) => b.kwh - a.kwh)[0];

/* What the battery ought to be doing, derived from the tariff and its charge —
   a recommendation, not a stored mode. */
function batteryAdvice(battery, now) {
  const window = windowAt(now);
  const upcoming = nextWindow(now);
  const kwhToFull = Math.round(((100 - battery.chargePercent) / 100) * battery.capacityKwh * 10) / 10;
  if (window.id === "off_peak") return { action: "charge", line: "Charging now, in the off-peak window", detail: kwhToFull + " kWh to full at " + tariffWindow("off_peak").pencePerKwh + "p" };
  if (window.id === "peak") return { action: "discharge", line: "Running the house off the battery", detail: "Every kWh from the battery saves " + Math.round(tariffWindow("peak").pencePerKwh - tariffWindow("off_peak").pencePerKwh) + "p against the peak rate" };
  return { action: "hold", line: "Holding until the off-peak window", detail: upcoming.window.label.toLowerCase() + " opens in " + Math.floor(upcoming.minutesAway / 60) + "h " + (upcoming.minutesAway % 60) + "m · " + kwhToFull + " kWh to full" };
}

/* Where the electricity is going, now: rooms ranked by live draw, summed off the
   same device array the device table renders. */
function drawByRoom(devices) {
  return ROOMS.map((room) => ({ room: room.value, label: room.label, watts: wattsInRoom(devices, room.value), devices: devicesInRoom(devices, room.value).filter((d) => DEVICE_FLAGS.drawing(d)) }))
    .filter((row) => row.watts > 0)
    .sort((a, b) => b.watts - a.watts);
}

/* ── Water ───────────────────────────────────────────────────────────────
   A leak is derived: water that keeps moving when the house is asleep. */
const waterToday = (days) => days[days.length - 1];
const waterComplete = (days) => days.filter((d) => !d.partial);
const waterAverage = (days) => (waterComplete(days).length ? waterComplete(days).reduce((n, d) => n + d.litres, 0) / waterComplete(days).length : 0);
const OVERNIGHT_LEAK_LITRES = 20;
const leakDays = (days) => days.filter((d) => d.overnightLitres >= OVERNIGHT_LEAK_LITRES);
function leakVerdict(days) {
  const flagged = leakDays(days);
  if (!flagged.length) return null;
  const litres = flagged.reduce((n, d) => n + d.overnightLitres, 0);
  return { nights: flagged.length, litres: litres, since: flagged[0].at, perNight: Math.round(litres / flagged.length) };
}

/* ── Money ───────────────────────────────────────────────────────────────── */
const billsOpen = (bills) => bills.filter((b) => b.status === "due" || b.status === "estimated");
const billsDue = (bills) => bills.filter((b) => b.status === "due");
const billsOverdue = (bills, now) => bills.filter((b) => BILL_FLAGS.overdue(b, now));
const billsDisputed = (bills) => bills.filter((b) => b.status === "disputed");
const billsAboveForecast = (bills) => bills.filter((b) => BILL_FLAGS.aboveForecast(b));
const billsOutstanding = (bills) => billsOpen(bills).concat(billsDisputed(bills)).reduce((n, b) => n + b.amountMinor, 0);
const contractsActive = (contracts) => contracts.filter((c) => c.status === "active");
const contractsEndingSoon = (contracts, now) => contractsActive(contracts).filter((c) => c.endsAt && c.endsAt - now <= 60 * DAY_MS);
const monthlyCommitted = (contracts) => contractsActive(contracts).reduce((n, c) => n + c.monthlyMinor, 0);
/* What running the house costs a month: the contracts plus the last complete
   month of every recurring bill kind. */
function monthlyRunningCost(data) {
  const bills = BILL_KINDS.map((kind) => {
    const latest = data.bills.filter((b) => b.kind === kind.value && b.status === "paid").slice().sort((a, b) => b.issuedAt - a.issuedAt)[0];
    return latest ? { kind: kind.value, label: kind.label, minor: latest.kind === "insurance" ? Math.round(latest.amountMinor / 12) : latest.kind === "water" ? Math.round(latest.amountMinor / 3) : latest.amountMinor } : null;
  }).filter(Boolean);
  const contracts = monthlyCommitted(data.contracts);
  return { rows: bills, bills: bills.reduce((n, b) => n + b.minor, 0), contracts: contracts, total: bills.reduce((n, b) => n + b.minor, 0) + contracts };
}
const spendByKind = (bills) => BILL_KINDS.map((kind) => {
  const rows = bills.filter((b) => b.kind === kind.value);
  return { kind: kind.value, label: kind.label, rows: rows, minor: rows.reduce((n, b) => n + b.amountMinor, 0) };
}).filter((row) => row.rows.length).sort((a, b) => b.minor - a.minor);

/* ── Upkeep ──────────────────────────────────────────────────────────────
   A job's runtime hours come off its device, so a filter's due state is a fact
   about the machine rather than a date somebody typed. */
function jobRows(data, now) {
  return data.jobs.map((job) => {
    const device = job.deviceId ? deviceById(data.devices, job.deviceId) : null;
    const ranHours = job.lifeHours != null && device && device.runtimeHours != null ? device.runtimeHours : job.ranHours;
    const row = Object.assign({}, job, { ranHours: ranHours, device: device });
    return Object.assign(row, {
      overdue: JOB_FLAGS.overdue(row, now),
      dueSoon: JOB_FLAGS.dueSoon(row, now),
      spent: JOB_FLAGS.consumableSpent(row),
      lifeLeft: JOB_FLAGS.lifeLeft(row),
      needsYou: JOB_FLAGS.needsYou(row, now),
    });
  });
}
const jobsOpen = (rows) => rows.filter((j) => j.status !== "done" && j.status !== "cancelled");
const jobsOverdue = (rows) => jobsOpen(rows).filter((j) => j.overdue);
const jobsNeedingYou = (rows) => jobsOpen(rows).filter((j) => j.needsYou);
const jobsBooked = (rows) => rows.filter((j) => j.status === "scheduled" || j.status === "in_progress");
const jobsDone = (rows) => rows.filter((j) => j.status === "done");
const jobsAwaitingQuote = (rows) => rows.filter((j) => j.status === "quoted");
const committedSpend = (rows) => jobsOpen(rows).reduce((n, j) => n + (j.costMinor || j.quoteMinor || 0), 0);
const jobsForProvider = (rows, providerId) => rows.filter((j) => j.providerId === providerId);
const jobsForDevice = (rows, deviceId) => rows.filter((j) => j.deviceId === deviceId);

/* ── Access ──────────────────────────────────────────────────────────────── */
const grantsLive = (grants) => grants.filter((g) => g.status === "active" || g.status === "scheduled");
const grantsExpiringSoon = (grants, now) => grants.filter((g) => ACCESS_FLAGS.expiringSoon(g, now));
const grantsNeverUsed = (grants) => grants.filter((g) => ACCESS_FLAGS.neverUsed(g));
const grantsForEntry = (grants, entryId) => grantsLive(grants).filter((g) => g.entryIds.indexOf(entryId) > -1);
const grantsForProvider = (grants, providerId) => grants.filter((g) => g.providerId === providerId);

/* ── Routines ────────────────────────────────────────────────────────────
   A routine that names a person reads that person's own preference, so the time
   it runs at is derived and moves when they change it. */
function routineWhen(routine, data, now) {
  if (routine.trigger === "time" && routine.memberId) {
    const member = memberById(data.members, routine.memberId);
    return member ? member.prefs.bedtime : "—";
  }
  if (routine.trigger === "time" && routine.id === "rou_06") return tariffWindow("off_peak").from;
  if (routine.trigger === "sunset") return HOUSE_POLICY.darkAfter;
  return null;
}
function routineRows(data, now) {
  return data.routines.map((routine) => Object.assign({}, routine, {
    broken: ROUTINE_FLAGS.broken(routine, data.devices),
    needsYou: ROUTINE_FLAGS.needsYou(routine, data.devices),
    recessed: ROUTINE_FLAGS.recessed(routine),
    when: routineWhen(routine, data, now),
    missing: routine.deviceIds.filter((id) => !data.devices.some((d) => d.id === id && d.status !== "retired")),
  }));
}
const routinesRunning = (rows) => rows.filter((r) => r.status === "on" && !r.broken);
const routinesNeedingYou = (rows) => rows.filter((r) => r.needsYou);
const routinesPaused = (rows) => rows.filter((r) => r.status === "paused");
const routinesForDevice = (rows, deviceId) => rows.filter((r) => r.deviceIds.indexOf(deviceId) > -1);
const routinesForMember = (rows, memberId) => rows.filter((r) => r.memberId === memberId);

/* ── Requests and the log ────────────────────────────────────────────────── */
const requestsWaiting = (requests) => requests.filter((r) => REQUEST_FLAGS.waiting(r));
const requestsForMember = (requests, memberId) => requests.filter((r) => r.memberId === memberId);
const eventsNewestFirst = (events) => events.slice().sort((a, b) => b.at - a.at);
const eventsToday = (events, now) => events.filter((e) => new Date(e.at).toDateString() === new Date(now).toDateString());
const eventsOfKind = (events, kind) => events.filter((e) => e.kind === kind);
/* What an event is attached to, so the log can open it. */
function eventRoute(event) {
  const s = event.subject;
  if (s.type === "device") return "/devices/" + s.id;
  if (s.type === "job") return "/upkeep/" + s.id;
  if (s.type === "member") return "/people/" + s.id;
  if (s.type === "request") return "/people";
  if (s.type === "entry" || s.type === "house") return "/security";
  if (s.type === "zone") return "/climate";
  if (s.type === "routine") return "/routines";
  if (s.type === "grant") return "/access";
  if (s.type === "bill") return "/spend";
  return "/log";
}
const eventsForDevice = (events, deviceId) => eventsNewestFirst(events).filter((e) => (e.subject.type === "device" && e.subject.id === deviceId));
const eventsForMember = (events, memberId) => eventsNewestFirst(events).filter((e) => e.actorId === memberId || (e.subject.type === "member" && e.subject.id === memberId));
const eventsForJob = (events, jobId) => eventsNewestFirst(events).filter((e) => e.subject.type === "job" && e.subject.id === jobId);

/* ── Nav badges: one predicate over one array, recoverable by the reader ─── */
const HEARTH_NAV_BADGES = {
  devicesNeedingYou: { label: "devices needing you", count: (data) => devicesNeedingYou(data.devices).length },
  camerasBlind: { label: "cameras that cannot see", count: (data) => cameraRows(data).filter((c) => c.blind).length },
  entriesInsecure: { label: "ways in that are not secure", count: (data, now) => entriesInsecure(data, now).length },
  billsDue: { label: "bills to pay", count: (data) => billsDue(data.bills).length },
  jobsOverdue: { label: "jobs past their date", count: (data, now) => jobsOverdue(jobRows(data, now)).length },
  requestsWaiting: { label: "requests waiting on you", count: (data) => requestsWaiting(data.requests).length },
};

Object.assign(window, {
  memberById, memberName, deviceById, deviceName, providerById, providerName, entryById, roomName, zoneName, initialsOf,
  activeMembers, membersHome, membersAwake, membersAsleep, householdName, comfortTarget, comfortSetter, zoneTarget, zoneRows, zonesCallingForHeat, zonesHeatingWithWindowOpen,
  entriesInsecure, entriesOpen, entriesBlockingArming, securityVerdict,
  devicesInHouse, devicesNeedingYou, devicesLowBattery, devicesOn, devicesInRoom, devicesDrawing, liveWatts, wattsInRoom, roomRows,
  linkOf, bandOf, linkRows, devicesWeakSignal, devicesBehind, devicesUnanswered, devicesSlow, averageLatency, linkVerdict,
  cameraRows, camerasWatching, camerasNotWatching, camerasShuttered, cameraVerdict, clipsNewestFirst, clipsToday, clipsFor, clipsOfKind,
  tariffWindow, windowAt, nextWindow, costOf, dayCost, energyToday, energyComplete, kwhSum, costSum, solarSum, exportSum, exportValue, averageDayKwh, worstDay, batteryAdvice, drawByRoom,
  waterToday, waterComplete, waterAverage, leakDays, leakVerdict, OVERNIGHT_LEAK_LITRES,
  billsOpen, billsDue, billsOverdue, billsDisputed, billsAboveForecast, billsOutstanding, contractsActive, contractsEndingSoon, monthlyCommitted, monthlyRunningCost, spendByKind,
  jobRows, jobsOpen, jobsOverdue, jobsNeedingYou, jobsBooked, jobsDone, jobsAwaitingQuote, committedSpend, jobsForProvider, jobsForDevice,
  grantsLive, grantsExpiringSoon, grantsNeverUsed, grantsForEntry, grantsForProvider,
  routineWhen, routineRows, routinesRunning, routinesNeedingYou, routinesPaused, routinesForDevice, routinesForMember,
  requestsWaiting, requestsForMember, eventsNewestFirst, eventsToday, eventsOfKind, eventRoute, eventsForDevice, eventsForMember, eventsForJob,
  HEARTH_NAV_BADGES,
});

})();
</script>
<script type="text/babel" data-presets="react" data-file="lib/command-palette.js">
(function(){
/* App-scope search — copied per product, domain-free. This file owns the
   shortcut, the query, the empty state and the route check; the shell supplies
   the record groups. The field in the toolbar is read-only and opens this same
   overlay, so search is a palette, not a filter.

   The shortcut is suppressed while a modal is open (`blocked`): a palette over a
   staged dialog is two dialogs deep, and navigating from it would leave the
   dialog floating over a screen it does not belong to. The host passes whether
   something modal is up; Escape still closes the palette itself.

   Optional `footer`: one persistent row under the list, given the palette's own
   close function. It is not filtered by the query, so it is for a destination
   rather than a record — "see every result", "open the full list". Products
   without one pass nothing and get no row. */

const { Command } = window.OriginDesignSystem_7c998b;

function useCommandPalette(blocked) {
  const [open, setOpen] = React.useState(false);
  React.useEffect(() => { if (blocked) setOpen(false); }, [blocked]);
  React.useEffect(() => {
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        if (!blocked) setOpen((v) => !v);
      }
      if (e.key === "Escape") setOpen(false);
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [blocked]);
  return { open: open && !blocked, setOpen: (v) => setOpen(!!v && !blocked), bound: true };
}

function CommandPalette({ open, onClose, routes, recordGroups = [], onNavigate, placeholder, label, emptyMessage, footer }) {
  /* Route check: an item that leads nowhere is a bug in the caller, not a
     silent dead end. */
  React.useEffect(() => {
    if (!open) return;
    recordGroups.forEach((g) => g.items.forEach((i) => {
      if (i.route && !matchRoute(i.route, routes)) console.warn("[command-palette] item points at an unregistered route:", i.route);
    }));
  }, [open, recordGroups, routes]);

  if (!open) return null;

  const destinations = Object.values(routes)
    .filter((r) => !r.offSidebar)
    .map((r) => ({ value: r.path, label: r.label, route: r.path, shortcut: r.path === "/" ? null : null }));
  const groups = [{ label: "Go to", items: destinations }].concat(recordGroups);

  return (
    <>
      <div className="o-backdrop" onClick={onClose} />
      <div className="o-viewport">
        <div className="o-dialog fd-command" role="dialog" aria-modal="true" aria-label={label || "Search"}>
          <Command
            groups={groups}
            placeholder={placeholder || "Search…"}
            emptyMessage={emptyMessage || "Nothing matches that. Try a record or a destination."}
            onSelect={(item) => { if (item.route) onNavigate(item.route); onClose(); }}
          />
          {footer ? <div className="fd-command__foot">{typeof footer === "function" ? footer(onClose) : footer}</div> : null}
        </div>
      </div>
    </>
  );
}

Object.assign(window, { useCommandPalette, CommandPalette });

})();
</script>
<script type="text/babel" data-presets="react" data-file="lib/template-menu.js">
(function(){
/* Preview page menu — copied per product, domain-free, byte-identical across
   products. This is the template's own harness chrome, not part of any product
   shell: it exists so a reader who opens a template can reach every screen the
   route table registers, including the ones a running product would only reach
   from a real session — a record, an account screen, an expired reset link.

   Two things it deliberately owns, and why:
   · The page list is a projection of the product's route table (nav.js passes
     it through templatePages), so a route added anywhere shows up here without
     being retyped, and a record route with no fixture example is not offered.
   · The theme control. Auth screens render no shell (AGENTS §7.1), so on those
     screens the product has no theme control at all — and §7.3 wants dark mode
     verified on them too. Outside auth this sits beside the shell's own theme
     button on purpose: one belongs to the product, one to the harness, and the
     harness panel says "Template" at the top so the two do not read as a
     duplicated duty.

   It injects its own stylesheet once rather than adding rules to each product's
   kit.css, which is what keeps the eight copies identical. The one rule it
   reaches outside itself lifts the toast region so toasts stack above the
   button instead of burying it. */

const { Input } = window.OriginDesignSystem_7c998b;

const TEMPLATE_MENU_CSS = `
.tm-root{position:fixed;inset-block-end:var(--space-5);inset-inline-end:var(--space-5);z-index:80;display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-2);pointer-events:none}
.tm-root>*{pointer-events:auto}
.tm-fab{display:inline-flex;width:44px;height:44px;flex:none;align-items:center;justify-content:center;border:0;border-radius:var(--radius-full);background:var(--foreground);color:var(--background);box-shadow:var(--shadow-lg);cursor:pointer;opacity:0;transition:transform var(--duration-fast) ease,opacity var(--duration-normal) ease}
/* The fab is preview chrome, not product UI: it stays out of the way until the
   pointer is in the app, and is always present for the keyboard and while open. */
html:hover .tm-fab,.tm-root:focus-within .tm-fab,.tm-fab[aria-expanded="true"]{opacity:1}
.tm-fab:hover{transform:translateY(-1px)}
.tm-fab:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
.tm-panel{display:flex;width:21rem;max-width:calc(100vw - var(--space-6));max-height:min(70vh,32rem);flex-direction:column;overflow:hidden;border:1px solid var(--border);border-radius:var(--radius-xl);background:var(--popover);color:var(--popover-foreground);box-shadow:var(--shadow-lg)}
.tm-head{display:flex;align-items:center;gap:var(--space-2);border-bottom:1px solid var(--border);padding:var(--space-3)}
.tm-chip{border:1px solid var(--border);border-radius:var(--radius-full);padding:1px var(--space-1-5);font-family:var(--font-mono);font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-foreground)}
.tm-head__title{font-family:var(--font-heading);font-size:var(--text-sm);font-weight:var(--font-weight-semibold)}
.tm-head__count{margin-inline-start:auto;font-size:var(--text-xs);color:var(--muted-foreground)}
.tm-filter{padding:var(--space-2) var(--space-3)}
.tm-body{flex:1;min-height:0;overflow-y:auto;padding:0 var(--space-2) var(--space-2)}
.tm-group+.tm-group{margin-block-start:var(--space-2)}
.tm-group__label{padding:var(--space-1-5) var(--space-2);font-size:var(--text-xs);font-weight:var(--font-weight-medium);color:var(--muted-foreground)}
.tm-item{display:flex;width:100%;align-items:baseline;gap:var(--space-2);border:0;border-radius:var(--radius-lg);background:transparent;padding:var(--space-1-5) var(--space-2);text-align:left;font:inherit;color:inherit;cursor:pointer}
.tm-item:hover{background:var(--accent)}
.tm-item:focus-visible{outline:2px solid var(--ring);outline-offset:-2px}
.tm-item.is-active{background:var(--accent);color:var(--accent-foreground);font-weight:var(--font-weight-medium)}
.tm-item__label{flex:1;min-width:0;font-size:var(--text-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tm-item__path{flex:none;max-width:9rem;font-family:var(--font-mono);font-size:10px;color:var(--muted-foreground);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tm-empty{padding:var(--space-4) var(--space-2);font-size:var(--text-sm);color:var(--muted-foreground)}
.tm-foot{display:flex;align-items:center;gap:var(--space-2);border-top:1px solid var(--border);padding:var(--space-2-5) var(--space-3)}
.tm-foot__label{font-size:var(--text-xs);color:var(--muted-foreground)}
.tm-seg{display:flex;margin-inline-start:auto;gap:2px;border:1px solid var(--border);border-radius:var(--radius-md);padding:2px}
.tm-seg__btn{border:0;border-radius:calc(var(--radius-md) - 3px);background:transparent;padding:2px var(--space-2);font:inherit;font-size:var(--text-xs);color:var(--muted-foreground);cursor:pointer}
.tm-seg__btn.is-on{background:var(--accent);color:var(--accent-foreground)}
.o-toast-region{inset-block-end:calc(var(--space-5) + 44px + var(--space-2))}
@media (max-width:600px){.tm-root{inset-inline:var(--space-3);inset-block-end:var(--space-3)}.tm-panel{width:100%;max-width:none;max-height:64vh}.o-toast-region{inset-block-end:calc(var(--space-3) + 44px + var(--space-2))}}
`;

/* The route table read whole and grouped. `examples` maps a parameterised
   pattern to one concrete path from the fixture; a pattern with no example is
   left out rather than offered as a link that resolves nowhere. */
function templatePages(nav, authPaths, examples) {
  const destinations = [], records = [], settings = [], auth = [];
  /* An off-nav route may name its own group ("Create", "Reports"); a route in
     the nav may not, because there `group` is the sidebar's own sectioning. */
  const named = {}, order = [];
  const put = (label, item) => { if (!named[label]) { named[label] = []; order.push(label); } named[label].push(item); };
  nav.forEach((route) => {
    const parameterised = route.path.indexOf(":") > -1;
    const to = parameterised ? (examples || {})[route.path] : route.path;
    if (!to) return;
    const item = { to: to, label: route.label };
    if (authPaths.indexOf(route.path) > -1) auth.push(item);
    else if (!route.offSidebar) destinations.push(item);
    else if (route.group) put(route.group, item);
    else if (parameterised) records.push(item);
    else settings.push(item);
  });
  return [{ label: "Destinations", items: destinations }]
    .concat(order.map((label) => ({ label: label, items: named[label] })))
    .concat([
      { label: "Records", items: records },
      { label: "Account and settings", items: settings },
      { label: "Auth", items: auth },
    ])
    .filter((group) => group.items.length > 0);
}

function TemplateMenu({ product, path, onNavigate, groups, dark, onToggleTheme }) {
  const [open, setOpen] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const rootRef = React.useRef(null);

  React.useEffect(() => {
    if (document.getElementById("tm-styles")) return;
    const style = document.createElement("style");
    style.id = "tm-styles";
    style.textContent = TEMPLATE_MENU_CSS;
    document.head.appendChild(style);
  }, []);

  React.useEffect(() => {
    if (!open) return undefined;
    const away = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("mousedown", away);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", away); document.removeEventListener("keydown", onKey); };
  }, [open]);

  const total = groups.reduce((n, group) => n + group.items.length, 0);
  const q = query.trim().toLowerCase();
  const shown = groups
    .map((group) => ({ label: group.label, items: group.items.filter((item) => !q || (item.label + " " + item.to).toLowerCase().indexOf(q) > -1) }))
    .filter((group) => group.items.length > 0);
  const go = (to) => { onNavigate(to); setOpen(false); setQuery(""); };

  return (
    <div className="tm-root" ref={rootRef}>
      {open ? (
        <div className="tm-panel" role="dialog" aria-label={product + " — template pages"}>
          <div className="tm-head">
            <span className="tm-chip">Template</span>
            <span className="tm-head__title">{product}</span>
            <span className="tm-head__count">{total} pages</span>
          </div>
          <div className="tm-filter">
            <Input size="sm" value={query} onChange={(e) => setQuery(e.target.value)}
              aria-label="Filter pages" placeholder="Filter pages…" startAddon={<Icon name="search" size={14} />} />
          </div>
          <div className="tm-body">
            {shown.length ? shown.map((group) => (
              <div className="tm-group" key={group.label}>
                <div className="tm-group__label">{group.label}</div>
                {group.items.map((item) => {
                  const active = item.to === path;
                  return (
                    <button type="button" key={item.to} className={"tm-item" + (active ? " is-active" : "")}
                      aria-current={active ? "page" : undefined} onClick={() => go(item.to)}>
                      <span className="tm-item__label">{item.label}</span>
                      <span className="tm-item__path">{item.to}</span>
                    </button>
                  );
                })}
              </div>
            )) : <p className="tm-empty">No page matches “{query.trim()}”.</p>}
          </div>
          {onToggleTheme ? (
            <div className="tm-foot">
              <span className="tm-foot__label">Theme</span>
              <div className="tm-seg" role="group" aria-label="Theme">
                <button type="button" className={"tm-seg__btn" + (dark ? "" : " is-on")} aria-pressed={!dark} onClick={() => { if (dark) onToggleTheme(); }}>Light</button>
                <button type="button" className={"tm-seg__btn" + (dark ? " is-on" : "")} aria-pressed={!!dark} onClick={() => { if (!dark) onToggleTheme(); }}>Dark</button>
              </div>
            </div>
          ) : null}
        </div>
      ) : null}
      <button type="button" className="tm-fab" aria-expanded={open}
        aria-label={open ? "Close the template page menu" : "Template pages — " + total + " screens in " + product}
        onClick={() => setOpen(!open)}>
        <Icon name={open ? "x" : "layout-grid"} size={18} />
      </button>
    </div>
  );
}

Object.assign(window, { TemplateMenu, templatePages });

})();
</script>
<script type="text/babel" data-presets="react" data-file="HearthShell.jsx">
(function(){
/* Hearth shell — SIDEBAR shell, LABELLED mode, one navigation owner. The sidebar
   owns every top-level destination; the top bar is utility only and repeats
   nothing.
   Frame: one plane for the whole shell — --sidebar, the design system's light
   neutral — carrying a 264px WHITE sidebar panel inset --space-3 on all four
   sides, and a content column that paints nothing of its own where every region
   is a card: one hairline, --radius-2xl, one elevation step, never nested. The
   gap between cards is the only separator.
   Density: generous — 34px nav rows, 24px page gutters, 44px table rows.
   Active state: --sidebar-accent wash + foreground token + medium weight.
   Recession: --muted surface, muted text, no primary control, link and badge
   kept — a device updating or switched out of the house, and a paused routine.
   Corner and motion character: 16px cards, 10px controls, transitions only on
   width and background, never on a number.
   App-scope duties: search → top bar, a read-only field that opens the palette ·
   session and account → the sidebar footer, at every width · utility → top bar
   (notifications, theme) · page context → the page header.
   TEN destinations over two groups of five — this is a house with five people in
   it, not a department. The first group is what the house is DOING and what
   somebody standing in the hall reaches for; the second is what keeps it running.
   The four registers a household consults occasionally — Bills, Access,
   Providers, Log — hang off the destination they belong to rather than taking a
   line of their own; each is a registered route with a full screen, reached from
   a button on its parent and from the palette's Registers group.
   Primary content per destination:
     Today      — the house in one view: a verdict card, four figures, then the
                  two columns of what is happening and what wants a person
     Rooms      — canvas of room cards with live controls
     Cameras    — the live view first: one focused camera at 16:9 with its
                  controls, the others as tiles, the clips, and the shutters
     Climate    — chart-led: the derived comfort target, zones, and a day strip
                  per zone showing the schedule against now
     Security   — board: the alarm, then every way in as a row with its grants
     Devices    — the hub's verdict and a connection test, what the hub can hear
                  but has not taken in, what wants a person, then the whole list
     Routines   — three sections, each running its own predicate
     People     — canvas of people over the requests waiting on an answer
     Energy     — chart-led: fourteen days, the tariff windows as a day strip,
                  the battery, and where the draw is going right now
     Upkeep     — list of jobs, consumables measured in hours rather than dates
   Climate is the destination that would most plausibly have been a plain table
   and is not: the question a heating screen answers is "why is the house this
   temperature", which is a chain — who is home, who is awake, what each zone was
   told — and the strip shows the answer against the clock in one glance.
   Sign-in and the reset flow render with no shell at all (AGENTS §7.1). */

const {
  Button, Input, Kbd, Avatar, MenuItem, MenuGroupLabel,
  Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator,
  Toast, ToastRegion,
} = window.OriginDesignSystem_7c998b;

const HEARTH_MARK = (window.__resources["r008"]);
/* The product's name, which is what the brand block carries (§6.3) — the house it
   is running is named underneath it. */
const HEARTH_PRODUCT = "Hearth";

/* One declaration for the whole shell: 1280 labelled · 1024 icon rail, labels
   become tooltips · below 900 an off-canvas drawer carrying the labelled
   sidebar whole. */
function useShellMode() {
  const read = () => (window.innerWidth >= 1280 ? "expanded" : window.innerWidth >= 900 ? "rail" : "drawer");
  const [mode, setMode] = React.useState(read);
  React.useEffect(() => {
    const onResize = () => setMode(read());
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return mode;
}

function NavLink({ route, active, badge, rail, onNavigate }) {
  const button = (
    <button type="button" className="hh-link" aria-current={active ? "page" : undefined}
      aria-label={rail ? route.label + (badge && badge.count ? " — " + badge.count + " " + badge.label : "") : undefined}
      onClick={() => onNavigate(route.path)}>
      <Icon name={route.icon} size={17} />
      {rail ? null : <span className="hh-link__label">{route.label}</span>}
      {!rail && badge && badge.count > 0 ? <span className="hh-link__count" aria-label={badge.count + " " + badge.label}>{badge.count}</span> : null}
      {rail && badge && badge.count > 0 ? <span className="hh-dot" aria-hidden="true" /> : null}
    </button>
  );
  return rail ? <FloatingTip label={route.label + (badge && badge.count ? " · " + badge.count + " " + badge.label : "")}>{button}</FloatingTip> : button;
}

function Sidebar({ path, data, now, viewer, rail, onNavigate, onClose }) {
  return (
    <nav className="hh-side" data-rail={rail ? "true" : "false"} aria-label={HEARTH_PRODUCT + " — sections"}>
      <div className="hh-brand">
        <img className="hh-brand__mark" src={HEARTH_MARK} alt="" aria-hidden="true" width="26" height="26" />
        {rail ? null : (
          <span className="hh-brand__text">
            <span className="hh-brand__name">{HEARTH_PRODUCT}</span>
            <span className="hh-brand__sub">{householdName(data.members)} · {membersHome(data.members).length} home</span>
          </span>
        )}
      </div>
      <div className="hh-nav">
        {HEARTH_SIDEBAR.map((group) => (
          <div className="hh-group" key={group.label}>
            <span className="hh-group__label">{group.label}</span>
            {group.items.map((route) => {
              const spec = route.badge ? HEARTH_NAV_BADGES[route.badge] : null;
              return (
                <NavLink key={route.path} route={route} rail={rail}
                  active={route.path === path || (route.path !== "/" && path.indexOf(route.path + "/") === 0)}
                  badge={spec ? { count: spec.count(data, now), label: spec.label } : null}
                  onNavigate={(p) => { onNavigate(p); if (onClose) onClose(); }} />
              );
            })}
          </div>
        ))}
      </div>
      <div className="hh-side__foot">
        <AccountMenu rail={rail} viewer={viewer} onNavigate={(p) => { onNavigate(p); if (onClose) onClose(); }} />
      </div>
    </nav>
  );
}

function AccountMenu({ rail, viewer, onNavigate }) {
  const trigger = (
    <button type="button" className={"hh-account" + (rail ? " hh-account--rail" : "")} aria-label={"Account — " + viewer.name + ", " + labelOf(HOUSEHOLD_ROLES, viewer.role)}>
      <Avatar size={rail ? 30 : 34} src={viewer.avatar} alt="" fallback={initialsOf(viewer.name)} />
      {rail ? null : (
        <span className="hh-account__text">
          <span className="hh-account__name">{viewer.name}</span>
          <span className="hh-account__role">{labelOf(HOUSEHOLD_ROLES, viewer.role)}</span>
        </span>
      )}
    </button>
  );
  return (
    <Floating trigger={trigger} align="start" width="15rem" label="Account">
      {(close) => (
        <>
          <MenuGroupLabel>{viewer.name} · {labelOf(HOUSEHOLD_ROLES, viewer.role)}</MenuGroupLabel>
          {HEARTH_ACCOUNT_MENU.map((route) => (
            <MenuItem key={route.path} onClick={() => { onNavigate(route.path); close(); }}>{route.label}</MenuItem>
          ))}
          <MenuItem onClick={() => { onNavigate("/signin"); close(); }}>Sign out</MenuItem>
        </>
      )}
    </Floating>
  );
}

function NotificationsButton({ items, unread, onRead, onNavigate }) {
  const trigger = (
    <span className="hh-utility">
      <Button variant="ghost" size="icon-sm" aria-label={unread ? unread + " unread notifications" : "Notifications"}>
        <Icon name="bell" />
      </Button>
      {unread ? <span className="hh-badge-dot" aria-hidden="true">{unread}</span> : null}
    </span>
  );
  return (
    <FloatingTip label="Notifications" side="bottom">
      <Floating trigger={trigger} align="end" width="25rem" role="dialog" panelClass="o-popover hh-notify" label="Notifications">
        {(close) => (
          <>
            <div className="hh-notify__head">
              <span className="o-popover__title">Notifications</span>
              <Button variant="ghost" size="xs" onClick={onRead}>Mark all read</Button>
            </div>
            <div>
              {items.map((n) => (
                <button type="button" key={n.id} className="hh-notify__item" onClick={() => { onNavigate(n.route); close(); }}>
                  <span className="hh-notify__title">{n.title}</span>
                  <span className="hh-notify__body">{n.body}</span>
                  <span className="hh-notify__time" title={formatAbsolute(n.at)}>{formatWhen(n.at, HEARTH_NOW)}</span>
                </button>
              ))}
            </div>
          </>
        )}
      </Floating>
    </FloatingTip>
  );
}

function TopBar({ mode, dark, onToggleTheme, onOpenPalette, onOpenDrawer, notifications, unread, onRead, onNavigate }) {
  return (
    <div className="hh-topbar">
      {mode === "drawer" ? (
        <Button variant="ghost" size="icon-sm" aria-label="Open navigation" onClick={onOpenDrawer}><Icon name="menu" /></Button>
      ) : null}
      <div className="hh-search" onClick={onOpenPalette}>
        <Input readOnly size="sm" aria-label={"Search " + RESIDENCE.short}
          placeholder="Search devices, rooms, people, jobs…"
          startAddon={<Icon name="search" />} endAddon={<Kbd>⌘K</Kbd>} onFocus={onOpenPalette} />
      </div>
      <span className="hh-search-btn">
        <FloatingTip label="Search — ⌘K" side="bottom">
          <Button variant="ghost" size="icon-sm" aria-label={"Search " + RESIDENCE.short} onClick={onOpenPalette}><Icon name="search" /></Button>
        </FloatingTip>
      </span>
      <div className="hh-topbar__utility">
        <NotificationsButton items={notifications} unread={unread} onRead={onRead} onNavigate={onNavigate} />
        <FloatingTip label={dark ? "Switch to light" : "Switch to dark"} side="bottom">
          <Button variant="ghost" size="icon-sm" aria-label={dark ? "Switch to light theme" : "Switch to dark theme"} onClick={onToggleTheme}>
            <Icon name={dark ? "sun" : "moon"} />
          </Button>
        </FloatingTip>
      </div>
    </div>
  );
}

/* Every screen renders PageHead and none of them pass the router, so the shell
   registers it once per render and the crumbs read it from here. */
let crumbNavigate = null;

function PageHead({ path, title, lede, actions, params, crumb }) {
  const trail = breadcrumb(path, HEARTH_ROUTES).map((seg, i, all) => (
    crumb && i === all.length - 1 ? Object.assign({}, seg, { label: crumb }) : seg
  ));
  return (
    <header className="hh-head">
      <div>
        {trail.length > 1 ? (
          <Breadcrumb>
            <BreadcrumbList>
              {trail.map((seg, i) => (
                <React.Fragment key={seg.path}>
                  {i > 0 ? <BreadcrumbSeparator /> : null}
                  <BreadcrumbItem>
                    {i === trail.length - 1
                      ? <BreadcrumbPage>{seg.label}</BreadcrumbPage>
                      : seg.isNamespace ? <span>{seg.label}</span>
                        : <BreadcrumbLink href={"#" + resolvePath(seg.path, params || {})} onSelect={() => crumbNavigate && crumbNavigate(resolvePath(seg.path, params || {}))}>{seg.label}</BreadcrumbLink>}
                  </BreadcrumbItem>
                </React.Fragment>
              ))}
            </BreadcrumbList>
          </Breadcrumb>
        ) : null}
        <h1 className="hh-title">{title}</h1>
        {lede ? <p className="hh-lede">{lede}</p> : null}
      </div>
      {actions ? <div className="hh-actions">{actions}</div> : null}
    </header>
  );
}

function HearthShell({ path, onNavigate, dark, onToggleTheme, data, now, viewer, notifications, unread, onRead, palette, toasts, onDismissToast, children }) {
  crumbNavigate = onNavigate;
  const mode = useShellMode();
  const [drawer, setDrawer] = React.useState(false);
  React.useEffect(() => { if (mode !== "drawer") setDrawer(false); }, [mode]);

  const sidebar = (rail, onClose) => (
    <Sidebar path={path} data={data} now={now} viewer={viewer} rail={rail} onNavigate={onNavigate} onClose={onClose} />
  );

  return (
    <div className="hh-shell" data-mode={mode}>
      {mode === "drawer" ? (
        drawer ? (
          <>
            <div className="hh-scrim" onClick={() => setDrawer(false)} />
            <div className="hh-drawer">{sidebar(false, () => setDrawer(false))}</div>
          </>
        ) : null
      ) : sidebar(mode === "rail", null)}

      <main className="hh-main">
        <TopBar mode={mode} dark={dark} onToggleTheme={onToggleTheme}
          onOpenPalette={() => palette.setOpen(true)} onOpenDrawer={() => setDrawer(true)}
          notifications={notifications} unread={unread} onRead={onRead} onNavigate={onNavigate} />
        {children}
      </main>

      <ToastRegion>
        {toasts.map((t) => (
          <Toast key={t.id} variant={t.variant} title={t.title} description={t.description} onClose={() => onDismissToast(t.id)}
            action={t.onUndo ? <Button variant="outline" size="xs" onClick={() => { t.onUndo(); onDismissToast(t.id); }}>Undo</Button> : null} />
        ))}
      </ToastRegion>
    </div>
  );
}

Object.assign(window, { HearthShell, PageHead, AccountMenu, useShellMode, HEARTH_MARK, HEARTH_PRODUCT });

})();
</script>
<script type="text/babel" data-presets="react" data-file="HearthParts.jsx">
(function(){
/* Hearth — the parts every screen shares.
   A Card is the only surface in the product: one hairline, --radius-2xl, one
   elevation step, never nested. Nothing here holds data or assigns a status
   tone: StatusBadge does the tone, metrics.js does the arithmetic, enums.js
   decides which transitions exist. Reported in the README as local additions to
   the design system's inventory. */
(() => {
  const {
    Button, Input, Select, Checkbox, Switch, Textarea, Field, FieldLabel, FieldDescription, FieldError,
    Dialog, DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogPanel, DialogFooter,
    AlertDialog, AlertDialogPopup, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter,
    RadioGroup, MenuItem, MenuGroupLabel, Badge, Avatar, Slider,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
    Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
  } = window.OriginDesignSystem_7c998b;

  /* Money is pounds in this product, so the formatter lives here rather than in
     lib/ — currency is a product decision, and lib/ stays domain-free. */
  const poundsFormatter = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP", maximumFractionDigits: 0 });
  const poundsExactFormatter = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP", minimumFractionDigits: 2 });
  const formatPounds = (minor) => poundsFormatter.format((minor || 0) / 100);
  const formatPoundsExact = (minor) => poundsExactFormatter.format((minor || 0) / 100);
  const formatKwh = (kwh) => (Math.round(kwh * 10) / 10).toFixed(1);
  const formatTemp = (c) => (c == null ? "—" : (Math.round(c * 10) / 10).toFixed(1));
  const formatSince = (ts) => (ts ? new Date(ts).toLocaleDateString("en-GB", { month: "long", year: "numeric" }) : "—");
  const formatWatts = (w) => (w >= 1000 ? (w / 1000).toFixed(2) + " kW" : Math.round(w) + " W");

  /* ── Structure ─────────────────────────────────────────────────────────── */
  function Card({ title, note, actions, flush, tint, fill, children }) {
    return (
      <section className={"hh-card" + (flush ? " hh-card--flush" : "") + (tint ? " hh-card--tint" : "") + (fill ? " hh-card--fill" : "")}>
        {title || actions ? (
          <div className="hh-card__head">
            <div style={{ minWidth: 0 }}>
              <h2 className="hh-card__title">{title}</h2>
              {note ? <p className="hh-card__note">{note}</p> : null}
            </div>
            {actions ? <div className="hh-actions">{actions}</div> : null}
          </div>
        ) : null}
        {children}
      </section>
    );
  }

  /* A card in a band of its own, for a screen that wants one full-width region. */
  const Band = ({ children }) => <div className="hh-band">{children}</div>;

  /* Equal-weight metric tiles, so they may auto-fit (§5.1). Every one carries a
     qualifier that decomposes its own figure's set. */
  function Figures({ items }) {
    return (
      <div className="hh-figs">
        {items.map((item) => (
          <div key={item.label}>
            <span className="hh-fig__label">{item.label}</span>
            <span className="hh-fig">{item.value}{item.unit ? <span className="hh-fig__unit"> {item.unit}</span> : null}</span>
            <span className="hh-fig__qualifier">{item.qualifier}</span>
          </div>
        ))}
      </div>
    );
  }

  function Pairs({ items }) {
    return (
      <div className="hh-pairs">
        {items.map((item) => (
          <div className="hh-pair" key={item.label}>
            <span className="hh-pair__label">{item.label}</span>
            <span className="hh-pair__value">{item.value}</span>
          </div>
        ))}
      </div>
    );
  }

  function Rows({ children }) { return <div className="hh-rows">{children}</div>; }

  function Row({ title, note, meta, value, recessed, children }) {
    return (
      <div className={"hh-row" + (recessed ? " hh-row--recessed" : "")}>
        <div className="hh-row__text">
          <span className="hh-row__title">{title}</span>
          {note ? <span className="hh-row__note">{note}</span> : null}
          {meta ? <span className="hh-row__note">{meta}</span> : null}
        </div>
        {value != null ? <span className="hh-row__value">{value}</span> : null}
        {children}
      </div>
    );
  }

  /* ── The two signature geometries ──────────────────────────────────────── */
  function Bar({ percent, tone, label }) {
    return (
      <span className="hh-bar" role="img" aria-label={label}>
        <span className={"hh-bar__fill" + (tone ? " hh-bar__fill--" + tone : "")} style={{ width: Math.max(1.5, Math.min(100, percent)) + "%" }} />
      </span>
    );
  }

  /* A day series built from the same proportion: the dark part is what was drawn
     inside a cheap window, the soft part the rest. */
  function DayBars({ days, valueOf, softOf, tickOf, labelOf: labelFor, selected, onSelect }) {
    const max = Math.max.apply(null, days.map(valueOf));
    return (
      <div className="hh-bars">
        {days.map((day, index) => {
          const value = valueOf(day);
          const soft = softOf ? softOf(day) : 0;
          const height = max ? (value / max) * 100 : 0;
          const softPart = value ? (soft / value) * 100 : 0;
          const on = selected === index;
          return (
            <button type="button" key={day.at} className="hh-bars__col" data-on={on ? "true" : "false"}
              aria-label={labelFor(day)} aria-pressed={on} onClick={() => onSelect && onSelect(on ? null : index)}>
              <span className="hh-bars__stack" style={{ height: "80px" }}>
                <span className="hh-bars__part" style={{ height: Math.max(2, height - (height * softPart) / 100) + "%" }} />
                {soft ? <span className="hh-bars__part hh-bars__part--soft" style={{ height: (height * softPart) / 100 + "%" }} /> : null}
              </span>
              <span className="hh-bars__tick">{tickOf(day, index)}</span>
            </button>
          );
        })}
      </div>
    );
  }

  /* 24 cells, one per hour. levelOf returns 0–3, or a tone name for a window. */
  function DayStrip({ levelOf, toneOf, now, label, axis = true }) {
    const nowHour = new Date(now).getHours();
    return (
      <div>
        <div className="hh-strip" role="img" aria-label={label}>
          {Array.from({ length: 24 }, (_, hour) => (
            <span key={hour} className="hh-strip__cell"
              data-level={levelOf ? levelOf(hour) : undefined}
              data-tone={toneOf ? toneOf(hour) : undefined}
              data-now={hour === nowHour ? "true" : undefined}
              title={String(hour).padStart(2, "0") + ":00"} />
          ))}
        </div>
        {axis ? (
          <div className="hh-strip__axis" aria-hidden="true">
            <span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span>
          </div>
        ) : null}
      </div>
    );
  }

  function Legend({ items }) {
    return (
      <div className="hh-legend">
        {items.map((item) => (
          <span className="hh-legend__key" key={item.label}>
            <span className={"hh-legend__swatch" + (item.kind ? " hh-legend__swatch--" + item.kind : "")} />
            {item.label}
          </span>
        ))}
      </div>
    );
  }

  /* ── People and time ───────────────────────────────────────────────────── */
  function PersonCell({ name, meta, avatar, size = 30, onOpen, badge }) {
    return (
      <span className="hh-person">
        <Avatar size={size} src={avatar || undefined} alt={avatar ? "Photograph of " + name : ""} fallback={initialsOf(name)} />
        <span className="hh-person__text">
          <span className="hh-person__name">{onOpen ? <button type="button" className="hh-linkbtn" onClick={onOpen}>{name}</button> : name}</span>
          {meta ? <span className="hh-person__meta">{meta}</span> : null}
        </span>
        {badge || null}
      </span>
    );
  }

  /* One format rule per column (§8.3). `problem` is a property of the column,
     declared by the caller: whether a time in the past is a problem. */
  function Clock({ at, now, problem, dangerous }) {
    if (!at) return <span className="hh-muted">Never</span>;
    const past = at < now;
    const cls = past && problem ? (dangerous ? "hh-danger" : "hh-warn") : "";
    return <span className={cls} title={formatAbsolute(at)}>{formatWhen(at, now)}</span>;
  }

  function Battery({ level }) {
    if (level == null) return <span className="hh-muted">Mains</span>;
    const tone = level <= 0 ? "danger" : level <= HOUSE_POLICY.lowBatteryPercent ? "warn" : null;
    return (
      <span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-2)", minWidth: "5.5rem" }}>
        <span style={{ width: 34, display: "inline-block" }}><Bar percent={level} tone={tone} label={level + "% battery"} /></span>
        <span className={tone === "danger" ? "hh-danger" : tone === "warn" ? "hh-warn" : "hh-muted"} style={{ fontSize: "var(--text-xs)", fontVariantNumeric: "tabular-nums" }}>{level}%</span>
      </span>
    );
  }

  /* A verdict headline. The tone rides on a dotted badge above the sentence, not
     on the sentence's colour — so the strongest thing on the card is what it
     says, and the colour is one 6px dot doing one job. */
  function VerdictLine({ verdict, size }) {
    const spec = VERDICT_TONES.find((t) => t.id === verdict.tone) || VERDICT_TONES[2];
    return (
      <>
        <span className="hh-vdot" data-tone={verdict.tone}>{spec.label}</span>
        <p className="hh-card__title" style={{ marginTop: "var(--space-2)", fontSize: size || "var(--text-2xl)" }}>{verdict.line}</p>
      </>
    );
  }

  /* Signal is three bars off ONE derived band, so a bar, a word and a count can
     never disagree about what −68 dBm means. */
  function Signal({ dbm, compact }) {
    const band = signalBand(dbm);
    if (!band) return <span className="hh-muted">No answer</span>;
    const spec = SIGNAL_BANDS.find((b) => b.value === band);
    return (
      <span className="hh-signal" data-band={band} title={dbm + " dBm"}>
        <span className="hh-signal__bars" role="img" aria-label={spec.label + " signal, " + dbm + " dBm"}><i /><i /><i /></span>
        {compact ? null : <span className="hh-signal__text">{spec.label}</span>}
      </span>
    );
  }

  /* The live view. A template has no picture to show, so the plate names what the
     camera is pointed at rather than pretending — and it carries the overlay a
     real view would: whether it is live, the clock, and which camera this is. */
  function CameraView({ camera, now, tall, onClick, selected }) {
    const state = camera.blind ? "blind" : camera.privacy ? "shuttered" : "live";
    const label = state === "live" ? "Live" : state === "shuttered" ? "Shutter closed" : statusValue(DEVICE_STATUS, camera.device ? camera.device.status : "offline").label;
    const inner = (
      <>
        <span className="hh-view__top">
          <span className="hh-view__chip" data-live={state === "live" ? "true" : "false"}>{label}</span>
          {tall ? <span className="hh-view__clock">{formatAbsolute(now)}</span> : null}
        </span>
        <span className="hh-view__body"><Icon name={state === "live" ? "video" : "video-off"} size={tall ? 22 : 15} /><span>{camera.view}</span></span>
        <span className="hh-view__foot"><span className="hh-view__name">{camera.name}</span>{tall ? <span className="hh-view__res">{camera.resolution} · {roomName(camera.device ? camera.device.room : null)}</span> : null}</span>
      </>
    );
    if (!onClick) return <div className="hh-view" data-state={state} data-tall={tall ? "true" : "false"}>{inner}</div>;
    return (
      <button type="button" className="hh-view hh-view--pick" data-state={state} data-tall={tall ? "true" : "false"}
        aria-pressed={selected ? "true" : "false"} onClick={onClick}>{inner}</button>
    );
  }

  /* ── Actions ───────────────────────────────────────────────────────────── */
  function Transitions({ transitions, onRun, size = "sm" }) {
    if (!transitions.length) return null;
    return (
      <span className="hh-actions__row">
        {transitions.map((t) => {
          const button = (
            <Button key={t.id} size={size} disabled={!!t.disabled}
              variant={t.destructive ? "destructive-subtle" : t.primary ? "default" : "outline"}
              onClick={() => onRun(t)}>{t.label}</Button>
          );
          return t.disabled && t.disabledReason
            ? <FloatingTip key={t.id} label={t.disabledReason} side="bottom">{button}</FloatingTip>
            : button;
        })}
      </span>
    );
  }

  /* A row carries the transition its state asks for — the primary one — as a
     button. Everything else goes in the row menu: a list is not where an
     irreversible action belongs. */
  function RowActions({ transitions, onRun, label, extra }) {
    const primary = transitions.filter((t) => t.primary);
    const rest = transitions.filter((t) => !t.primary).concat(extra || []);
    if (!primary.length && !rest.length) return <span className="hh-muted">—</span>;
    return (
      <span className="hh-actions__row">
        {primary.length ? <Transitions transitions={primary} onRun={onRun} /> : null}
        {rest.length ? (
          <Floating align="end" width="15rem" label={label}
            trigger={<Button variant="ghost" size="icon-sm" aria-label={label}><Icon name="ellipsis" /></Button>}>
            {(close) => rest.map((t) => (
              <MenuItem key={t.id} disabled={!!t.disabled} variant={t.destructive ? "destructive" : "default"} onClick={() => { close(); onRun(t); }}>{t.label}</MenuItem>
            ))}
          </Floating>
        ) : null}
      </span>
    );
  }

  /* A device's control is whatever that kind of device actually has: a switch
     for anything that turns on, a lock button for a lock, nothing at all for a
     meter or a sensor. Switch means it applies immediately (§3). */
  function DeviceControl({ device, viewer, onToggle, onRun }) {
    if (DEVICE_FLAGS.recessed(device)) return <span className="hh-footnote">{device.status === "updating" ? "Updating — nothing to do" : "Out of the house"}</span>;
    if (DEVICE_FLAGS.needsYou(device)) {
      const transitions = deviceTransitions(device, viewer).filter((t) => t.primary);
      return <Transitions transitions={transitions} onRun={(t) => onRun(device, t)} />;
    }
    if (device.kind === "meter" || device.kind === "sensor") return <span className="hh-footnote">Reporting</span>;
    if (device.kind === "lock") {
      const may = ROLE_CAN_UNLOCK[viewer.role];
      const button = (
        <Button size="sm" variant={device.on ? "outline" : "default"} disabled={!may}
          onClick={() => onRun(device, { id: device.on ? "unlock" : "lock", label: device.on ? "Unlock" : "Lock" })}>
          {device.on ? "Unlock" : "Lock"}
        </Button>
      );
      return may ? button : <FloatingTip label={"Locks are an adult's to move. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "."} side="bottom">{button}</FloatingTip>;
    }
    return <Switch checked={!!device.on} aria-label={(device.on ? "Turn off " : "Turn on ") + device.name} onCheckedChange={() => onToggle(device)} />;
  }

  /* ── List furniture: same order on every list screen (§5) ───────────────── */
  function FilterBar({ query, onQuery, placeholder, selects, onClear, dirty }) {
    return (
      <div className="hh-filters">
        <div className="hh-filters__text">
          <Input size="sm" value={query} placeholder={placeholder} aria-label={placeholder} startAddon={<Icon name="search" />} onChange={(e) => onQuery(e.target.value)} />
        </div>
        {(selects || []).map((s) => (
          <span className="hh-filters__select" key={s.id}>
            <Select size="sm" items={s.items} value={s.value} aria-label={s.label} onValueChange={s.onChange} />
          </span>
        ))}
        {dirty ? <Button variant="ghost" size="sm" onClick={onClear}>Clear filters</Button> : null}
      </div>
    );
  }


  function ExportMenu({ scope, onExport, size = "sm" }) {
    return (
      <Floating align="end" width="13rem" label="Export"
        trigger={<Button variant="outline" size={size}><Icon name="download" />Export<Icon name="chevron-down" /></Button>}>
        {(close) => (
          <>
            <MenuGroupLabel>{scope}</MenuGroupLabel>
            {["CSV", "PDF"].map((format) => (
              <MenuItem key={format} onClick={() => { close(); onExport(format); }}>{format}</MenuItem>
            ))}
          </>
        )}
      </Floating>
    );
  }

  const TableWrap = ({ children }) => <div className="hh-tablewrap">{children}</div>;

  function TableFoot({ shown, total, noun, children }) {
    return (
      <div className="hh-tablefoot">
        <span className="hh-footnote">Showing {shown} of {total} {noun}</span>
        {children ? <span className="hh-actions__row">{children}</span> : null}
      </div>
    );
  }

  function NoResults({ noun, onClear }) {
    return (
      <div style={{ padding: "var(--space-6) var(--card-pad)" }}>
        <Empty>
          <EmptyHeader>
            <EmptyMedia variant="icon"><Icon name="search-x" size={18} /></EmptyMedia>
            <EmptyTitle>No {noun} match those filters</EmptyTitle>
            <EmptyDescription>Nothing in this list matches every filter at once. Clearing them brings the whole list back.</EmptyDescription>
          </EmptyHeader>
          <EmptyContent><Button onClick={onClear}>Clear filters</Button></EmptyContent>
        </Empty>
      </div>
    );
  }

  /* ── Dialogs ───────────────────────────────────────────────────────────── */
  function ConfirmDialog({ request, onCancel, onConfirm }) {
    return (
      <AlertDialog open={!!request} onOpenChange={(open) => { if (!open) onCancel(); }}>
        <AlertDialogPopup>
          <AlertDialogHeader>
            <AlertDialogTitle>{request ? request.title : ""}</AlertDialogTitle>
            <AlertDialogDescription>{request ? request.description : ""}</AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <Button variant="outline" onClick={onCancel}>Cancel</Button>
            <Button variant="destructive" onClick={onConfirm}>{request ? request.confirmLabel : ""}</Button>
          </AlertDialogFooter>
        </AlertDialogPopup>
      </AlertDialog>
    );
  }

  /* A staged dialog — its own commit model, so an instant screen may open one. */
  /* A staged dialog — its own commit model, so an instant screen may open one.
     A request may carry one set of fields, or `steps`: a named stage each with
     its own fields and its own validation, which is what pairing needs. Nothing
     is committed until the last stage is submitted. */
  function FormDialog({ request, onCancel, onSubmit }) {
    const [values, setValues] = React.useState({});
    const [touched, setTouched] = React.useState(false);
    const [submitted, setSubmitted] = React.useState(false);
    const [stage, setStage] = React.useState(0);
    React.useEffect(() => { setValues(request ? request.initial || {} : {}); setTouched(false); setSubmitted(false); setStage(0); }, [request]);
    if (!request) return null;
    const stages = request.steps || [{ title: request.title, description: request.description, fields: request.fields, validate: request.validate }];
    const current = stages[Math.min(stage, stages.length - 1)];
    const last = stage >= stages.length - 1;
    const errors = current.validate ? current.validate(values) : {};
    const valid = Object.keys(errors).length === 0;
    const set = (id, value) => { setTouched(true); setValues((v) => Object.assign({}, v, { [id]: value })); };
    const advance = () => { setSubmitted(true); if (!valid) return; if (last) return onSubmit(values); setStage((n) => n + 1); setSubmitted(false); };
    return (
      <Dialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
        <DialogPopup>
          <DialogHeader>
            <DialogTitle>{request.title}</DialogTitle>
            <DialogDescription>{current.description}</DialogDescription>
          </DialogHeader>
          <DialogPanel>
            {stages.length > 1 ? (
              <p className="hh-footnote" style={{ marginBottom: "var(--space-4)" }}>Step {stage + 1} of {stages.length} · {current.title}</p>
            ) : null}
            <div className="hh-fields hh-fields--one">
              {current.fields.filter((f) => !f.when || f.when(values)).map((field) => (
                <Field key={field.id}>
                  <FieldLabel htmlFor={"hh-f-" + field.id}>{field.label}</FieldLabel>
                  {field.kind === "radio" ? (
                    <RadioGroup name={"hh-f-" + field.id} items={field.options} value={values[field.id] || ""} onValueChange={(v) => set(field.id, v)} />
                  ) : field.kind === "select" ? (
                    <Select items={field.options} value={values[field.id] || ""} aria-label={field.label} onValueChange={(v) => set(field.id, v)} />
                  ) : field.kind === "text" ? (
                    <Textarea id={"hh-f-" + field.id} rows={3} placeholder={field.placeholder} value={values[field.id] || ""} invalid={submitted && !!errors[field.id]} onChange={(e) => set(field.id, e.target.value)} />
                  ) : field.kind === "checkbox" ? (
                    <span className="hh-person">
                      <Checkbox id={"hh-f-" + field.id} checked={!!values[field.id]} onCheckedChange={(v) => set(field.id, v)} />
                      <span className="hh-pair__value">{field.checkboxLabel}</span>
                    </span>
                  ) : field.kind === "note" ? (
                    <p className="hh-card__note" style={{ margin: 0 }}>{typeof field.body === "function" ? field.body(values) : field.body}</p>
                  ) : (
                    <Input id={"hh-f-" + field.id} placeholder={field.placeholder} value={values[field.id] || ""} invalid={submitted && !!errors[field.id]} onChange={(e) => set(field.id, e.target.value)} />
                  )}
                  {submitted && errors[field.id] ? <FieldError>{errors[field.id]}</FieldError> : field.hint ? <FieldDescription>{field.hint}</FieldDescription> : null}
                </Field>
              ))}
            </div>
          </DialogPanel>
          <DialogFooter>
            {stage > 0 ? <Button variant="outline" onClick={() => { setStage((n) => n - 1); setSubmitted(false); }}>Back</Button> : touched ? <Button variant="outline" onClick={onCancel}>Cancel</Button> : null}
            <Button disabled={!touched && stages.length === 1} onClick={advance}>{last ? request.submitLabel : current.nextLabel || "Next"}</Button>
          </DialogFooter>
        </DialogPopup>
      </Dialog>
    );
  }

  function FormBar({ dirty, note, onCancel, onSave, label }) {
    return (
      <div className="hh-formbar">
        <span className="hh-formbar__note">{note}</span>
        {dirty ? <Button variant="outline" onClick={onCancel}>Cancel</Button> : null}
        <Button disabled={!dirty} onClick={onSave}>{label}</Button>
      </div>
    );
  }

  function RouteNotBuilt({ path, onNavigate }) {
    return (
      <div style={{ padding: "var(--space-8) var(--gutter-page)" }}>
        <Empty>
          <EmptyHeader>
            <EmptyMedia variant="icon"><Icon name="signpost" size={18} /></EmptyMedia>
            <EmptyTitle>Nothing is registered at {path}</EmptyTitle>
            <EmptyDescription>Hearth registers ten destinations and the screens behind them. The route table decides what exists, so this address has no screen behind it.</EmptyDescription>
          </EmptyHeader>
          <EmptyContent><Button onClick={() => onNavigate("/")}>Go to Today</Button></EmptyContent>
        </Empty>
      </div>
    );
  }

  /* Sorting: one hook, one header control, one comparator. */
  function useSort(key, dir) {
    const [sort, setSort] = React.useState({ key: key, dir: dir || "desc" });
    const onSort = (next) => setSort((s) => (s.key === next ? { key: next, dir: s.dir === "asc" ? "desc" : "asc" } : { key: next, dir: "desc" }));
    return { sort: sort, onSort: onSort };
  }

  function SortHead({ label, sortKey, sort, onSort }) {
    const on = sort.key === sortKey;
    return (
      <button type="button" className="hh-sort" data-on={on ? "true" : "false"}
        aria-label={"Sort by " + label + (on && sort.dir === "asc" ? ", highest first" : ", lowest first")}
        onClick={() => onSort(sortKey)}>
        {label}
        <Icon name={on ? (sort.dir === "asc" ? "arrow-up" : "arrow-down") : "chevrons-up-down"} size={12} />
      </button>
    );
  }

  function sortRows(rows, sort, accessors) {
    const get = accessors[sort.key];
    if (!get) return rows;
    return rows.slice().sort((a, b) => {
      const av = get(a), bv = get(b);
      if (typeof av === "string" || typeof bv === "string") {
        const cmp = String(av == null ? "" : av).localeCompare(String(bv == null ? "" : bv));
        return sort.dir === "asc" ? cmp : -cmp;
      }
      const an = av == null ? -Infinity : av, bn = bv == null ? -Infinity : bv;
      return sort.dir === "asc" ? an - bn : bn - an;
    });
  }

  Object.assign(window, {
    formatPounds, formatPoundsExact, formatKwh, formatTemp, formatWatts, formatSince,
    Card, Band, Figures, Pairs, Rows, Row, Bar, DayBars, DayStrip, Legend,
    PersonCell, Clock, Battery, Signal, CameraView, VerdictLine, Transitions, RowActions, DeviceControl,
    FilterBar, ExportMenu, TableWrap, TableFoot, NoResults,
    ConfirmDialog, FormDialog, FormBar, RouteNotBuilt, useSort, SortHead, sortRows,
  });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="TodayScreen.jsx">
(function(){
/* Today — the house in one view. Commit model: INSTANT. Every control here
   applies on its own and raises an undo toast where the change is consequential;
   there is no Save on this screen.

   A verdict card first, because the question somebody opens this app with at
   seven in the evening is "is the house all right". Then four figures, then two
   column stacks: what the house is doing, and what wants a person. Every list
   runs its own predicate from metrics.js — none of them is a slice. */
(() => {
  const { Button, Badge, Alert, AlertTitle, AlertDescription, Avatar } = window.OriginDesignSystem_7c998b;

  function VerdictCard({ data, now, viewer, onNavigate, onArm }) {
    const verdict = securityVerdict(data, now);
    const insecure = entriesInsecure(data, now);
    const target = comfortTarget(data.members, now);
    const setter = comfortSetter(data.members, now);
    const home = membersHome(data.members);
    const window_ = windowAt(now);
    const upcoming = nextWindow(now);
    return (
      <Card>
        <div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", justifyContent: "space-between", gap: "var(--space-5)" }}>
          <div style={{ minWidth: 0, flex: "1 1 20rem" }}>
            <span className="hh-actions__row" style={{ marginBottom: "var(--space-2)" }}>
              <StatusBadge values={ALARM_STATUS} value={data.house.alarm} />
              <span className="hh-footnote">changed {formatWhen(data.house.alarmChangedAt, now)} by {memberName(data.members, data.house.alarmChangedById)}</span>
            </span>
            <VerdictLine verdict={verdict} />
            <p className="hh-card__note">{verdict.detail}</p>
            <span className="hh-actions__row" style={{ marginTop: "var(--space-4)" }}>
              {insecure.length ? <Button size="sm" onClick={() => onNavigate("/security")}>Open security</Button> : null}
              <Transitions transitions={armTransitions(data, viewer)} onRun={onArm} />
            </span>
          </div>
          <div className="hh-dial" style={{ flex: "0 1 14rem", alignItems: "flex-end", textAlign: "right" }}>
            <span className="hh-dial__reading">{formatTemp(zoneTarget(data.zones.find((z) => z.id === "ground"), data.members, now))}<span>°</span></span>
            <span className="hh-dial__target" style={{ textAlign: "right" }}>
              Holding {formatTemp(target.c)}° downstairs — {target.because}
              {setter ? <> · <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/people/" + setter.id)}>{setter.name.split(" ")[0]}'s preference</button></> : null}
            </span>
          </div>
        </div>
        <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-4)", marginTop: "var(--space-5)", paddingTop: "var(--space-4)", borderTop: "1px solid var(--border)" }}>
          <span className="hh-person" style={{ gap: "var(--space-2)" }}>
            {home.map((member) => (
              <FloatingTip key={member.id} label={member.name + " — " + (MEMBER_FLAGS.isAwake(member, now) ? "up" : "asleep") + (member.where ? ", " + roomName(member.where).toLowerCase() : "")} side="bottom">
                <span style={{ display: "inline-flex" }}><Avatar size={30} src={member.avatar} alt={member.name} fallback={initialsOf(member.name)} /></span>
              </FloatingTip>
            ))}
          </span>
          <span className="hh-footnote" style={{ flex: 1, minWidth: "12rem" }}>
            {home.length} home, {membersAwake(data.members, now).length} up · {activeMembers(data.members).length - home.length} out ·{" "}
            <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/people")}>the household</button>
          </span>
          <span className="hh-footnote">
            {window_.label} rate, {window_.pencePerKwh}p · {upcoming.window.label.toLowerCase()} in {Math.floor(upcoming.minutesAway / 60)}h {upcoming.minutesAway % 60}m
          </span>
        </div>
      </Card>
    );
  }

  /* Arming is a house-level transition, so it lives beside the house rather than
     in enums' per-entity sets: what is offered depends on the alarm and on
     whether anything is open. */
  function armTransitions(data, viewer) {
    const may = ROLE_CAN_ARM[viewer.role];
    const blocking = entriesBlockingArming(data.entries);
    const gate = (t) => Object.assign({}, t,
      !may ? { disabled: true, disabledReason: "Arming the house is an adult's decision. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." }
        : blocking.length && t.id !== "disarm" ? { disabled: true, disabledReason: blocking.map((e) => e.label).join(" and ") + " " + (blocking.length === 1 ? "is" : "are") + " not shut, so the alarm would fault immediately." } : {});
    if (data.house.alarm === "disarmed") return [gate({ id: "arm_home", label: "Arm — home", primary: true }), gate({ id: "arm_away", label: "Arm — away" })];
    if (data.house.alarm === "triggered") return [gate({ id: "disarm", label: "Disarm", primary: true }), { id: "call_response", label: "Call Sentinel Response" }];
    if (data.house.alarm === "fault") return [gate({ id: "clear_fault", label: "Clear the fault", primary: true })];
    return [gate({ id: "disarm", label: "Disarm", primary: true })];
  }

  function TodayScreen({ data, now, viewer, onNavigate, onArm, onToggleDevice, onDeviceTransition, onRequest, onJobTransition, onExport }) {
    const insecure = entriesInsecure(data, now);
    const today = energyToday(data.energy);
    const draw = drawByRoom(data.devices);
    const rooms = roomRows(data, now);
    const needy = devicesNeedingYou(data.devices);
    const waiting = requestsWaiting(data.requests);
    const jobs = jobRows(data, now);
    const overdue = jobsOverdue(jobs);
    const nextJob = jobsBooked(jobs).slice().sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0))[0];
    const routines = routineRows(data, now);
    const brokenRoutines = routinesNeedingYou(routines);
    const leak = leakVerdict(data.water);
    const advice = batteryAdvice(data.battery, now);
    const windowOpenZones = zonesHeatingWithWindowOpen(data, now);
    const events = eventsNewestFirst(data.events).slice(0, 6);

    return (
      <>
        <PageHead path="/" title="Today"
          lede={new Date(now).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" }) + " · " + RESIDENCE.name + " · dark since " + HOUSE_POLICY.darkAfter}
          actions={<ExportMenu scope={"Today at " + RESIDENCE.short} onExport={(format) => onExport("Today", "The house as it stands at " + new Date(now).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }), 1, format)} />} />

        <Band><VerdictCard data={data} now={now} viewer={viewer} onNavigate={onNavigate} onArm={onArm} /></Band>

        <Figures items={[
          { label: "Drawing now", value: formatWatts(liveWatts(data.devices)).split(" ")[0], unit: formatWatts(liveWatts(data.devices)).split(" ")[1], qualifier: devicesDrawing(data.devices).length + " devices on, most of it in the " + (draw[0] ? draw[0].label.toLowerCase() : "house") },
          { label: "Used today", value: formatKwh(today.kwh), unit: "kWh", qualifier: formatPounds(dayCost(today)) + " so far · " + Math.round(today.offPeakShare * 100) + "% of it off-peak" },
          { label: "Wants a person", value: needy.length + waiting.length + overdue.length, qualifier: needy.length + " devices, " + waiting.length + " requests, " + overdue.length + " jobs past their date" },
          { label: "Owed this month", value: formatPounds(billsOutstanding(data.bills)), qualifier: billsDue(data.bills).length + " bills to pay, " + billsOverdue(data.bills, now).length + " past due" },
        ]} />

        <div className="hh-cols hh-cols--wide">
          <div>
            <Card title="What the house is doing" note="Every device drawing power now, grouped by the room it is in.">
              <Rows>
                {draw.map((row) => (
                  <Row key={row.room} title={row.label} value={formatWatts(row.watts)}
                    note={row.devices.map((d) => d.name.toLowerCase()).join(", ")}>
                    <span style={{ width: "6rem" }}>
                      <Bar percent={(row.watts / Math.max(1, liveWatts(data.devices))) * 100} label={row.label + " share of the live draw"} />
                    </span>
                  </Row>
                ))}
              </Rows>
              <div style={{ marginTop: "var(--space-4)", paddingTop: "var(--space-3)", borderTop: "1px solid var(--border)" }} className="hh-footnote">
                {advice.line} — {advice.detail}. <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/energy")}>Energy</button>
              </div>
            </Card>

            <Card title={"Rooms with somebody in them (" + rooms.filter((r) => r.people.length).length + ")"}
              note="Read from where each person's phone last reported, against the devices in that room."
              actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/rooms")}>All rooms</Button>}>
              <Rows>
                {rooms.filter((room) => room.people.length).map((room) => (
                  <Row key={room.room} title={room.label}
                    note={room.people.map((p) => p.name.split(" ")[0] + (MEMBER_FLAGS.isAwake(p, now) ? "" : ", asleep")).join(" and ") + " · " + room.on.length + " of " + room.devices.length + " devices on"}
                    value={room.zone ? formatTemp(room.zone.reportedC) + "°" : null}>
                    <span className="hh-footnote">{formatWatts(room.watts)}</span>
                  </Row>
                ))}
              </Rows>
            </Card>

            <Card flush title={"Just now (" + events.length + " of " + data.events.length + ")"}
              note="The newest entries in the house log."
              actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/log")}>Whole log</Button>}>
              <div style={{ paddingInline: "var(--card-pad)" }}>
                <Rows>
                  {events.map((event) => (
                    <Row key={event.id}
                      title={<button type="button" className="hh-linkbtn" onClick={() => onNavigate(eventRoute(event))}>{event.line}</button>}
                      note={(event.actorId ? memberName(data.members, event.actorId) + " · " : "") + (event.note || labelOf(EVENT_KINDS, event.kind))}
                      value={<span className="hh-footnote" title={formatAbsolute(event.at)}>{formatWhen(event.at, now)}</span>} />
                  ))}
                </Rows>
              </div>
            </Card>
          </div>

          <div>
            {waiting.length ? (
              <Card title={"Waiting on you (" + waiting.length + ")"} note="Somebody in the house asked for something the rules do not let them do.">
                <Rows>
                  {waiting.map((request) => {
                    const member = memberById(data.members, request.memberId);
                    return (
                      <Row key={request.id}
                        title={<PersonCell name={member.name} avatar={member.avatar} size={26} meta={labelOf(HOUSEHOLD_ROLES, member.role) + " · asked " + formatWhen(request.askedAt, now)} onOpen={() => onNavigate("/people/" + member.id)} />}
                        note={"“" + request.body + "”"}>
                        <Transitions transitions={requestTransitions(request, viewer)} onRun={(t) => onRequest(request, t)} />
                      </Row>
                    );
                  })}
                </Rows>
              </Card>
            ) : null}

            {windowOpenZones.length ? (
              <Card tint>
                <Alert variant="warning">
                  <AlertTitle>{windowOpenZones.length === 1 ? "The " + zoneName(windowOpenZones[0].id).toLowerCase() + " is heating with a window open" : windowOpenZones.length + " zones are heating with a window open"}</AlertTitle>
                  <AlertDescription>
                    {windowOpenZones.map((zone) => zoneName(zone.id) + " is at " + formatTemp(zone.reportedC) + "° against " + formatTemp(zone.targetC) + "°, and " + data.entries.filter((e) => e.zone === zone.id && e.status === "open").map((e) => e.label.toLowerCase()).join(" and ") + " has been open " + formatWhen(data.entries.filter((e) => e.zone === zone.id && e.status === "open")[0].changedAt, now)).join(". ")}.
                  </AlertDescription>
                </Alert>
              </Card>
            ) : null}

            <Card title={"Devices needing you (" + needy.length + ")"}
              note="Offline, waiting to be set up, or on a flat battery."
              actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/devices")}>All devices</Button>}>
              {needy.length ? (
                <Rows>
                  {needy.map((device) => (
                    <Row key={device.id}
                      title={<button type="button" className="hh-linkbtn" onClick={() => onNavigate("/devices/" + device.id)}>{device.name}</button>}
                      note={roomName(device.room) + " · " + (device.status === "offline" ? "last reported " + formatWhen(device.lastSeenAt, now) : device.status === "setup_pending" ? "added " + formatWhen(device.addedAt, now) : "battery flat")}>
                      <StatusBadge values={DEVICE_STATUS} value={device.status} />
                      <DeviceControl device={device} viewer={viewer} onToggle={onToggleDevice} onRun={onDeviceTransition} />
                    </Row>
                  ))}
                </Rows>
              ) : <p className="hh-footnote">Every device in the house is reporting.</p>}
            </Card>

            <Card title="Upkeep" note="What is past its date, and what is booked next."
              actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/upkeep")}>All jobs</Button>}>
              <Rows>
                {overdue.map((job) => (
                  <Row key={job.id}
                    title={<button type="button" className="hh-linkbtn" onClick={() => onNavigate("/upkeep/" + job.id)}>{job.title}</button>}
                    note={job.spent ? job.device.name + " has run " + formatCount(job.ranHours) + " hours of a " + formatCount(job.lifeHours) + "-hour interval" : "Due " + formatWhen(job.dueAt, now) + " · " + formatCountdown(job.dueAt, now)}>
                    <Badge size="sm" variant="warning">{formatCountdown(job.dueAt, now)}</Badge>
                    <Transitions transitions={jobTransitions(job, viewer).filter((t) => t.primary)} onRun={(t) => onJobTransition(job, t)} />
                  </Row>
                ))}
                {nextJob ? (
                  <Row title={nextJob.title}
                    note={providerName(data.providers, nextJob.providerId) + " · " + formatWhen(nextJob.scheduledAt, now) + (nextJob.status === "in_progress" ? " — here now" : "")}
                    value={<StatusBadge values={JOB_STATUS} value={nextJob.status} />} />
                ) : null}
              </Rows>
            </Card>

            {leak ? (
              <Card title="Water" note="Water that keeps moving while the house is asleep is the only way a slow leak shows up.">
                <Rows>
                  <Row title={leak.perNight + " litres a night, " + leak.nights + " nights running"}
                    note={"Since " + formatWhen(leak.since, now) + " · " + leak.litres + " litres in total, against 4 to 6 on a normal night"}
                    value={<Badge size="sm" variant="warning">Check</Badge>} />
                </Rows>
                <div style={{ marginTop: "var(--space-3)" }}>
                  <Button variant="outline" size="sm" onClick={() => onNavigate("/energy")}>Water and energy</Button>
                </div>
              </Card>
            ) : null}

            {brokenRoutines.length ? (
              <Card title={"Routines that stopped working (" + brokenRoutines.length + ")"} note="Unfinished, or naming a device that is no longer in the house.">
                <Rows>
                  {brokenRoutines.map((routine) => (
                    <Row key={routine.id} title={routine.name}
                      note={routine.missing.length ? "Names " + routine.missing.map((id) => deviceName(data.devices, id)).join(" and ") + ", which is not in the house" : "Never finished — " + routine.note}>
                      <StatusBadge values={ROUTINE_STATUS} value={routine.status} />
                      <Button variant="outline" size="sm" onClick={() => onNavigate("/routines")}>Open</Button>
                    </Row>
                  ))}
                </Rows>
              </Card>
            ) : null}
          </div>
        </div>
      </>
    );
  }

  Object.assign(window, { TodayScreen, armTransitions, VerdictCard });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="RoomsScreen.jsx">
(function(){
/* Rooms — a canvas of room cards, each with the controls that room actually has.
   Commit model: INSTANT. A switch applies immediately; a lock raises an undo
   toast; nothing here has a Save.

   No /rooms/:id: this screen holds every room at once, which is what a house is,
   and a room has no fact a card cannot carry. */
(() => {
  const { Button, Badge, Switch, Select } = window.OriginDesignSystem_7c998b;

  function RoomCard({ room, data, now, viewer, onNavigate, onToggleDevice, onDeviceTransition, onZoneStep }) {
    const zone = room.zone;
    return (
      <Card fill>
        <div className="hh-room__top">
          <div style={{ minWidth: 0 }}>
            <div className="hh-room__name">{room.label}</div>
            <div className="hh-footnote">
              {room.devices.length ? room.on.length + " of " + room.devices.length + " on · " + formatWatts(room.watts) : "No devices in here"}
              {room.people.length ? " · " + room.people.map((p) => p.name.split(" ")[0]).join(" and ") + " here" : ""}
            </div>
          </div>
          {zone ? (
            <div style={{ textAlign: "right" }}>
              <div className="hh-room__temp">{formatTemp(zone.reportedC)}<span>°</span></div>
              <div className="hh-footnote">{zoneName(zone.id)} · {formatTemp(zone.targetC)}° wanted</div>
            </div>
          ) : null}
        </div>

        <Rows>
          {room.devices.map((device) => (
            <Row key={device.id} recessed={DEVICE_FLAGS.recessed(device)}
              title={<button type="button" className="hh-linkbtn" onClick={() => onNavigate("/devices/" + device.id)}>{device.name}</button>}
              note={labelOf(DEVICE_KINDS, device.kind) + (DEVICE_FLAGS.drawing(device) ? " · " + formatWatts(device.watts) : "") + (DEVICE_FLAGS.lowBattery(device) ? " · battery " + device.battery + "%" : "")}>
              {DEVICE_FLAGS.needsYou(device) ? <StatusBadge values={DEVICE_STATUS} value={device.status} /> : null}
              <DeviceControl device={device} viewer={viewer} onToggle={onToggleDevice} onRun={onDeviceTransition} />
            </Row>
          ))}
          {room.entries.map((entry) => (
            <Row key={entry.id} title={entry.label}
              note={labelOf(ENTRY_KINDS, entry.kind) + " · " + formatWhen(entry.changedAt, now) + (entry.byId ? " by " + memberName(data.members, entry.byId).split(" ")[0] : "")}>
              <StatusBadge values={ENTRY_STATUS} value={entry.status} />
              {ENTRY_FLAGS.insecure(entry, data.house.alarm, now) ? <Badge size="sm" variant="warning">Not secure</Badge> : null}
            </Row>
          ))}
        </Rows>

        {zone ? (
          <div className="hh-room__foot">
            <span className="hh-footnote">
              {zone.status === "off" ? "Heating off" : ZONE_FLAGS.callingForHeat(zone) ? "Calling for heat" : "At temperature"}
              {zone.overrideUntil ? " · boost until " + formatWhen(zone.overrideUntil, now) : ""}
            </span>
            <span className="hh-stepper">
              <Button variant="ghost" size="icon-xs" aria-label={"Cooler in the " + zoneName(zone.id).toLowerCase()} onClick={() => onZoneStep(zone, -0.5)}><Icon name="minus" size={14} /></Button>
              <span style={{ fontSize: "var(--text-xs)", fontVariantNumeric: "tabular-nums", minWidth: "2.6rem", textAlign: "center" }}>{formatTemp(zone.targetC)}°</span>
              <Button variant="ghost" size="icon-xs" aria-label={"Warmer in the " + zoneName(zone.id).toLowerCase()} onClick={() => onZoneStep(zone, 0.5)}><Icon name="plus" size={14} /></Button>
            </span>
          </div>
        ) : null}
      </Card>
    );
  }

  function RoomsScreen({ data, now, viewer, onNavigate, onToggleDevice, onDeviceTransition, onZoneStep, onAllOff, onNewDevice }) {
    const [only, setOnly] = React.useState("all");
    const rooms = roomRows(data, now);
    /* The filter narrows the canvas only; the lede states the scope in words. */
    const shown = only === "all" ? rooms
      : only === "on" ? rooms.filter((r) => r.on.length)
        : only === "people" ? rooms.filter((r) => r.people.length)
          : rooms.filter((r) => r.needsYou.length || r.insecure.length);
    const lit = rooms.filter((r) => r.on.length);

    return (
      <>
        <PageHead path="/rooms" title="Rooms"
          lede={lit.length + " of " + rooms.length + " rooms have something on, drawing " + formatWatts(liveWatts(data.devices)) + " between them."}
          actions={
            <>
              <span className="hh-filters__select">
                <Select size="sm" aria-label="Which rooms to show" value={only} onValueChange={setOnly}
                  items={[
                    { value: "all", label: "Every room (" + rooms.length + ")" },
                    { value: "on", label: "Something on (" + lit.length + ")" },
                    { value: "people", label: "Somebody in (" + rooms.filter((r) => r.people.length).length + ")" },
                    { value: "attention", label: "Wants a person (" + rooms.filter((r) => r.needsYou.length || r.insecure.length).length + ")" },
                  ]} />
              </span>
              <Button variant="outline" size="sm" onClick={onAllOff} disabled={!lit.length}>Everything off downstairs</Button>
              <Button size="sm" onClick={() => onNewDevice(null)}><Icon name="plus" />Pair a device</Button>
            </>
          } />
        {shown.length ? (
          <div className="hh-roomgrid">
            {shown.map((room) => (
              <RoomCard key={room.room} room={room} data={data} now={now} viewer={viewer}
                onNavigate={onNavigate} onToggleDevice={onToggleDevice} onDeviceTransition={onDeviceTransition} onZoneStep={onZoneStep} />
            ))}
          </div>
        ) : (
          <Band><Card><NoResults noun="rooms" onClear={() => setOnly("all")} /></Card></Band>
        )}
      </>
    );
  }

  Object.assign(window, { RoomsScreen, RoomCard });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="DeviceScreens.jsx">
(function(){
/* Devices — the kit and its radio: pair a new one, test the ones that are here,
   and fix the ones that want a person. Not a register of devices to browse; the
   list at the bottom is the last thing on the screen, not the first.

   Commit model: INSTANT on control, STAGED on pairing. A switch applies
   immediately, a consequential move raises an undo toast, removing a device
   confirms first (App.jsx), and taking a device INTO the house is a three-stage
   dialog — which is what pairing is: find it, place it, prove it answers.

   Everything the screen says about the radio is derived from one link record per
   device: weak, behind on firmware and stale are LINK_FLAGS, so a device becomes
   a problem the moment the reading changes and nothing is written on it.

   The tabs narrow the list only and the figures do not move with them (§5.3);
   the room filter is part of the same list container for the same reason. */
(() => {
  const {
    Button, Badge, Tabs, TabsList, TabsTab,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
  } = window.OriginDesignSystem_7c998b;

  const KIND_FILTER = [{ value: "all", label: "Every kind" }].concat(DEVICE_KINDS);
  const ROOM_FILTER = [{ value: "all", label: "Every room" }].concat(ROOMS);

  function DevicesScreen({ data, now, viewer, onNavigate, onToggleDevice, onDeviceTransition, onPairDevice, onRunHouseTest, onTestDevice, onUpdateFirmware, onExport }) {
    const [tab, setTab] = React.useState("all");
    const [query, setQuery] = React.useState("");
    const [kind, setKind] = React.useState("all");
    const [room, setRoom] = React.useState("all");
    const { sort, onSort } = useSort("name", "asc");

    const inHouse = devicesInHouse(data.devices);
    const needy = devicesNeedingYou(data.devices);
    const weak = devicesWeakSignal(data);
    const behind = devicesBehind(data);
    const retired = data.devices.filter((d) => d.status === "retired");
    const verdict = linkVerdict(data, now);
    const stale = LINK_FLAGS.stale(data.hub, now);
    const mayPair = ROLE_CAN_PAIR[viewer.role];

    const dirty = !!query || kind !== "all" || room !== "all";
    const clear = () => { setQuery(""); setKind("all"); setRoom("all"); };
    const base = tab === "all" ? inHouse : tab === "needs" ? needy : retired;
    const filtered = base.filter((device) => {
      if (kind !== "all" && device.kind !== kind) return false;
      if (room !== "all" && device.room !== room) return false;
      if (query && (device.name + " " + roomName(device.room) + " " + labelOf(DEVICE_KINDS, device.kind)).toLowerCase().indexOf(query.toLowerCase()) < 0) return false;
      return true;
    });
    const rows = sortRows(filtered, sort, {
      name: (d) => d.name,
      room: (d) => roomName(d.room),
      signal: (d) => { const link = linkOf(data.links, d.id); return link && link.dbm != null ? link.dbm : -999; },
      battery: (d) => (d.battery == null ? -1 : d.battery),
      seen: (d) => d.lastSeenAt,
    });

    return (
      <>
        <PageHead path="/devices" title="Devices"
          lede={inHouse.length + " devices on the house network, and one hub in the utility room. Everything on this screen — a weak signal, a firmware behind, a test gone stale — is read off the link rather than written on the device."}
          actions={
            <>
              <Button variant="outline" size="sm" onClick={onRunHouseTest}><Icon name="activity" />Test every device</Button>
              <ExportMenu scope={"All " + inHouse.length + " devices and their link readings"}
                onExport={(format) => onExport("Devices", "Every device in the house", inHouse.length, format)} />
              <Button size="sm" disabled={!mayPair} onClick={() => onPairDevice(null)}><Icon name="plus" />Pair a device</Button>
            </>
          } />

        <Band>
          <Card>
            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", justifyContent: "space-between", gap: "var(--space-5)" }}>
              <div style={{ minWidth: 0, flex: "1 1 22rem" }}>
                <span className="hh-actions__row" style={{ marginBottom: "var(--space-2)" }}>
                  <Badge size="sm" variant="secondary">Tested {formatWhen(data.hub.testedAt, now)}</Badge>
                  {stale ? <span className="hh-footnote">older than {HOUSE_POLICY.testStaleDays} days</span> : null}
                </span>
                <VerdictLine verdict={verdict} />
                <p className="hh-card__note">{verdict.detail}</p>
                <span className="hh-actions__row" style={{ marginTop: "var(--space-4)" }}>
                  <Button size="sm" onClick={onRunHouseTest}>Run the test again</Button>
                  {behind.length ? <Button variant="outline" size="sm" onClick={() => onUpdateFirmware(behind)}>Update {behind.length} devices</Button> : null}
                </span>
              </div>
              <div style={{ flex: "0 1 16rem", minWidth: 0 }}>
                <span className="hh-fig__label">The hub</span>
                <p className="hh-footnote" style={{ marginTop: "var(--space-2)" }}>
                  {data.hub.name}, {roomName(data.hub.room).toLowerCase()} · channel {data.hub.channel}<br />
                  Firmware {data.hub.firmware}{data.hub.firmware === data.hub.latest ? " — up to date" : " · " + data.hub.latest + " available"}<br />
                  Up {formatCount(data.hub.uptimeHours)} hours, average {averageLatency(data)}ms to answer.
                </p>
              </div>
            </div>
          </Card>
        </Band>

        <Figures items={[
          { label: "In the house", value: inHouse.length, qualifier: devicesOn(data.devices).length + " on now, drawing " + formatWatts(liveWatts(data.devices)) },
          { label: "Wanting a person", value: needy.length, qualifier: needy.map((d) => statusValue(DEVICE_STATUS, d.status).label.toLowerCase()).join(", ") || "nothing offline or unset" },
          { label: "Weak signal", value: weak.length, qualifier: weak.length ? weak.map((d) => d.name.toLowerCase()).join(", ") + " — under " + HOUSE_POLICY.weakSignalDbm + " dBm" : "everything above " + HOUSE_POLICY.weakSignalDbm + " dBm" },
          { label: "On battery", value: data.devices.filter((d) => d.battery != null).length, qualifier: devicesLowBattery(data.devices).length + " under " + HOUSE_POLICY.lowBatteryPercent + "%" + (devicesLowBattery(data.devices).length ? " — " + devicesLowBattery(data.devices).map((d) => d.name.toLowerCase()).join(", ") : "") + ", " + data.devices.filter((d) => d.battery === 0).length + " flat" },
        ]} />

        {data.discovered.length ? (
          <Band>
            <Card title={"Heard on the network (" + data.discovered.length + ")"}
              note="Devices the hub can hear that the house has not taken in. Pairing one starts from what it announced itself as, rather than from a blank form.">
              <Rows>
                {data.discovered.map((found) => (
                  <Row key={found.id} title={found.announced}
                    note={"Found " + formatWhen(found.foundAt, now) + " · suggests the " + roomName(found.suggestRoom).toLowerCase()}
                    value={
                      <span className="hh-actions__row">
                        <Signal dbm={found.dbm} />
                        <Button variant="outline" size="sm" disabled={!mayPair} onClick={() => onPairDevice(found)}>Pair</Button>
                      </span>
                    } />
                ))}
              </Rows>
            </Card>
          </Band>
        ) : null}

        {needy.length ? (
          <Band>
            <Card title={"Wanting a person (" + needy.length + ")"}
              note="One predicate — offline, never set up, or a flat battery. It is the same count the sidebar carries.">
              <Rows>
                {needy.map((device) => {
                  const link = linkOf(data.links, device.id);
                  return (
                    <Row key={device.id} title={device.name}
                      note={roomName(device.room) + " · " + (device.status === "offline" ? "last heard " + formatWhen(device.lastSeenAt, now) + ", the test got no answer" : device.status === "setup_pending" ? "added " + formatSince(device.addedAt) + " and never set up" : "battery flat")}
                      value={
                        <span className="hh-actions__row">
                          {link && link.result !== "untested" ? <StatusBadge values={TEST_STATUS} value={link.result} /> : null}
                          <Transitions transitions={deviceTransitions(device, viewer).filter((t) => t.primary)} onRun={(t) => onDeviceTransition(device, t)} />
                          {device.status === "offline" ? <Button variant="ghost" size="sm" onClick={() => onTestDevice(device)}>Test</Button> : null}
                        </span>
                      } />
                  );
                })}
              </Rows>
            </Card>
          </Band>
        ) : null}

        <Band>
          <Card flush title="Everything on the network" note="The whole list, for when you want a particular thing rather than a problem.">
            <div style={{ padding: "0 var(--card-pad) var(--space-4)" }}>
              <Tabs value={tab} onValueChange={setTab}>
                <TabsList>
                  <TabsTab value="all" size="sm">In the house ({inHouse.length})</TabsTab>
                  <TabsTab value="needs" size="sm">Wanting a person ({needy.length})</TabsTab>
                  <TabsTab value="retired" size="sm">Out of the house ({retired.length})</TabsTab>
                </TabsList>
              </Tabs>
            </div>
            <FilterBar query={query} onQuery={setQuery} placeholder="Search a device, a room or a kind…" dirty={dirty} onClear={clear}
              selects={[
                { id: "kind", label: "Kind", items: KIND_FILTER, value: kind, onChange: setKind },
                { id: "room", label: "Room", items: ROOM_FILTER, value: room, onChange: setRoom },
              ]} />
            {rows.length === 0 ? <NoResults noun="devices" onClear={clear} /> : (
              <>
                <TableWrap>
                  <Table>
                    <TableHeader>
                      <TableRow>
                        <TableHead><SortHead label="Device" sortKey="name" sort={sort} onSort={onSort} /></TableHead>
                        <TableHead><SortHead label="Room" sortKey="room" sort={sort} onSort={onSort} /></TableHead>
                        <TableHead><SortHead label="Signal" sortKey="signal" sort={sort} onSort={onSort} /></TableHead>
                        <TableHead>Last test</TableHead>
                        <TableHead><SortHead label="Battery" sortKey="battery" sort={sort} onSort={onSort} /></TableHead>
                        <TableHead><SortHead label="Last heard" sortKey="seen" sort={sort} onSort={onSort} /></TableHead>
                        <TableHead>Control</TableHead>
                      </TableRow>
                    </TableHeader>
                    <TableBody>
                      {rows.map((device) => {
                        const link = linkOf(data.links, device.id);
                        return (
                          <TableRow key={device.id} className={DEVICE_FLAGS.recessed(device) ? "hh-row--recessed" : undefined}>
                            <TableCell>
                              <span className="hh-person__text">
                                <span className="hh-person__name"><button type="button" className="hh-linkbtn" onClick={() => onNavigate("/devices/" + device.id)}>{device.name}</button></span>
                                <span className="hh-person__meta">{labelOf(DEVICE_KINDS, device.kind)}{link && LINK_FLAGS.behind(link) ? " · firmware " + link.firmware + " of " + link.latest : ""}</span>
                              </span>
                            </TableCell>
                            <TableCell>{roomName(device.room)}</TableCell>
                            <TableCell><Signal dbm={link ? link.dbm : null} /></TableCell>
                            <TableCell>
                              {link && link.result !== "pass"
                                ? <span className="hh-actions__row"><StatusBadge values={TEST_STATUS} value={link.result} />{device.status === "online" || device.status === "offline" ? <Button variant="ghost" size="sm" onClick={() => onTestDevice(device)}>Test</Button> : null}</span>
                                : <span className="hh-footnote">{link && link.latencyMs != null ? link.latencyMs + "ms" : "—"}</span>}
                            </TableCell>
                            <TableCell><Battery level={device.battery} /></TableCell>
                            <TableCell><Clock at={device.lastSeenAt} now={now} problem={device.status === "offline"} /></TableCell>
                            <TableCell><DeviceControl device={device} viewer={viewer} onToggle={onToggleDevice} onRun={onDeviceTransition} /></TableCell>
                          </TableRow>
                        );
                      })}
                    </TableBody>
                  </Table>
                </TableWrap>
                <TableFoot shown={rows.length} total={base.length} noun="devices" />
              </>
            )}
          </Card>
        </Band>
      </>
    );
  }

  function DeviceScreen({ data, now, viewer, deviceId, onNavigate, onToggleDevice, onDeviceTransition, onTestDevice, onUpdateFirmware, onExport }) {
    const device = deviceById(data.devices, deviceId);
    const link = linkOf(data.links, device.id);
    const routines = routinesForDevice(routineRows(data, now), device.id);
    const jobs = jobsForDevice(jobRows(data, now), device.id);
    const events = eventsForDevice(data.events, device.id);
    const entry = data.entries.find((e) => e.deviceId === device.id) || null;
    const share = liveWatts(data.devices) ? (device.watts / liveWatts(data.devices)) * 100 : 0;
    const consumable = jobs.find((j) => j.lifeHours != null);

    return (
      <>
        <PageHead path="/devices/:id" params={{ id: device.id }} crumb={device.name} title={device.name}
          lede={labelOf(DEVICE_KINDS, device.kind) + " in the " + roomName(device.room).toLowerCase() + ", in the house since " + formatSince(device.addedAt) + "."}
          actions={
            <>
              <Button variant="outline" size="sm" onClick={() => onTestDevice(device)} disabled={device.status !== "online" && device.status !== "offline"}><Icon name="activity" />Test the connection</Button>
              <ExportMenu scope={"This device and its " + events.length + " log entries"} onExport={(format) => onExport("Device", device.name, 1, format)} />
            </>
          } />

        <Band>
          <Card>
            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: "var(--space-5)" }}>
              <div style={{ minWidth: 0 }}>
                <span className="hh-actions__row">
                  <StatusBadge values={DEVICE_STATUS} value={device.status} />
                  {DEVICE_FLAGS.lowBattery(device) ? <Badge size="sm" variant="warning">Battery {device.battery}%</Badge> : null}
                  {entry ? <Badge size="sm" variant="secondary">{entry.label}</Badge> : null}
                </span>
                <p className="hh-card__note" style={{ marginTop: "var(--space-2)" }}>
                  {DEVICE_FLAGS.recessed(device)
                    ? device.status === "updating" ? "It is updating itself. Nothing can be asked of it until it finishes." : "It has been switched out of the house, so nothing acts on it — and the routines that name it are broken until it comes back."
                    : DEVICE_FLAGS.drawing(device) ? "Drawing " + formatWatts(device.watts) + ", which is " + Math.round(share) + "% of everything the house is using right now."
                      : device.kind === "sensor" || device.kind === "meter" ? "Reporting to the house; nothing to switch." : "On standby."}
                </p>
              </div>
              <Transitions transitions={deviceTransitions(device, viewer)} onRun={(t) => onDeviceTransition(device, t)} />
            </div>
          </Card>
        </Band>

        <Figures items={[
          { label: "Drawing now", value: DEVICE_FLAGS.drawing(device) ? formatWatts(device.watts).split(" ")[0] : "0", unit: DEVICE_FLAGS.drawing(device) ? formatWatts(device.watts).split(" ")[1] : "W", qualifier: DEVICE_FLAGS.drawing(device) ? Math.round(share) + "% of the live draw" : "nothing while it is off" },
          { label: "Battery", value: device.battery == null ? "Mains" : device.battery + "%", qualifier: device.battery == null ? "no battery to change" : device.battery === 0 ? "flat — it stopped reporting " + formatWhen(device.lastSeenAt, now) : "changed when it drops under " + HOUSE_POLICY.lowBatteryPercent + "%" },
          { label: "Signal", value: link && link.dbm != null ? link.dbm : "—", unit: link && link.dbm != null ? "dBm" : null, qualifier: link && link.dbm != null ? labelOf(SIGNAL_BANDS, signalBand(link.dbm)) + ", answered in " + link.latencyMs + "ms" : "it did not answer the last test" },
          { label: "Routines naming it", value: routines.length, qualifier: routines.filter((r) => r.broken).length + " of them are broken, " + routinesRunning(routines).length + " running" },
        ]} />

        <div className="hh-cols">
          <div>
            <Card title="Record">
              <Pairs items={[
                { label: "Kind", value: labelOf(DEVICE_KINDS, device.kind) },
                { label: "Room", value: <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/rooms")}>{roomName(device.room)}</button> },
                { label: "Reporting", value: <StatusBadge values={DEVICE_STATUS} value={device.status} /> },
                { label: "Last heard from", value: <Clock at={device.lastSeenAt} now={now} problem={device.status === "offline"} /> },
                { label: "Added", value: formatSince(device.addedAt) },
                { label: "Ways in it holds", value: entry ? entry.label + " — " + statusValue(ENTRY_STATUS, entry.status).label.toLowerCase() : "None" },
              ]} />
            </Card>

            <Card title="The connection" note="What the hub read when it last asked. Whether that answer is stale is worked out from the clock, not stored.">
              <Pairs items={[
                { label: "Signal", value: <Signal dbm={link ? link.dbm : null} /> },
                { label: "Last test", value: link ? <StatusBadge values={TEST_STATUS} value={link.result} /> : "Never tested" },
                { label: "Time to answer", value: link && link.latencyMs != null ? link.latencyMs + "ms, against " + averageLatency(data) + "ms across the house" : "No answer" },
                { label: "Tested", value: link && link.testedAt ? formatWhen(link.testedAt, now) + (LINK_FLAGS.stale(link, now) ? " — worth running again" : "") : "Never" },
                {
                  label: "Firmware",
                  value: link && link.firmware
                    ? (LINK_FLAGS.behind(link)
                      ? <span className="hh-actions__row"><span>{link.firmware} of {link.latest}</span><Button variant="outline" size="sm" onClick={() => onUpdateFirmware([device])}>Update</Button></span>
                      : link.firmware + " — up to date")
                    : "Not known until it is set up",
                },
              ]} />
            </Card>

            {consumable ? (
              <Card title="Consumable" note="Measured in the hours the machine has actually run, not in months.">
                <Rows>
                  <Row title={consumable.title}
                    note={formatCount(consumable.ranHours) + " hours run of " + formatCount(consumable.lifeHours) + " · " + (consumable.spent ? formatCount(consumable.ranHours - consumable.lifeHours) + " hours past the change" : Math.round(consumable.lifeLeft * 100) + "% of the interval left")}
                    value={<Button variant="outline" size="sm" onClick={() => onNavigate("/upkeep/" + consumable.id)}>Open job</Button>} />
                </Rows>
                <div style={{ marginTop: "var(--space-3)" }}>
                  <Bar percent={Math.max(0, (consumable.lifeLeft || 0) * 100)} tone={consumable.spent ? "danger" : consumable.lifeLeft < 0.2 ? "warn" : null} label="Filter life left" />
                </div>
              </Card>
            ) : null}
          </div>

          <div>
            <Card title={"Routines naming it (" + routines.length + ")"}
              note="A routine that names a device which is no longer in the house is broken, and says so without anything being written on it."
              actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/routines")}>All routines</Button>}>
              {routines.length ? (
                <Rows>
                  {routines.map((routine) => (
                    <Row key={routine.id} recessed={routine.recessed} title={routine.name}
                      note={routine.broken ? "Broken — it names " + routine.missing.map((id) => deviceName(data.devices, id)).join(" and ") : labelOf(TRIGGER_KINDS, routine.trigger) + (routine.when ? " · " + routine.when : "") + " · last ran " + formatWhen(routine.lastRunAt, now)}>
                      <StatusBadge values={ROUTINE_STATUS} value={routine.status} />
                      {routine.broken ? <Badge size="sm" variant="warning">Broken</Badge> : null}
                    </Row>
                  ))}
                </Rows>
              ) : <p className="hh-footnote">No routine names this device.</p>}
            </Card>

            <Card title={"In the log (" + events.length + ")"} note="Everything the house recorded about this device.">
              {events.length ? (
                <Rows>
                  {events.map((event) => (
                    <Row key={event.id} title={event.line} note={event.note || labelOf(EVENT_KINDS, event.kind)}
                      value={<span className="hh-footnote" title={formatAbsolute(event.at)}>{formatWhen(event.at, now)}</span>} />
                  ))}
                </Rows>
              ) : <p className="hh-footnote">Nothing about this device has reached the log yet.</p>}
            </Card>
          </div>
        </div>
      </>
    );
  }

  Object.assign(window, { DevicesScreen, DeviceScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="CamerasScreen.jsx">
(function(){
/* Cameras — the live view, then the four cameras, then what they caught.
   Commit model: INSTANT. A shutter, a floodlight and a clip apply immediately
   and raise an undo toast where the move is consequential; commissioning a
   camera that has never been set up is the pairing flow, opened from Devices.

   The focused camera is a STATE of this screen, not a route (§nav.js): a camera
   has no fact a card cannot carry, so /cameras/:id would be a screen invented to
   make a link resolve.

   Watching is derived from three facts at once — online, recording, shutter open
   — and CAMERA_FLAGS.watching is the one predicate the tile, the verdict, the
   count and the nav badge all read. Nothing stores it.

   Role: the live view and the clips are an adult's. A child sees the screen and
   is told why it is empty rather than being shown a wall of disabled buttons. */
(() => {
  const { Button, Badge, Switch } = window.OriginDesignSystem_7c998b;

  function CamerasScreen({ data, now, viewer, onNavigate, onCameraAction, onCameraPrivacy, onDeviceTransition, onExport }) {
    const rows = cameraRows(data);
    const may = ROLE_CAN_WATCH[viewer.role];
    const [focusId, setFocusId] = React.useState(() => (camerasWatching(rows)[0] || rows[0]).id);
    const focus = rows.find((c) => c.id === focusId) || rows[0];
    const verdict = cameraVerdict(data, now);
    const watching = camerasWatching(rows);
    const today = clipsToday(data.clips, now);
    const clips = clipsNewestFirst(data.clips);

    return (
      <>
        <PageHead path="/cameras" title="Cameras"
          lede={rows.length + " cameras on the outside of the house. A camera counts as watching only when it is online, set to record and its shutter is open — so a closed shutter reads as a decision somebody made, not a fault."}
          actions={
            <>
              <Button variant="outline" size="sm" onClick={() => onNavigate("/settings/data")}><Icon name="clock" />Clips and privacy</Button>
              <ExportMenu scope={clips.length + " clips from the last " + DATA_PREFS.keepClipsDays + " days"}
                onExport={(format) => onExport("Clips", "What the cameras caught", clips.length, format)} />
            </>
          } />

        <Band>
          <Card>
            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", justifyContent: "space-between", gap: "var(--space-5)" }}>
              <div style={{ minWidth: 0, flex: "1 1 20rem" }}>
                <VerdictLine verdict={verdict} />
                <p className="hh-card__note">{verdict.detail}</p>
              </div>
              <div style={{ flex: "0 1 15rem", minWidth: 0 }}>
                <span className="hh-fig__label">Caught today</span>
                <p className="hh-footnote" style={{ marginTop: "var(--space-2)" }}>
                  {today.length} clips, the newest {today.length ? formatWhen(today[0].at, now) : "— nothing yet"}.<br />
                  Kept {DATA_PREFS.keepClipsDays} days, then deleted.
                </p>
              </div>
            </div>
          </Card>
        </Band>

        {may ? (
          <div className="hh-cols hh-cols--wide">
            <div>
              <Card title={focus.name} note={focus.view + " · " + focus.resolution + (focus.nightVision ? " · night vision" : "") + " · " + focus.clipSeconds + "-second clips"}>
                <CameraView camera={focus} now={now} tall />
                <div style={{ marginTop: "var(--space-4)", display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: "var(--space-3)" }}>
                  <Transitions transitions={cameraTransitions(focus, viewer)} onRun={(t) => onCameraAction(focus, t)} />
                  <span className="hh-footnote">
                    {focus.blind ? "Nothing is being recorded from this camera." : focus.privacy ? "The shutter is closed, so nothing is recorded until it is opened." : "Recording motion. " + clipsFor(data.clips, focus.id).length + " clips kept from this camera."}
                  </span>
                </div>
              </Card>

              <Card title="Every camera" note="Pick one to watch it. A camera that is not answering falls back to the plain plane rather than showing a picture it does not have.">
                <div className="hh-views">
                  {rows.map((camera) => (
                    <CameraView key={camera.id} camera={camera} now={now} selected={camera.id === focus.id} onClick={() => setFocusId(camera.id)} />
                  ))}
                </div>
              </Card>
            </div>

            <div>
              <Card flush title={"Caught today (" + today.length + " of " + clips.length + ")"} note="Motion the cameras classified themselves.">
                <div style={{ paddingInline: "var(--card-pad)" }}>
                  <Rows>
                    {clips.slice(0, 6).map((clip) => {
                      const camera = rows.find((c) => c.id === clip.deviceId);
                      return (
                        <Row key={clip.id} title={labelOf(CLIP_KINDS, clip.kind)} note={clip.note}
                          value={
                            <span className="hh-actions__row">
                              <span className="hh-footnote" title={formatAbsolute(clip.at)}>{formatWhen(clip.at, now)} · {clip.seconds}s</span>
                              <Button variant="outline" size="sm" onClick={() => { if (camera) setFocusId(camera.id); }}>{camera ? camera.name.split(" ")[0] : "Camera"}</Button>
                            </span>
                          } />
                      );
                    })}
                  </Rows>
                </div>
                <TableFoot shown={Math.min(6, clips.length)} total={clips.length} noun="clips" />
              </Card>

              <Card title="Shutters" note="A closed shutter stops the camera recording anything at all. It is a household decision, so it sits here rather than in settings.">
                <Rows>
                  {rows.map((camera) => (
                    <Row key={camera.id} recessed={camera.recessed} title={camera.name}
                      note={camera.blind ? statusValue(DEVICE_STATUS, camera.device ? camera.device.status : "offline").label + " — the shutter cannot be moved" : camera.privacy ? "Closed" : "Open, recording motion"}
                      value={
                        camera.blind
                          ? <Button variant="outline" size="sm" onClick={() => onNavigate("/devices/" + camera.id)}>Open device</Button>
                          : <Switch checked={!camera.privacy} aria-label={"Shutter open on " + camera.name} onCheckedChange={() => onCameraPrivacy(camera)} />
                      } />
                  ))}
                </Rows>
              </Card>

              <Card tint title="What the house keeps">
                <Pairs items={[
                  { label: "Clips kept", value: DATA_PREFS.keepClipsDays + " days" },
                  { label: "Cameras watching", value: watching.length + " of " + rows.length },
                  { label: "Shutters closed", value: camerasShuttered(rows).length ? camerasShuttered(rows).map((c) => c.name).join(", ") : "None" },
                  { label: "Shared with providers", value: DATA_PREFS.shareWithProviders ? <span>Yes — <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/settings/data")}>change</button></span> : "No" },
                ]} />
              </Card>
            </div>
          </div>
        ) : (
          <Band>
            <Card tint title="The live view is an adult's">
              <p className="hh-card__note">
                You are signed in as {labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase()}, and the household's rule is that the cameras and their clips are for the adults. {watching.length} of {rows.length} cameras are watching the outside of the house.
              </p>
              <div style={{ marginTop: "var(--space-4)" }}>
                <Button variant="outline" size="sm" onClick={() => onNavigate("/people/" + viewer.id)}>Your standing in the household</Button>
              </div>
            </Card>
          </Band>
        )}
      </>
    );
  }

  Object.assign(window, { CamerasScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="ClimateScreen.jsx">
(function(){
/* Climate — why the house is this temperature.
   Commit model: INSTANT. Stepping a zone applies immediately with an undo toast;
   boosting is instant; nothing here has a Save.

   Chart-led rather than tabular, because the answer is a chain — who is home,
   who is awake, what each zone was told — and the day strip shows it against the
   clock in one glance. The comfort target is derived in metrics.comfortTarget
   and nothing on this screen stores it. */
(() => {
  const { Button, Badge, Alert, AlertTitle, AlertDescription, Avatar, Switch } = window.OriginDesignSystem_7c998b;

  /* The schedule strip: a zone's own weekday pattern, drawn as a level per hour
     against the derived target. Levels rather than temperatures, because the
     temperature itself is the chain — the strip says when, the dial says what. */
  const SCHEDULES = {
    ground: { warm: [[6, 9], [16, 23]], label: "Mornings and evenings" },
    first: { warm: [[6, 8], [20, 23]], label: "Getting up and going to bed" },
    study: { warm: [[8, 18]], label: "The working day" },
    water: { warm: [[5, 7], [17, 20]], label: "Two heats a day" },
    annexe: { warm: [], label: "Frost protection only" },
  };
  const scheduleLevel = (zoneId, hour) => {
    const schedule = SCHEDULES[zoneId];
    if (!schedule) return 0;
    return schedule.warm.some((span) => hour >= span[0] && hour < span[1]) ? 3 : 1;
  };

  function ZoneCard({ zone, data, now, viewer, onZoneStep, onZoneMode, onNavigate }) {
    const windowOpen = data.entries.filter((e) => e.zone === zone.id && e.status === "open");
    const heatingOpen = ZONE_FLAGS.heatingWithWindowOpen(zone, data.entries);
    const rooms = ROOMS.filter((room) => data.entries.some((e) => e.room === room.value && e.zone === zone.id));
    return (
      <Card fill>
        <div className="hh-room__top">
          <div style={{ minWidth: 0 }}>
            <div className="hh-room__name">{zoneName(zone.id)}</div>
            <div className="hh-footnote">
              {zone.radiators ? zone.radiators + " radiators" : "Cylinder"}
              {rooms.length ? " · " + rooms.map((r) => r.label.toLowerCase()).join(", ") : ""}
            </div>
          </div>
          <div style={{ textAlign: "right" }}>
            <div className="hh-room__temp">{formatTemp(zone.reportedC)}<span>°</span></div>
            <div className="hh-footnote">{zone.targetC == null ? "off" : formatTemp(zone.targetC) + "° wanted"}</div>
          </div>
        </div>

        <span className="hh-actions__row" style={{ marginBottom: "var(--space-3)" }}>
          <StatusBadge values={ZONE_STATUS} value={zone.status} />
          {ZONE_FLAGS.callingForHeat(zone) ? <Badge size="sm" variant="info">Calling for heat</Badge> : null}
          {zone.overrideUntil ? <Badge size="sm" variant="warning">Boost until {formatWhen(zone.overrideUntil, now)}</Badge> : null}
          {heatingOpen ? <Badge size="sm" variant="warning">Window open</Badge> : null}
        </span>

        <DayStrip levelOf={(hour) => scheduleLevel(zone.id, hour)} now={now}
          label={zoneName(zone.id) + " schedule — " + SCHEDULES[zone.id].label.toLowerCase()} />
        <p className="hh-footnote" style={{ marginTop: "var(--space-2)" }}>{SCHEDULES[zone.id].label} · the mark is now</p>

        <div className="hh-room__foot">
          <span className="hh-actions__row">
            {zone.status === "off"
              ? <Button size="sm" variant="outline" onClick={() => onZoneMode(zone, "comfort")}>Turn on</Button>
              : <Button size="sm" variant="outline" onClick={() => onZoneMode(zone, zone.status === "boost" ? "comfort" : "boost")}>{zone.status === "boost" ? "End boost" : "Boost " + HOUSE_POLICY.boostMinutes + " min"}</Button>}
            {windowOpen.length ? <Button size="sm" variant="ghost" onClick={() => onNavigate("/security")}>See the window</Button> : null}
          </span>
          {zone.targetC != null ? (
            <span className="hh-stepper">
              <Button variant="ghost" size="icon-xs" aria-label={"Cooler in " + zoneName(zone.id)} onClick={() => onZoneStep(zone, -0.5)}><Icon name="minus" size={14} /></Button>
              <span style={{ fontSize: "var(--text-xs)", fontVariantNumeric: "tabular-nums", minWidth: "2.6rem", textAlign: "center" }}>{formatTemp(zone.targetC)}°</span>
              <Button variant="ghost" size="icon-xs" aria-label={"Warmer in " + zoneName(zone.id)} onClick={() => onZoneStep(zone, 0.5)}><Icon name="plus" size={14} /></Button>
            </span>
          ) : null}
        </div>
      </Card>
    );
  }

  function ClimateScreen({ data, now, viewer, onNavigate, onZoneStep, onZoneMode, onEditPreference, onExport }) {
    const zones = zoneRows(data, now);
    const target = comfortTarget(data.members, now);
    const setter = comfortSetter(data.members, now);
    const calling = zonesCallingForHeat(data, now);
    const openZones = zonesHeatingWithWindowOpen(data, now);
    const awake = membersAwake(data.members, now);
    const asleep = membersAsleep(data.members, now);
    const heating = data.devices.filter((d) => (d.kind === "thermostat" || d.id === "dev_10") && d.status === "online");
    const heatingWatts = heating.filter((d) => DEVICE_FLAGS.drawing(d)).reduce((n, d) => n + d.watts, 0);

    return (
      <>
        <PageHead path="/climate" title="Climate"
          lede={"The house holds the warmest comfort preference among the people who are home and awake. Right now that is " + formatTemp(target.c) + "° — " + target.because + "."}
          actions={<ExportMenu scope={"All " + zones.length + " zones and their schedules"} onExport={(format) => onExport("Climate", "Zones and schedules", zones.length, format)} />} />

        <Band>
          <Card>
            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", justifyContent: "space-between", gap: "var(--space-6)" }}>
              <div style={{ flex: "1 1 26rem", minWidth: 0 }}>
                <h2 className="hh-card__title">Where that number comes from</h2>
                <p className="hh-card__note">
                  Nothing stores it. It is the warmest comfort preference among the people who are home and awake; with everybody asleep the house drops to {formatTemp(HOUSE_POLICY.nightSetbackC)}°, and with nobody home to {formatTemp(HOUSE_POLICY.awaySetbackC)}°. Change somebody's preference and every zone on this screen moves.
                </p>
              </div>
              <div className="hh-dial" style={{ flex: "0 1 12rem", alignItems: "flex-end", textAlign: "right" }}>
                <span className="hh-dial__reading">{formatTemp(target.c)}<span>°</span></span>
                <span className="hh-dial__target" style={{ textAlign: "right" }}>what the house is holding</span>
              </div>
            </div>
            {/* The household in two columns: seven people down one column is a list
                to scan, and the same seven in two is a set to read. */}
            <div className="hh-split" style={{ marginTop: "var(--space-5)", paddingTop: "var(--space-4)", borderTop: "1px solid var(--border)" }}>
              {[0, 1].map((half) => {
                const people = activeMembers(data.members);
                const size = Math.ceil(people.length / 2);
                const slice = people.slice(half * size, half * size + size);
                if (!slice.length) return null;
                return (
                  <Rows key={half}>
                    {slice.map((member) => {
                      const counts = MEMBER_FLAGS.countsForComfort(member, now);
                      const isSetter = setter && setter.id === member.id;
                      return (
                        <Row key={member.id} recessed={!counts}
                          title={<PersonCell name={member.name} avatar={member.avatar} size={26}
                            meta={(member.home ? (MEMBER_FLAGS.isAwake(member, now) ? "home and up" : "home, asleep since " + member.prefs.bedtime) : "out") + " · wants " + formatTemp(member.prefs.comfortC) + "°"}
                            onOpen={() => onNavigate("/people/" + member.id)} />}
                          value={isSetter ? <Badge size="sm" variant="info">Setting the target</Badge> : counts ? <span className="hh-footnote">counts</span> : <span className="hh-footnote">ignored</span>}>
                          <Button variant="ghost" size="xs" onClick={() => onEditPreference(member)}>Change</Button>
                        </Row>
                      );
                    })}
                  </Rows>
                );
              })}
            </div>
          </Card>
        </Band>

        <Figures items={[
          { label: "Zones calling for heat", value: calling.length, unit: "of " + zones.length, qualifier: calling.length ? calling.map((z) => zoneName(z.id).toLowerCase()).join(", ") : "every zone is at temperature" },
          { label: "Coldest zone", value: formatTemp(zones.slice().sort((a, b) => a.reportedC - b.reportedC)[0].reportedC), unit: "°", qualifier: zoneName(zones.slice().sort((a, b) => a.reportedC - b.reportedC)[0].id) + " — " + statusValue(ZONE_STATUS, zones.slice().sort((a, b) => a.reportedC - b.reportedC)[0].status).label.toLowerCase() },
          { label: "Heating draw", value: formatWatts(heatingWatts).split(" ")[0], unit: formatWatts(heatingWatts).split(" ")[1], qualifier: "controllers and the study heater, off the same device list as everything else" },
          { label: "People counted", value: awake.length, qualifier: asleep.length + " asleep and " + (activeMembers(data.members).length - membersHome(data.members).length) + " out, so their preferences are ignored" },
        ]} />

        {openZones.length ? (
          <Band>
            <Card tint>
              <Alert variant="warning">
                <AlertTitle>{openZones.length === 1 ? zoneName(openZones[0].id) + " is heating with a window open" : openZones.length + " zones are heating with a window open"}</AlertTitle>
                <AlertDescription>
                  {openZones.map((zone) => {
                    const open = data.entries.filter((e) => e.zone === zone.id && e.status === "open");
                    return zoneName(zone.id) + " is at " + formatTemp(zone.reportedC) + "° against " + formatTemp(zone.targetC) + "°, and " + open.map((e) => e.label.toLowerCase()).join(" and ") + " has been open since " + formatWhen(open[0].changedAt, now);
                  }).join(". ")}.
                </AlertDescription>
              </Alert>
            </Card>
          </Band>
        ) : null}

        <div className="hh-roomgrid">
          {zones.map((zone) => (
            <ZoneCard key={zone.id} zone={zone} data={data} now={now} viewer={viewer}
              onZoneStep={onZoneStep} onZoneMode={onZoneMode} onNavigate={onNavigate} />
          ))}
        </div>

        <Band>
          <Card tint title="The house's own rules" note="Set in Settings, and read by every zone above.">
            <Pairs items={[
              { label: "Away setback", value: formatTemp(HOUSE_POLICY.awaySetbackC) + "° when nobody is home" },
              { label: "Night setback", value: formatTemp(HOUSE_POLICY.nightSetbackC) + "° once everybody home is asleep" },
              { label: "Frost protection", value: formatTemp(HOUSE_POLICY.frostC) + "° in the annexe" },
              { label: "Boost", value: HOUSE_POLICY.boostMinutes + " minutes, " + formatTemp(1.5) + "° over the target" },
            ]} />
          </Card>
        </Band>
      </>
    );
  }

  Object.assign(window, { ClimateScreen, ZoneCard, SCHEDULES, scheduleLevel });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="SecurityScreen.jsx">
(function(){
/* Security — the alarm, then every way into the house.
   Commit model: INSTANT. Arming, locking and unlocking apply immediately and
   raise an undo toast; calling the response company is irreversible in the sense
   that matters, so it confirms first (from App.jsx).

   A board rather than a table: the question is "is the house shut", and an entry
   is only insecure against the alarm and the clock — ENTRY_FLAGS.insecure is the
   one predicate the row, the section count and the nav badge all read. */
(() => {
  const { Button, Badge, Alert, AlertTitle, AlertDescription, Avatar } = window.OriginDesignSystem_7c998b;

  function SecurityScreen({ data, now, viewer, onNavigate, onArm, onEntry, onCallResponse, onDeviceTransition, onExport }) {
    const verdict = securityVerdict(data, now);
    const insecure = entriesInsecure(data, now);
    const blocking = entriesBlockingArming(data.entries);
    const cameras = cameraRows(data);
    const watching = camerasWatching(cameras);
    const grants = grantsLive(data.grants);
    const events = eventsOfKind(eventsNewestFirst(data.events), "security");
    const secure = data.entries.filter((e) => insecure.indexOf(e) < 0);

    return (
      <>
        <PageHead path="/security" title="Security"
          lede={"Ten ways into the house. An entry counts as insecure against the alarm and the clock — an open window at four in the afternoon is a window; the same window armed away is why the alarm exists."}
          actions={
            <>
              <Button variant="outline" size="sm" onClick={() => onNavigate("/cameras")}><Icon name="video" />Cameras</Button>
              <ExportMenu scope={"All " + data.entries.length + " ways in and " + grants.length + " live grants"} onExport={(format) => onExport("Security", "Ways in and who can use them", data.entries.length, format)} />
            </>
          } />

        <Band>
          <Card>
            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", justifyContent: "space-between", gap: "var(--space-5)" }}>
              <div style={{ minWidth: 0, flex: "1 1 22rem" }}>
                <span className="hh-actions__row" style={{ marginBottom: "var(--space-2)" }}>
                  <StatusBadge values={ALARM_STATUS} value={data.house.alarm} />
                  <span className="hh-footnote">since {formatWhen(data.house.alarmChangedAt, now)}, by {memberName(data.members, data.house.alarmChangedById)}</span>
                </span>
                <VerdictLine verdict={verdict} />
                <p className="hh-card__note">{verdict.detail}</p>
                <span className="hh-actions__row" style={{ marginTop: "var(--space-4)" }}>
                  <Transitions transitions={armTransitions(data, viewer)} onRun={onArm} />
                  {data.house.alarm === "triggered" ? null : <Button variant="ghost" size="sm" onClick={onCallResponse}>Call Sentinel Response</Button>}
                </span>
              </div>
              <div style={{ flex: "0 1 16rem", minWidth: 0 }}>
                <span className="hh-fig__label">Monitoring</span>
                <p className="hh-footnote" style={{ marginTop: "var(--space-2)" }}>
                  {providerName(data.providers, "prv_05")} · {RESIDENCE.responseNumber}<br />
                  Calls {memberName(data.members, "mem_01").split(" ")[0]}, then {memberName(data.members, "mem_02").split(" ")[0]}, then the police.
                </p>
                <p className="hh-footnote">Last armed away {formatWhen(data.house.lastArmedAwayAt, now)}.</p>
              </div>
            </div>
          </Card>
        </Band>

        <Figures items={[
          { label: "Not secure", value: insecure.length, unit: "of " + data.entries.length, qualifier: insecure.length ? insecure.map((e) => e.label.toLowerCase()).join(", ") : "everything is shut and locked" },
          { label: "Would block arming", value: blocking.length, qualifier: blocking.length ? blocking.map((e) => e.label.toLowerCase()).join(", ") + " — open or not reporting" : "the house can be armed as it stands" },
          { label: "Cameras watching", value: watching.length, unit: "of " + cameras.length, qualifier: camerasNotWatching(cameras).map((c) => c.name.toLowerCase() + " " + (c.shuttered ? "has its shutter closed" : statusValue(DEVICE_STATUS, c.device ? c.device.status : "offline").label.toLowerCase())).join(", ") || "all of them online, recording and open" },
          { label: "Ways in handed out", value: grants.length, qualifier: grantsExpiringSoon(data.grants, now).length + " expiring within " + HOUSE_POLICY.accessExpirySoonDays + " days, " + grantsNeverUsed(data.grants).length + " never used" },
        ]} />

        {insecure.length ? (
          <Band>
            <Card flush title={"Not secure (" + insecure.length + ")"} note="Each one says what it is, and what the house would do about it if it were armed.">
              <div style={{ paddingInline: "var(--card-pad)" }}>
                <Rows>
                  {insecure.map((entry) => (
                    <Row key={entry.id} title={entry.label}
                      note={roomName(entry.room) + " · " + labelOf(ENTRY_KINDS, entry.kind) + " · " + statusValue(ENTRY_STATUS, entry.status).label.toLowerCase() + " since " + formatWhen(entry.changedAt, now) + (entry.byId ? ", " + memberName(data.members, entry.byId) : "")}
                      meta={entry.status === "unknown"
                        ? "Its sensor stopped reporting — the house cannot tell whether it is open, so arming would fault."
                        : ENTRY_FLAGS.wouldBlockArming(entry) ? "Arming the house now would fault immediately."
                          : "It is dark, so an unlocked door counts against the house."}>
                      <StatusBadge values={ENTRY_STATUS} value={entry.status} />
                      <Transitions transitions={entryTransitions(entry, data, viewer)} onRun={(t) => onEntry(entry, t)} />
                    </Row>
                  ))}
                </Rows>
              </div>
            </Card>
          </Band>
        ) : null}

        <div className="hh-cols hh-cols--wide">
          <div>
            <Card flush title={"Everything shut (" + secure.length + ")"} note="Locked, or reported closed by a sensor that is still talking to the house.">
              <div style={{ paddingInline: "var(--card-pad)" }}>
                <Rows>
                  {secure.map((entry) => {
                    const holders = grantsForEntry(data.grants, entry.id);
                    return (
                      <Row key={entry.id} title={entry.label}
                        note={roomName(entry.room) + " · " + formatWhen(entry.changedAt, now) + (entry.byId ? " by " + memberName(data.members, entry.byId).split(" ")[0] : "")}
                        meta={holders.length ? holders.length + (holders.length === 1 ? " grant opens it: " : " grants open it: ") + holders.map((g) => g.label.toLowerCase()).join(", ") : "No grant opens it"}>
                        <StatusBadge values={ENTRY_STATUS} value={entry.status} />
                        <Transitions transitions={entryTransitions(entry, data, viewer)} onRun={(t) => onEntry(entry, t)} />
                      </Row>
                    );
                  })}
                </Rows>
              </div>
            </Card>
          </div>

          <div>
            <Card title={"Cameras (" + cameras.length + ")"} note="Watching means online, recording and the shutter open — the same predicate the cameras screen and the sidebar count read."
              actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/cameras")}>Live view</Button>}>
              <Rows>
                {cameras.map((camera) => (
                  <Row key={camera.id} recessed={camera.recessed}
                    title={<button type="button" className="hh-linkbtn" onClick={() => onNavigate("/cameras")}>{camera.name}</button>}
                    note={camera.view + " · " + (camera.watching ? "watching, last frame " + formatWhen(camera.device.lastSeenAt, now) : camera.shuttered ? "shutter closed on purpose, recording nothing" : camera.device && camera.device.status === "setup_pending" ? "added " + formatWhen(camera.device.addedAt, now) + ", never commissioned" : "not answering")}>
                    {camera.device ? <StatusBadge values={DEVICE_STATUS} value={camera.device.status} /> : null}
                    {camera.shuttered ? <Badge size="sm" variant="secondary">Shutter closed</Badge> : null}
                    {camera.device && camera.device.status === "setup_pending" ? <Transitions transitions={deviceTransitions(camera.device, viewer).filter((t) => t.primary)} onRun={(t) => onDeviceTransition(camera.device, t)} /> : null}
                  </Row>
                ))}
              </Rows>
              <p className="hh-footnote" style={{ marginTop: "var(--space-3)", paddingTop: "var(--space-3)", borderTop: "1px solid var(--border)" }}>
                The monitoring renewal is priced on the cameras being live — <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/upkeep/job_08")}>commissioning is job 08</button>.
              </p>
            </Card>

            <Card title={"Who can get in (" + grants.length + ")"} note="Every way in that is not a person."
              actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/access")}>All access</Button>}>
              <Rows>
                {grants.map((grant) => (
                  <Row key={grant.id} title={grant.label}
                    note={grant.holder + " · " + labelOf(ACCESS_KINDS, grant.kind) + " · " + grant.entryIds.map((id) => (entryById(data.entries, id) || {}).label).join(", ")}>
                    <StatusBadge values={ACCESS_STATUS} value={grant.status} />
                    {ACCESS_FLAGS.expiringSoon(grant, now) ? <Badge size="sm" variant="warning">{formatCountdown(grant.endsAt, now)}</Badge> : null}
                    {ACCESS_FLAGS.neverUsed(grant) ? <Badge size="sm" variant="secondary">Never used</Badge> : null}
                  </Row>
                ))}
              </Rows>
            </Card>

            <Card title={"Security in the log (" + events.length + ")"} note="Doors, windows, gates and the alarm.">
              <Rows>
                {events.map((event) => (
                  <Row key={event.id}
                    title={<button type="button" className="hh-linkbtn" onClick={() => onNavigate(eventRoute(event))}>{event.line}</button>}
                    note={(event.actorId ? memberName(data.members, event.actorId) + " · " : "") + (event.note || "")}
                    value={<span className="hh-footnote" title={formatAbsolute(event.at)}>{formatWhen(event.at, now)}</span>} />
                ))}
              </Rows>
            </Card>
          </div>
        </div>
      </>
    );
  }

  /* An entry's transitions: what the house can do about this door or window now.
     Lives here because it is a screen-level composition over the lock device the
     entry names, not a stored state of its own. */
  function entryTransitions(entry, data, viewer) {
    const may = ROLE_CAN_UNLOCK[viewer.role];
    const gate = (t) => Object.assign({}, t, may ? {} : { disabled: true, disabledReason: "Doors and windows are an adult's to move. You are signed in as " + labelOf(HOUSEHOLD_ROLES, viewer.role).toLowerCase() + "." });
    if (entry.status === "unknown") {
      const device = entry.deviceId ? deviceById(data.devices, entry.deviceId) : null;
      return device ? [{ id: "open_device", label: "Open the sensor", primary: true, deviceId: device.id }] : [];
    }
    if (entry.status === "open") return [{ id: "acknowledge", label: "I have shut it", primary: true }];
    if (entry.status === "unlocked") return [gate({ id: "lock", label: "Lock", primary: true })];
    return [gate({ id: "unlock", label: "Unlock" })];
  }

  Object.assign(window, { SecurityScreen, entryTransitions });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="EnergyScreen.jsx">
(function(){
/* Energy — fourteen days, the tariff, the battery, the water.
   Commit model: INSTANT. Selecting a day is a bare control (harmless); setting
   the battery mode applies immediately with an undo toast.

   The period control is a SCOPE filter (§5.3): it sits above the figures and
   moves them. Selecting a day in the chart narrows the detail below the chart
   only, and the figures do not move with it — the chart says which.
   costOf in metrics.js is the only place a tariff rate is applied. */
(() => {
  const { Button, Badge, Alert, AlertTitle, AlertDescription, Select, Tabs, TabsList, TabsTab } = window.OriginDesignSystem_7c998b;

  const PERIODS = [
    { value: "7", label: "Last 7 days" },
    { value: "14", label: "Last 14 days" },
  ];

  function EnergyScreen({ data, now, viewer, period, onPeriodChange, onBatteryMode, onNavigate, onExport }) {
    const [picked, setPicked] = React.useState(null);
    const all = data.energy;
    /* Scoped first: every figure below is computed from this array. */
    const days = all.slice(-Number(period));
    const complete = energyComplete(days);
    const today = energyToday(all);
    const window_ = windowAt(now);
    const upcoming = nextWindow(now);
    const advice = batteryAdvice(data.battery, now);
    const draw = drawByRoom(data.devices);
    const water = data.water;
    const leak = leakVerdict(water);
    const selected = picked == null ? null : days[picked];
    const worst = worstDay(days);

    return (
      <>
        <PageHead path="/energy" title="Energy"
          lede={"Electricity, the tariff it is bought on, the battery and the water. " + TARIFF.name + " from " + TARIFF.supplier + " — three rates, and the house buys what it can inside the cheap one."}
          actions={
            <>
              <Button variant="outline" size="sm" onClick={() => onNavigate("/spend")}><Icon name="receipt" />Bills</Button>
              <ExportMenu scope={days.length + " days of readings"} onExport={(format) => onExport("Energy", "The last " + days.length + " days", days.length, format)} />
            </>
          } />

        <Band>
          <Card tint>
            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-3) var(--space-4)" }}>
              <span className="hh-fig__label">Period</span>
              <span className="hh-filters__select">
                <Select size="sm" aria-label="Scope every figure on this screen to a period" items={PERIODS} value={period} onValueChange={(v) => { onPeriodChange(v); setPicked(null); }} />
              </span>
              <p className="hh-card__note" style={{ flex: 1, minWidth: "16rem", margin: 0 }}>
                Counting the {days.length} days to {new Date(today.at).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}, with today still running.
              </p>
              <span className="hh-footnote">{window_.label} rate now, {window_.pencePerKwh}p · {upcoming.window.label.toLowerCase()} in {Math.floor(upcoming.minutesAway / 60)}h {upcoming.minutesAway % 60}m</span>
            </div>
          </Card>
        </Band>

        <Figures items={[
          { label: "Used", value: formatKwh(kwhSum(days)), unit: "kWh", qualifier: "over " + days.length + " days · " + formatKwh(averageDayKwh(days)) + " a day on the " + complete.length + " complete ones" },
          { label: "Cost", value: formatPounds(costSum(days)), qualifier: "at " + tariffWindow("off_peak").pencePerKwh + "p off-peak and " + tariffWindow("standard").pencePerKwh + "p otherwise, plus " + HOUSE_POLICY.standingChargePence + "p a day standing charge" },
          { label: "From the roof", value: formatKwh(solarSum(days)), unit: "kWh", qualifier: formatKwh(exportSum(days)) + " kWh exported, worth " + formatPounds(exportValue(days)) + " back" },
          { label: "Worst day", value: formatKwh(worst.kwh), unit: "kWh", qualifier: new Date(worst.at).toLocaleDateString("en-GB", { weekday: "long" }) + " · " + formatPounds(dayCost(worst)) + " · " + Math.round(worst.offPeakShare * 100) + "% off-peak" },
        ]} />

        <div className="hh-cols hh-cols--wide">
          <div>
            <Card title="Day by day" note="The dark part of each bar is what was bought inside a cheap window. Choose a day to read it below.">
              <DayBars days={days} selected={picked} onSelect={setPicked}
                valueOf={(d) => d.kwh}
                softOf={(d) => d.kwh * (1 - d.offPeakShare)}
                tickOf={(d, i) => (days.length > 7 && i % 2 ? "" : new Date(d.at).toLocaleDateString("en-GB", { weekday: "narrow" }))}
                labelOf={(d) => new Date(d.at).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "short" }) + ": " + formatKwh(d.kwh) + " kWh, " + formatPounds(dayCost(d))} />
              <div style={{ marginTop: "var(--space-4)" }}>
                <Legend items={[{ label: "Bought off-peak" }, { label: "Bought at standard rate", kind: "soft" }]} />
              </div>
              {selected ? (
                <div style={{ marginTop: "var(--space-4)", paddingTop: "var(--space-4)", borderTop: "1px solid var(--border)" }}>
                  <Rows>
                    <Row title={new Date(selected.at).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" }) + (selected.partial ? " — still running" : "")}
                      note={formatKwh(selected.kwh) + " kWh · " + Math.round(selected.offPeakShare * 100) + "% off-peak · " + formatKwh(selected.solarKwh) + " kWh from the roof · " + formatKwh(selected.exportKwh) + " kWh exported"}
                      value={formatPounds(dayCost(selected))} />
                  </Rows>
                  <Button variant="ghost" size="xs" onClick={() => setPicked(null)}>Clear the day</Button>
                </div>
              ) : null}
            </Card>

            <Card title="The tariff, across a day" note="Where the cheap and expensive windows fall. The mark is now.">
              <DayStrip now={now} label="Tariff windows across the day"
                toneOf={(hour) => {
                  const off = tariffWindow("off_peak"), peak = tariffWindow("peak");
                  if (hour >= parseClock(off.from) / 60 && hour < parseClock(off.to) / 60) return "off";
                  if (hour >= parseClock(peak.from) / 60 && hour < parseClock(peak.to) / 60) return "peak";
                  return undefined;
                }} />
              <div style={{ marginTop: "var(--space-4)" }}>
                <Legend items={[
                  { label: "Off-peak " + tariffWindow("off_peak").from + "–" + tariffWindow("off_peak").to + ", " + tariffWindow("off_peak").pencePerKwh + "p", kind: "off" },
                  { label: "Peak " + tariffWindow("peak").from + "–" + tariffWindow("peak").to + ", " + tariffWindow("peak").pencePerKwh + "p", kind: "peak" },
                  { label: "Standard " + tariffWindow("standard").pencePerKwh + "p", kind: "soft" },
                ]} />
              </div>
              <p className="hh-footnote" style={{ marginTop: "var(--space-3)" }}>
                Set in <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/settings/tariff")}>Settings → Tariff and meters</button>, and read by the battery, the routines and every cost on this screen.
              </p>
            </Card>

            <Card title="Water" note="Litres a day, and the overnight figure a slow leak shows up in.">
              <DayBars days={water} selected={null}
                valueOf={(d) => d.litres}
                softOf={(d) => d.overnightLitres}
                tickOf={(d) => new Date(d.at).toLocaleDateString("en-GB", { weekday: "narrow" })}
                labelOf={(d) => new Date(d.at).toLocaleDateString("en-GB", { weekday: "long" }) + ": " + d.litres + " litres, " + d.overnightLitres + " of them overnight"} />
              <div style={{ marginTop: "var(--space-4)" }}>
                <Legend items={[{ label: "Daytime" }, { label: "Overnight, when the house is asleep", kind: "soft" }]} />
              </div>
              {leak ? (
                <div style={{ marginTop: "var(--space-4)" }}>
                  <Alert variant="warning">
                    <AlertTitle>Water is moving overnight, {leak.nights} nights running</AlertTitle>
                    <AlertDescription>
                      {leak.perNight} litres a night since {formatWhen(leak.since, now)}, against 4 to 6 on a normal night — {leak.litres} litres in total. A running overflow or a cistern valve would look exactly like this, and the {labelOf(BILL_KINDS, "water").toLowerCase()} bill for this period is already {formatPounds(data.bills.find((b) => b.id === "bil_02").amountMinor - data.bills.find((b) => b.id === "bil_02").forecastMinor)} above its forecast.
                    </AlertDescription>
                  </Alert>
                </div>
              ) : null}
              <p className="hh-footnote" style={{ marginTop: "var(--space-3)" }}>
                {Math.round(waterAverage(water))} litres a day on average across the {waterComplete(water).length} complete days.
              </p>
            </Card>
          </div>

          <div>
            <Card title="The battery" note="What it ought to be doing is worked out from the tariff and its charge, not stored.">
              <div className="hh-dial" style={{ alignItems: "flex-start", minWidth: 0 }}>
                <span className="hh-dial__reading">{data.battery.chargePercent}<span>%</span></span>
                <span className="hh-dial__target" style={{ textAlign: "left" }}>{formatKwh((data.battery.chargePercent / 100) * data.battery.capacityKwh)} kWh of {data.battery.capacityKwh} kWh</span>
              </div>
              <div style={{ marginTop: "var(--space-4)" }}>
                <Bar percent={data.battery.chargePercent} tone={data.battery.chargePercent < 25 ? "warn" : "good"} label="Battery charge" />
              </div>
              <Rows>
                <Row title={advice.line} note={advice.detail} />
                <Row title="Today" note={formatKwh(data.battery.chargedTodayKwh) + " kWh in, " + formatKwh(data.battery.dischargedTodayKwh) + " kWh out · " + formatCount(data.battery.cycles) + " cycles since it was fitted"} />
              </Rows>
              <div style={{ marginTop: "var(--space-4)", paddingTop: "var(--space-3)", borderTop: "1px solid var(--border)" }}>
                <span className="hh-fig__label" style={{ display: "block", marginBottom: "var(--space-2)" }}>How it decides</span>
                <Tabs value={data.battery.mode} onValueChange={onBatteryMode}>
                  <TabsList>
                    <TabsTab value="tariff" size="sm">Follow the tariff</TabsTab>
                    <TabsTab value="solar" size="sm">Store the roof</TabsTab>
                    <TabsTab value="hold" size="sm">Hold charge</TabsTab>
                  </TabsList>
                </Tabs>
                <p className="hh-footnote" style={{ marginTop: "var(--space-2)" }}>
                  {data.battery.mode === "tariff" ? "Charging inside the off-peak window and running the house through the peak one."
                    : data.battery.mode === "solar" ? "Only charging from the roof, so nothing is bought to fill it."
                      : "Keeping what it has for a power cut, and neither charging nor discharging."}
                </p>
              </div>
            </Card>

            <Card title="From the roof" note="Today against what the array can do at its best.">
              <Rows>
                <Row title="Generated today" value={formatKwh(data.solar.todayKwh) + " kWh"}
                  note={"Best " + formatKwh(data.solar.bestTodayKw) + " kW at " + new Date(data.solar.bestAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) + " · " + data.solar.peakKwp + " kWp fitted"} />
                <Row title="Generating now" value={formatKwh(data.solar.nowKw) + " kW"} note={isDark(now) ? "Dark since " + HOUSE_POLICY.darkAfter : "Daylight"} />
                <Row title="Exported today" value={formatKwh(today.exportKwh) + " kWh"} note={"Worth " + formatPounds(Math.round(today.exportKwh * TARIFF.exportPencePerKwh)) + " at " + TARIFF.exportPencePerKwh + "p"} />
              </Rows>
            </Card>

            <Card title="Where it is going, now" note="Every device drawing power, grouped by room — the same array the device table renders.">
              <Rows>
                {draw.map((row) => (
                  <Row key={row.room} title={row.label} value={formatWatts(row.watts)}
                    note={row.devices.map((d) => d.name.toLowerCase()).join(", ")}>
                    <span style={{ width: "5rem" }}>
                      <Bar percent={(row.watts / Math.max(1, liveWatts(data.devices))) * 100} label={row.label + " share"} />
                    </span>
                  </Row>
                ))}
              </Rows>
              <p className="hh-footnote" style={{ marginTop: "var(--space-3)" }}>
                {formatWatts(liveWatts(data.devices))} across {devicesDrawing(data.devices).length} devices · <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/devices")}>the device list</button>
              </p>
            </Card>
          </div>
        </div>
      </>
    );
  }

  Object.assign(window, { EnergyScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="SpendScreen.jsx">
(function(){
/* Spend — what running the house costs, what is owed, and what is committed.
   Commit model: INSTANT. Paying, querying and closing a query apply immediately
   with an undo toast; raising a bill is a staged dialog from this screen.

   Every figure divides one array: monthlyRunningCost, billsOutstanding and
   spendByKind all read data.bills and data.contracts, so nothing here can
   disagree with the rows beneath it. */
(() => {
  const {
    Button, Badge, Alert, AlertTitle, AlertDescription, Tabs, TabsList, TabsTab,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableFooter,
  } = window.OriginDesignSystem_7c998b;

  function SpendScreen({ data, now, viewer, onNavigate, onBillTransition, onNewBill, onExport }) {
    const [tab, setTab] = React.useState("open");
    const { sort, onSort } = useSort("due", "asc");

    const monthly = monthlyRunningCost(data);
    const open = billsOpen(data.bills);
    const overdue = billsOverdue(data.bills, now);
    const disputed = billsDisputed(data.bills);
    const above = billsAboveForecast(data.bills);
    const contracts = contractsActive(data.contracts);
    const ending = contractsEndingSoon(data.contracts, now);
    const byKind = spendByKind(data.bills);

    const base = tab === "open" ? open.concat(disputed) : tab === "paid" ? data.bills.filter((b) => b.status === "paid") : data.bills;
    const rows = sortRows(base, sort, {
      due: (b) => b.dueAt,
      amount: (b) => b.amountMinor,
      supplier: (b) => b.supplier,
    });

    return (
      <>
        <PageHead path="/spend" title="Bills"
          lede={"Running " + RESIDENCE.short + " costs about " + formatPounds(monthly.total) + " a month: " + formatPounds(monthly.bills) + " in bills and " + formatPounds(monthly.contracts) + " in service contracts."}
          actions={
            <>
              <ExportMenu scope={"All " + data.bills.length + " bills and " + contracts.length + " contracts"} onExport={(format) => onExport("Bills", "Bills and contracts", data.bills.length, format)} />
              <Button size="sm" onClick={onNewBill}><Icon name="plus" />New bill</Button>
            </>
          } />

        <Figures items={[
          { label: "Owed now", value: formatPounds(billsOutstanding(data.bills)), qualifier: open.length + " open bills and " + disputed.length + " queried · " + overdue.length + " past their date" },
          { label: "A month to run", value: formatPounds(monthly.total), qualifier: monthly.rows.length + " recurring bills plus " + contracts.length + " contracts" },
          { label: "Above forecast", value: above.length, qualifier: above.length ? formatPounds(above.reduce((n, b) => n + (b.amountMinor - b.forecastMinor), 0)) + " more than expected, across " + above.reduce((list, b) => (list.indexOf(b.kind) > -1 ? list : list.concat([b.kind])), []).map((k) => labelOf(BILL_KINDS, k).toLowerCase()).join(", ") : "every bill came in at or under its forecast" },
          { label: "Committed", value: formatPounds(monthlyCommitted(data.contracts)), unit: "a month", qualifier: ending.length ? ending.length + " contracts end within 60 days" : "nothing ending within 60 days" },
        ]} />

        {overdue.length || above.length ? (
          <Band>
            <Card tint>
              <Alert variant={overdue.length ? "warning" : "info"}>
                <AlertTitle>{overdue.length ? (overdue.length === 1 ? "One bill is past its due date" : overdue.length + " bills are past their due date") : "Bills above forecast"}</AlertTitle>
                <AlertDescription>
                  {overdue.length
                    ? overdue.map((b) => b.supplier + " — " + formatPoundsExact(b.amountMinor) + ", due " + formatWhen(b.dueAt, now) + " (" + formatCountdown(b.dueAt, now) + ")").join(" · ")
                    : above.map((b) => b.supplier + " — " + formatPoundsExact(b.amountMinor - b.forecastMinor) + " above forecast").join(" · ")}
                  {above.some((b) => b.kind === "water") ? " The water figure and the overnight flow are the same story." : ""}
                </AlertDescription>
              </Alert>
            </Card>
          </Band>
        ) : null}

        <div className="hh-cols hh-cols--wide">
          <div>
            <Card flush title="Bills" note="What has arrived, what is owed and what has been paid."
              actions={
                <Tabs value={tab} onValueChange={setTab}>
                  <TabsList>
                    <TabsTab value="open" size="sm">Open ({open.length + disputed.length})</TabsTab>
                    <TabsTab value="paid" size="sm">Paid ({data.bills.filter((b) => b.status === "paid").length})</TabsTab>
                    <TabsTab value="all" size="sm">All ({data.bills.length})</TabsTab>
                  </TabsList>
                </Tabs>
              }>
              <TableWrap>
                <Table>
                  <TableHeader>
                    <TableRow>
                      <TableHead><SortHead label="Supplier" sortKey="supplier" sort={sort} onSort={onSort} /></TableHead>
                      <TableHead>For</TableHead>
                      <TableHead>Status</TableHead>
                      <TableHead className="hh-num"><SortHead label="Amount" sortKey="amount" sort={sort} onSort={onSort} /></TableHead>
                      <TableHead className="hh-num">Against forecast</TableHead>
                      <TableHead><SortHead label="Due" sortKey="due" sort={sort} onSort={onSort} /></TableHead>
                      <TableHead>Action</TableHead>
                    </TableRow>
                  </TableHeader>
                  <TableBody>
                    {rows.map((bill) => {
                      const gap = bill.forecastMinor != null ? bill.amountMinor - bill.forecastMinor : null;
                      return (
                        <TableRow key={bill.id}>
                          <TableCell>
                            <span className="hh-person__text">
                              <span className="hh-person__name">{bill.supplier}</span>
                              <span className="hh-person__meta">{labelOf(BILL_KINDS, bill.kind)}</span>
                            </span>
                          </TableCell>
                          <TableCell>{bill.period}</TableCell>
                          <TableCell>
                            <span className="hh-actions__row">
                              <StatusBadge values={BILL_STATUS} value={bill.status} />
                              {BILL_FLAGS.overdue(bill, now) ? <Badge size="sm" variant="warning">{formatCountdown(bill.dueAt, now)}</Badge> : null}
                            </span>
                          </TableCell>
                          <TableCell className="hh-num">{formatPoundsExact(bill.amountMinor)}</TableCell>
                          <TableCell className="hh-num">
                            {gap == null ? <span className="hh-muted">—</span>
                              : gap > 0 ? <span className="hh-warn">{formatPoundsExact(gap)} over</span>
                                : gap < 0 ? <span className="hh-good">{formatPoundsExact(Math.abs(gap))} under</span>
                                  : <span className="hh-muted">as forecast</span>}
                          </TableCell>
                          <TableCell><Clock at={bill.dueAt} now={now} problem={bill.status === "due"} /></TableCell>
                          <TableCell><RowActions transitions={billTransitions(bill, viewer)} onRun={(t) => onBillTransition(bill, t)} label={"Actions for the " + bill.supplier + " bill"} /></TableCell>
                        </TableRow>
                      );
                    })}
                  </TableBody>
                  <TableFooter>
                    <TableRow>
                      <TableCell colSpan={3}>{tab === "open" ? "Open and queried" : tab === "paid" ? "Paid" : "Every bill"}</TableCell>
                      <TableCell className="hh-num">{formatPoundsExact(rows.reduce((n, b) => n + b.amountMinor, 0))}</TableCell>
                      <TableCell colSpan={3} />
                    </TableRow>
                  </TableFooter>
                </Table>
              </TableWrap>
              <TableFoot shown={rows.length} total={data.bills.length} noun="bills" />
            </Card>
          </div>

          <div>
            <Card title="What a month costs" note="The last paid bill of each kind, annual costs spread, plus the contracts.">
              <Rows>
                {monthly.rows.map((row) => (
                  <Row key={row.kind} title={row.label} value={formatPounds(row.minor)}
                    note={row.kind === "insurance" ? "A year's premium spread over twelve" : row.kind === "water" ? "A quarter's bill spread over three" : "The last month billed"}>
                    <span style={{ width: "4.5rem" }}><Bar percent={(row.minor / Math.max(1, monthly.total)) * 100} label={row.label + " share"} /></span>
                  </Row>
                ))}
                <Row title="Service contracts" value={formatPounds(monthly.contracts)} note={contracts.map((c) => c.name.split(",")[0].toLowerCase()).join(", ")}>
                  <span style={{ width: "4.5rem" }}><Bar percent={(monthly.contracts / Math.max(1, monthly.total)) * 100} label="Contract share" /></span>
                </Row>
              </Rows>
              <div style={{ display: "flex", justifyContent: "space-between", marginTop: "var(--space-4)", paddingTop: "var(--space-3)", borderTop: "1px solid var(--border)" }}>
                <span className="hh-row__title">A month, all in</span>
                <span className="hh-row__value">{formatPounds(monthly.total)}</span>
              </div>
            </Card>

            <Card title={"Service contracts (" + contracts.length + ")"} note="What is committed, and when each one is up for renewal."
              actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/providers")}>Providers</Button>}>
              <Rows>
                {contracts.map((contract) => (
                  <Row key={contract.id} title={contract.name}
                    note={providerName(data.providers, contract.providerId) + " · " + formatPounds(contract.monthlyMinor) + " a month · " + (contract.endsAt ? "runs to " + formatWhen(contract.endsAt, now) : "no end date")}
                    meta={contract.note}>
                    {ending.indexOf(contract) > -1 ? <Badge size="sm" variant="warning">{formatCountdown(contract.endsAt, now)}</Badge> : null}
                    <Button variant="ghost" size="sm" onClick={() => onNavigate("/providers/" + contract.providerId)}>Provider</Button>
                  </Row>
                ))}
              </Rows>
            </Card>

            <Card title="Where the money goes" note="Every bill on the record, grouped by what it is for.">
              <Rows>
                {byKind.map((row) => (
                  <Row key={row.kind} title={row.label} value={formatPounds(row.minor)}
                    note={row.rows.length + (row.rows.length === 1 ? " bill" : " bills") + " on the record"}>
                    <span style={{ width: "5rem" }}><Bar percent={(row.minor / Math.max(1, byKind[0].minor)) * 100} label={row.label + " share"} /></span>
                  </Row>
                ))}
              </Rows>
            </Card>
          </div>
        </div>
      </>
    );
  }

  Object.assign(window, { SpendScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="UpkeepScreens.jsx">
(function(){
/* Upkeep and providers — the work the house needs, one job's record, and the
   people who do it.
   Commit model: INSTANT on all three. Moving a job applies immediately with an
   undo toast; asking for a quote, booking and rescheduling need a date or a
   price, so they open a staged dialog from App.jsx; cancelling a job is
   irreversible and confirms.

   A consumable's due state is read off its device's hours, so a filter falls
   overdue without anybody writing on the job. */
(() => {
  const {
    Button, Badge, Alert, AlertTitle, AlertDescription, Avatar, Tabs, TabsList, TabsTab,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
  } = window.OriginDesignSystem_7c998b;

  const KIND_FILTER = [{ value: "all", label: "Every kind" }].concat(JOB_KINDS);

  function UpkeepScreen({ data, now, viewer, onNavigate, onJobTransition, onNewJob, onExport }) {
    const [tab, setTab] = React.useState("open");
    const [query, setQuery] = React.useState("");
    const [kind, setKind] = React.useState("all");
    const { sort, onSort } = useSort("due", "asc");

    const jobs = jobRows(data, now);
    const open = jobsOpen(jobs);
    const overdue = jobsOverdue(jobs);
    const needy = jobsNeedingYou(jobs);
    const booked = jobsBooked(jobs);
    const quoted = jobsAwaitingQuote(jobs);
    const consumables = jobs.filter((j) => j.kind === "consumable");

    const dirty = !!query || kind !== "all";
    const clear = () => { setQuery(""); setKind("all"); };
    const base = tab === "open" ? open : tab === "needs" ? needy : tab === "booked" ? booked : tab === "done" ? jobsDone(jobs) : jobs;
    const filtered = base.filter((job) => {
      if (kind !== "all" && job.kind !== kind) return false;
      if (query && (job.title + " " + (job.note || "") + " " + providerName(data.providers, job.providerId)).toLowerCase().indexOf(query.toLowerCase()) < 0) return false;
      return true;
    });
    const rows = sortRows(filtered, sort, {
      due: (j) => j.dueAt,
      cost: (j) => j.costMinor || j.quoteMinor || 0,
      title: (j) => j.title,
    });

    return (
      <>
        <PageHead path="/upkeep" title="Upkeep"
          lede={open.length + " jobs open on the house. " + (overdue.length ? overdue.length + " are past their date, and a consumable's date is the hours its machine has actually run." : "Nothing is past its date.")}
          actions={
            <>
              <Button variant="outline" size="sm" onClick={() => onNavigate("/providers")}><Icon name="hard-hat" />Providers</Button>
              <ExportMenu scope={dirty || tab !== "open" ? rows.length + " jobs in this list" : open.length + " open jobs"}
                onExport={(format) => onExport("Upkeep", dirty || tab !== "open" ? "The filtered job list" : "Open jobs", rows.length, format)} />
              <Button size="sm" onClick={onNewJob}><Icon name="plus" />New job</Button>
            </>
          } />

        <Figures items={[
          { label: "Open", value: open.length, qualifier: overdue.length + " past their date, " + jobsBooked(jobs).length + " booked in" },
          { label: "Wanting a decision", value: needy.length, qualifier: quoted.length + " quoted, " + consumables.filter((c) => c.spent).length + " consumables spent" },
          { label: "Committed", value: formatPounds(committedSpend(jobs)), qualifier: "across " + open.filter((j) => (j.costMinor || j.quoteMinor)).length + " jobs with a price or a quote against them" },
          { label: "Done this year", value: jobsDone(jobs).length, qualifier: jobsDone(jobs).length ? formatPounds(jobsDone(jobs).reduce((n, j) => n + (j.costMinor || 0), 0)) + " spent, most recently " + formatWhen(jobsDone(jobs)[0].dueAt, now) : "nothing completed yet" },
        ]} />

        {consumables.some((c) => c.spent) ? (
          <Band>
            <Card tint>
              <Alert variant="warning">
                <AlertTitle>{consumables.filter((c) => c.spent).length === 1 ? "A consumable is past its interval" : consumables.filter((c) => c.spent).length + " consumables are past their interval"}</AlertTitle>
                <AlertDescription>
                  {consumables.filter((c) => c.spent).map((c) => c.title + " — " + c.device.name + " has run " + formatCount(c.ranHours) + " hours of a " + formatCount(c.lifeHours) + "-hour interval, " + formatCount(c.ranHours - c.lifeHours) + " past the change").join(" · ")}.
                  The hours come off the machine, so this does not depend on anybody remembering a date.
                </AlertDescription>
              </Alert>
            </Card>
          </Band>
        ) : null}

        <Band>
          <Card flush>
            <div style={{ padding: "0 var(--card-pad) var(--space-4)" }}>
              <Tabs value={tab} onValueChange={setTab}>
                <TabsList>
                  <TabsTab value="open" size="sm">Open ({open.length})</TabsTab>
                  <TabsTab value="needs" size="sm">Needs you ({needy.length})</TabsTab>
                  <TabsTab value="booked" size="sm">Booked ({booked.length})</TabsTab>
                  <TabsTab value="done" size="sm">Done ({jobsDone(jobs).length})</TabsTab>
                  <TabsTab value="all" size="sm">All ({jobs.length})</TabsTab>
                </TabsList>
              </Tabs>
            </div>
            <FilterBar query={query} onQuery={setQuery} placeholder="Search a job, a note or a provider…" dirty={dirty} onClear={clear}
              selects={[{ id: "kind", label: "Kind", items: KIND_FILTER, value: kind, onChange: setKind }]} />
            {rows.length === 0 ? <NoResults noun="jobs" onClear={clear} /> : (
              <>
                <TableWrap>
                  <Table>
                    <TableHeader>
                      <TableRow>
                        <TableHead><SortHead label="Job" sortKey="title" sort={sort} onSort={onSort} /></TableHead>
                        <TableHead>Kind</TableHead>
                        <TableHead>Status</TableHead>
                        <TableHead>Who</TableHead>
                        <TableHead><SortHead label="Due" sortKey="due" sort={sort} onSort={onSort} /></TableHead>
                        <TableHead style={{ minWidth: "9rem" }}>Life left</TableHead>
                        <TableHead className="hh-num"><SortHead label="Price" sortKey="cost" sort={sort} onSort={onSort} /></TableHead>
                        <TableHead>Action</TableHead>
                      </TableRow>
                    </TableHeader>
                    <TableBody>
                      {rows.map((job) => (
                        <TableRow key={job.id}>
                          <TableCell>
                            <span className="hh-person__text">
                              <span className="hh-person__name"><button type="button" className="hh-linkbtn" onClick={() => onNavigate("/upkeep/" + job.id)}>{job.title}</button></span>
                              <span className="hh-person__meta">{job.room ? roomName(job.room) : "The whole house"}</span>
                            </span>
                          </TableCell>
                          <TableCell>{labelOf(JOB_KINDS, job.kind)}</TableCell>
                          <TableCell>
                            <span className="hh-actions__row">
                              <StatusBadge values={JOB_STATUS} value={job.status} />
                              {job.overdue ? <Badge size="sm" variant="warning">{formatCountdown(job.dueAt, now)}</Badge> : null}
                            </span>
                          </TableCell>
                          <TableCell>{job.providerId ? <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/providers/" + job.providerId)}>{providerName(data.providers, job.providerId)}</button> : <span className="hh-muted">Nobody booked</span>}</TableCell>
                          <TableCell><Clock at={job.dueAt} now={now} problem={job.status !== "done" && job.status !== "cancelled"} /></TableCell>
                          <TableCell>
                            {job.lifeHours != null ? (
                              <span style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: "8rem" }}>
                                <span className="hh-footnote">{formatCount(job.ranHours)} of {formatCount(job.lifeHours)} hours</span>
                                <Bar percent={Math.max(0, (job.lifeLeft || 0) * 100)} tone={job.spent ? "danger" : job.lifeLeft < 0.2 ? "warn" : null} label="Life left" />
                              </span>
                            ) : <span className="hh-muted">—</span>}
                          </TableCell>
                          <TableCell className="hh-num">{job.costMinor ? formatPounds(job.costMinor) : job.quoteMinor ? formatPounds(job.quoteMinor) + " quoted" : <span className="hh-muted">—</span>}</TableCell>
                          <TableCell><RowActions transitions={jobTransitions(job, viewer)} onRun={(t) => onJobTransition(job, t)} label={"Actions for " + job.title} /></TableCell>
                        </TableRow>
                      ))}
                    </TableBody>
                  </Table>
                </TableWrap>
                <TableFoot shown={rows.length} total={jobs.length} noun="jobs" />
              </>
            )}
          </Card>
        </Band>
      </>
    );
  }

  function JobScreen({ data, now, viewer, jobId, onNavigate, onJobTransition, onExport }) {
    const job = jobRows(data, now).find((j) => j.id === jobId);
    const provider = job.providerId ? providerById(data.providers, job.providerId) : null;
    const contract = provider ? data.contracts.find((c) => c.providerId === provider.id && c.status === "active") : null;
    const events = eventsForJob(data.events, job.id);

    return (
      <>
        <PageHead path="/upkeep/:id" params={{ id: job.id }} crumb={job.title} title={job.title}
          lede={labelOf(JOB_KINDS, job.kind) + (job.room ? " · " + roomName(job.room) : " · the whole house") + (job.device ? " · " + job.device.name : "")}
          actions={<ExportMenu scope="This job and its history" onExport={(format) => onExport("Job", job.title, 1, format)} />} />

        <Band>
          <Card>
            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", justifyContent: "space-between", gap: "var(--space-5)" }}>
              <div style={{ minWidth: 0, flex: "1 1 22rem" }}>
                <span className="hh-actions__row" style={{ marginBottom: "var(--space-2)" }}>
                  <StatusBadge values={JOB_STATUS} value={job.status} />
                  {job.overdue ? <Badge size="sm" variant="warning">{formatCountdown(job.dueAt, now)}</Badge> : null}
                  {job.spent ? <Badge size="sm" variant="warning">Interval spent</Badge> : null}
                </span>
                <p className="hh-card__note">{job.note}</p>
              </div>
              <Transitions transitions={jobTransitions(job, viewer)} onRun={(t) => onJobTransition(job, t)} />
            </div>
          </Card>
        </Band>

        <div className="hh-cols">
          <div>
            <Card title="The job">
              <Pairs items={[
                { label: "Status", value: <StatusBadge values={JOB_STATUS} value={job.status} /> },
                { label: "Due", value: <Clock at={job.dueAt} now={now} problem={job.status !== "done" && job.status !== "cancelled"} /> },
                { label: "Booked for", value: job.scheduledAt ? formatWhen(job.scheduledAt, now) : "Not booked" },
                { label: "Who", value: provider ? <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/providers/" + provider.id)}>{provider.name}</button> : "Nobody booked" },
                { label: "Price", value: job.costMinor ? formatPoundsExact(job.costMinor) : job.quoteMinor ? formatPoundsExact(job.quoteMinor) + " quoted" : "No price yet" },
                { label: "Under contract", value: contract ? contract.name : "Charged as it comes" },
                { label: "Device", value: job.device ? <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/devices/" + job.device.id)}>{job.device.name}</button> : "None" },
                { label: "Where", value: job.room ? roomName(job.room) : "The whole house" },
              ]} />
            </Card>

            {job.lifeHours != null ? (
              <Card tint title="Measured in hours, not months" note="The interval is read off the machine, so nobody has to remember a date.">
                <div style={{ marginBottom: "var(--space-3)" }}>
                  <Bar percent={Math.max(0, (job.lifeLeft || 0) * 100)} tone={job.spent ? "danger" : job.lifeLeft < 0.2 ? "warn" : null} label="Life left" />
                </div>
                <Rows>
                  <Row title="Hours run" value={formatCount(job.ranHours)} note={job.device ? "Read from " + job.device.name : "Recorded on the job"} />
                  <Row title="Interval" value={formatCount(job.lifeHours)} note={job.spent ? formatCount(job.ranHours - job.lifeHours) + " hours past the change" : formatCount(job.lifeHours - job.ranHours) + " hours to go"} />
                </Rows>
              </Card>
            ) : null}
          </div>

          <div>
            <Card title={"In the log (" + events.length + ")"} note="Everything the house recorded about this job.">
              {events.length ? (
                <Rows>
                  {events.map((event) => (
                    <Row key={event.id} title={event.line} note={event.note || labelOf(EVENT_KINDS, event.kind)}
                      value={<span className="hh-footnote" title={formatAbsolute(event.at)}>{formatWhen(event.at, now)}</span>} />
                  ))}
                </Rows>
              ) : <p className="hh-footnote">Nothing about this job has reached the log yet.</p>}
            </Card>

            {provider ? (
              <Card title="Who does it" note="What else this provider has done for the house.">
                <Rows>
                  <Row title={<PersonCell name={provider.name} meta={labelOf(TRADES, provider.trade) + " · " + provider.contact} onOpen={() => onNavigate("/providers/" + provider.id)} />}
                    note={provider.phone + " · " + (provider.keyHolder ? "holds a way in" : "no access held")} />
                  {jobsForProvider(jobRows(data, now), provider.id).filter((j) => j.id !== job.id).map((other) => (
                    <Row key={other.id} title={<button type="button" className="hh-linkbtn" onClick={() => onNavigate("/upkeep/" + other.id)}>{other.title}</button>}
                      note={statusValue(JOB_STATUS, other.status).label + " · " + (other.scheduledAt ? formatWhen(other.scheduledAt, now) : "due " + formatWhen(other.dueAt, now))} />
                  ))}
                </Rows>
              </Card>
            ) : null}
          </div>
        </div>
      </>
    );
  }

  function ProvidersScreen({ data, now, viewer, onNavigate, onNewProvider, onExport }) {
    const jobs = jobRows(data, now);
    const withCounts = data.providers.map((provider) => {
      const theirs = jobsForProvider(jobs, provider.id);
      const done = theirs.filter((j) => j.status === "done");
      const openJobs = jobsOpen(theirs);
      const contract = data.contracts.find((c) => c.providerId === provider.id && c.status === "active") || null;
      const grants = grantsForProvider(data.grants, provider.id);
      const lastVisit = theirs.filter((j) => j.scheduledAt).sort((a, b) => b.scheduledAt - a.scheduledAt)[0];
      return { provider: provider, jobs: theirs, done: done, open: openJobs, contract: contract, grants: grants, lastVisit: lastVisit };
    });
    const keyHolders = withCounts.filter((row) => row.grants.some((g) => g.status === "active"));

    return (
      <>
        <PageHead path="/providers" title="Providers"
          lede={data.providers.length + " people and firms the house uses. " + keyHolders.length + " of them hold a way in, and " + contractsActive(data.contracts).length + " are on a contract."}
          actions={
            <>
              <ExportMenu scope={"All " + data.providers.length + " providers"} onExport={(format) => onExport("Providers", "Every provider and their jobs", data.providers.length, format)} />
              <Button size="sm" onClick={onNewProvider}><Icon name="plus" />New provider</Button>
            </>
          } />

        <Figures items={[
          { label: "Providers", value: data.providers.length, qualifier: TRADES.filter((t) => data.providers.some((p) => p.trade === t.value)).length + " trades covered" },
          { label: "On a contract", value: contractsActive(data.contracts).length, qualifier: formatPounds(monthlyCommitted(data.contracts)) + " a month committed" },
          { label: "Holding a way in", value: keyHolders.length, qualifier: keyHolders.map((r) => r.provider.name.split(" ")[0]).join(", ") || "nobody holds access" },
          { label: "Open jobs between them", value: jobsOpen(jobs).filter((j) => j.providerId).length, qualifier: jobsOpen(jobs).filter((j) => !j.providerId).length + " more open jobs have nobody booked" },
        ]} />

        <div className="hh-roomgrid">
          {withCounts.map((row) => (
            <Card key={row.provider.id} fill>
              <div className="hh-room__top">
                <div style={{ minWidth: 0 }}>
                  <div className="hh-room__name"><button type="button" className="hh-linkbtn" onClick={() => onNavigate("/providers/" + row.provider.id)}>{row.provider.name}</button></div>
                  <div className="hh-footnote">{labelOf(TRADES, row.provider.trade)} · {row.provider.contact}</div>
                </div>
                {row.contract ? <Badge size="sm" variant="info">On contract</Badge> : null}
              </div>
              <Rows>
                <Row title="Jobs" value={row.jobs.length} note={row.done.length + " done, " + row.open.length + " open"} />
                <Row title="Last here" value={row.lastVisit ? formatWhen(row.lastVisit.scheduledAt, now) : "—"} note={row.lastVisit ? row.lastVisit.title : "No visit on the record"} />
                <Row title="Access" value={row.grants.filter((g) => g.status === "active").length || "None"}
                  note={row.grants.length ? row.grants.map((g) => g.label + " — " + statusValue(ACCESS_STATUS, g.status).label.toLowerCase()).join(", ") : "No code or key held"} />
              </Rows>
              <div className="hh-room__foot">
                <span className="hh-footnote">{row.provider.hourlyMinor ? formatPounds(row.provider.hourlyMinor) + " an hour" : "Monitoring only"} · since {formatSince(row.provider.since)}</span>
                <Button variant="outline" size="sm" onClick={() => onNavigate("/providers/" + row.provider.id)}>Open</Button>
              </div>
            </Card>
          ))}
        </div>
      </>
    );
  }

  function ProviderScreen({ data, now, viewer, providerId, onNavigate, onJobTransition, onAccessTransition, onExport }) {
    const provider = providerById(data.providers, providerId);
    const jobs = jobsForProvider(jobRows(data, now), provider.id);
    const contract = data.contracts.find((c) => c.providerId === provider.id) || null;
    const grants = grantsForProvider(data.grants, provider.id);
    const spent = jobs.filter((j) => j.status === "done").reduce((n, j) => n + (j.costMinor || 0), 0);

    return (
      <>
        <PageHead path="/providers/:id" params={{ id: provider.id }} crumb={provider.name} title={provider.name}
          lede={labelOf(TRADES, provider.trade) + " · " + provider.contact + " · " + provider.phone}
          actions={<ExportMenu scope={"This provider and their " + jobs.length + " jobs"} onExport={(format) => onExport("Provider", provider.name, 1, format)} />} />

        <Figures items={[
          { label: "Jobs", value: jobs.length, qualifier: jobs.filter((j) => j.status === "done").length + " done, " + jobsOpen(jobs).length + " open" },
          { label: "Spent with them", value: formatPounds(spent), qualifier: "across the jobs marked done" },
          { label: "Ways in held", value: grants.filter((g) => g.status === "active").length, qualifier: grants.length ? grants.map((g) => statusValue(ACCESS_STATUS, g.status).label.toLowerCase()).join(", ") : "no code or key" },
          { label: "Rate", value: provider.hourlyMinor ? formatPounds(provider.hourlyMinor) : "—", unit: provider.hourlyMinor ? "an hour" : "", qualifier: contract ? contract.name : "no contract, charged as it comes" },
        ]} />

        <div className="hh-cols">
          <div>
            <Card title="Record">
              <Pairs items={[
                { label: "Trade", value: labelOf(TRADES, provider.trade) },
                { label: "Contact", value: provider.contact },
                { label: "Phone", value: provider.phone },
                { label: "Email", value: provider.email },
                { label: "Used since", value: formatSince(provider.since) },
                { label: "Holds a way in", value: provider.keyHolder ? "Yes" : "No" },
              ]} />
              <p className="hh-card__note" style={{ marginTop: "var(--space-4)" }}>{provider.note}</p>
            </Card>

            {contract ? (
              <Card tint title="Contract">
                <Rows>
                  <Row title={contract.name} value={formatPounds(contract.monthlyMinor) + " a month"} note={contract.note} />
                  <Row title="Runs to" value={contract.endsAt ? formatWhen(contract.endsAt, now) : "No end date"}
                    note={contract.endsAt ? formatCountdown(contract.endsAt, now) : "Reviewed rather than renewed"} />
                </Rows>
                <div style={{ marginTop: "var(--space-3)" }}>
                  <Button variant="outline" size="sm" onClick={() => onNavigate("/spend")}>Bills</Button>
                </div>
              </Card>
            ) : null}
          </div>

          <div>
            <Card title={"Jobs (" + jobs.length + ")"} note="Everything the house has asked them to do.">
              {jobs.length ? (
                <Rows>
                  {jobs.map((job) => (
                    <Row key={job.id}
                      title={<button type="button" className="hh-linkbtn" onClick={() => onNavigate("/upkeep/" + job.id)}>{job.title}</button>}
                      note={statusValue(JOB_STATUS, job.status).label + " · " + (job.scheduledAt ? formatWhen(job.scheduledAt, now) : "due " + formatWhen(job.dueAt, now)) + (job.costMinor ? " · " + formatPounds(job.costMinor) : job.quoteMinor ? " · " + formatPounds(job.quoteMinor) + " quoted" : "")}>
                      {job.overdue ? <Badge size="sm" variant="warning">{formatCountdown(job.dueAt, now)}</Badge> : null}
                      <Transitions transitions={jobTransitions(job, viewer).filter((t) => t.primary)} onRun={(t) => onJobTransition(job, t)} />
                    </Row>
                  ))}
                </Rows>
              ) : <p className="hh-footnote">No job on the record for them.</p>}
            </Card>

            <Card title={"Access they hold (" + grants.length + ")"} note="A provider's way in is a grant like any other, and it can be suspended from here."
              actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/access")}>All access</Button>}>
              {grants.length ? (
                <Rows>
                  {grants.map((grant) => (
                    <Row key={grant.id} title={grant.label}
                      note={labelOf(ACCESS_KINDS, grant.kind) + " · " + grant.entryIds.map((id) => (entryById(data.entries, id) || {}).label).join(", ") + " · used " + formatCount(grant.uses) + " times, last " + formatWhen(grant.lastUsedAt, now)}>
                      <StatusBadge values={ACCESS_STATUS} value={grant.status} />
                      <Transitions transitions={accessTransitions(grant, viewer).filter((t) => t.primary)} onRun={(t) => onAccessTransition(grant, t)} />
                    </Row>
                  ))}
                </Rows>
              ) : <p className="hh-footnote">They have no code or key.</p>}
            </Card>
          </div>
        </div>
      </>
    );
  }

  Object.assign(window, { UpkeepScreen, JobScreen, ProvidersScreen, ProviderScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="PeopleScreens.jsx">
(function(){
/* People, one person's record, and the access the house hands out.
   Commit model: the household screens are INSTANT (answering a request applies
   at once with an undo toast; denying one needs a reason, so it opens a staged
   dialog); /people/:id is STAGED — preferences are a form, and saving them moves
   the comfort target on every other screen. */
(() => {
  const {
    Button, Badge, Avatar, Alert, AlertTitle, AlertDescription, Switch, Checkbox, Slider,
    Input, Select, Field, FieldLabel, FieldDescription, FieldError, Tabs, TabsList, TabsTab,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
  } = window.OriginDesignSystem_7c998b;

  function PeopleScreen({ data, now, viewer, onNavigate, onRequest, onMemberTransition, onInvite, onExport }) {
    const members = data.members;
    const home = membersHome(members);
    const awake = membersAwake(members, now);
    const waiting = requestsWaiting(data.requests);
    const target = comfortTarget(members, now);
    const setter = comfortSetter(members, now);

    return (
      <>
        <PageHead path="/people" title="People"
          lede={activeMembers(members).length + " people in the household, " + home.length + " home and " + awake.length + " up. The warmest preference among those " + awake.length + " is what the house is holding — " + formatTemp(target.c) + "°."}
          actions={
            <>
              <Button variant="outline" size="sm" onClick={() => onNavigate("/access")}><Icon name="key-round" />Access</Button>
              <ExportMenu scope={"All " + members.length + " people"} onExport={(format) => onExport("People", "The household", members.length, format)} />
              <Button size="sm" onClick={onInvite}><Icon name="plus" />New person</Button>
            </>
          } />

        <Figures items={[
          { label: "Home", value: home.length, unit: "of " + activeMembers(members).length, qualifier: awake.length + " up, " + membersAsleep(members, now).length + " asleep" },
          { label: "Waiting on you", value: waiting.length, qualifier: waiting.length ? waiting.map((r) => memberName(members, r.memberId).split(" ")[0]).join(" and ") + " asked for something" : "nobody is waiting on an answer" },
          { label: "Setting the temperature", value: setter ? formatTemp(setter.prefs.comfortC) : formatTemp(target.c), unit: "°", qualifier: setter ? setter.name + " — " + target.because : target.because },
          { label: "Not active", value: members.filter((m) => m.status !== "active").length, qualifier: members.filter((m) => m.status !== "active").map((m) => statusValue(MEMBER_STATUS, m.status).label.toLowerCase()).join(", ") || "everybody is active" },
        ]} />

        {waiting.length ? (
          <Band>
            <Card flush title={"Waiting on you (" + waiting.length + ")"} note="Somebody asked for something the rules do not let them do on their own.">
              <div style={{ paddingInline: "var(--card-pad)" }}>
                <Rows>
                  {waiting.map((request) => {
                    const member = memberById(data.members, request.memberId);
                    const subject = request.kind === "unlock" ? (entryById(data.entries, request.subject) || {}).label
                      : request.kind === "lights" ? deviceName(data.devices, request.subject)
                        : zoneName(request.subject);
                    return (
                      <Row key={request.id}
                        title={<PersonCell name={member.name} avatar={member.avatar} size={30}
                          meta={labelOf(HOUSEHOLD_ROLES, member.role) + " · asked " + formatWhen(request.askedAt, now) + " · " + subject}
                          onOpen={() => onNavigate("/people/" + member.id)} />}
                        note={"“" + request.body + "”"}>
                        <Transitions transitions={requestTransitions(request, viewer)} onRun={(t) => onRequest(request, t)} />
                      </Row>
                    );
                  })}
                </Rows>
              </div>
            </Card>
          </Band>
        ) : null}

        <div className="hh-roomgrid">
          {members.map((member) => {
            const counts = MEMBER_FLAGS.countsForComfort(member, now);
            const theirs = requestsForMember(data.requests, member.id);
            const routines = routinesForMember(routineRows(data, now), member.id);
            return (
              <Card key={member.id} fill>
                <div className="hh-room__top">
                  <PersonCell name={member.name} avatar={member.avatar} size={44}
                    meta={labelOf(HOUSEHOLD_ROLES, member.role) + " · since " + formatSince(member.since)}
                    onOpen={() => onNavigate("/people/" + member.id)} />
                  <StatusBadge values={MEMBER_STATUS} value={member.status} />
                </div>
                <Rows>
                  <Row title={member.home ? "Home" : "Out"}
                    note={member.status !== "active" ? (member.status === "invited" ? "Invited " + formatWhen(member.since, now) + " and not accepted yet" : member.suspendedNote || "No access")
                      : member.home ? (member.where ? "In the " + roomName(member.where).toLowerCase() + ", " : "") + (MEMBER_FLAGS.isAwake(member, now) ? "up until " + member.prefs.bedtime : "asleep since " + member.prefs.bedtime)
                        : "Last seen " + formatWhen(member.lastSeenAt, now)}
                    value={member.status === "active" ? <Badge size="sm" variant={member.home ? "success" : "secondary"}>{member.home ? "In" : "Out"}</Badge> : null} />
                  <Row title="Wants it at" value={formatTemp(member.prefs.comfortC) + "°"}
                    note={counts ? "Counts towards the target now" : member.status !== "active" ? "Ignored — no access" : member.home ? "Asleep, so ignored" : "Out, so ignored"} />
                  <Row title="Day" value={member.prefs.wake + "–" + member.prefs.bedtime}
                    note={routines.length ? routines.map((r) => r.name.toLowerCase()).join(", ") + " read this" : "No routine reads their times"} />
                  {theirs.length ? (
                    <Row title={"Requests (" + theirs.length + ")"}
                      note={theirs.map((r) => statusValue(REQUEST_STATUS, r.status).label.toLowerCase() + " — " + (r.reason || r.body.slice(0, 40) + "…")).join(" · ")} />
                  ) : null}
                </Rows>
                <div className="hh-room__foot">
                  <span className="hh-footnote">{member.email || "No email on the record"}</span>
                  <span className="hh-actions__row">
                    <Transitions transitions={memberTransitions(member, viewer).filter((t) => t.primary)} onRun={(t) => onMemberTransition(member, t)} />
                    <Button variant="outline" size="sm" onClick={() => onNavigate("/people/" + member.id)}>Open</Button>
                  </span>
                </div>
              </Card>
            );
          })}
        </div>
      </>
    );
  }

  /* STAGED: preferences are a form. Saving them moves the comfort target, the
     bedtime routines and the climate screen, which is stated on the bar. */
  function PersonScreen({ data, now, viewer, memberId, onNavigate, onSavePreferences, onMemberTransition, onRequest }) {
    const member = memberById(data.members, memberId);
    const initial = {
      comfortC: member.prefs.comfortC, bedtime: member.prefs.bedtime, wake: member.prefs.wake,
      lightsOnArrival: member.prefs.lightsOnArrival, notifyAlarm: member.prefs.notifyAlarm, notifyDeliveries: member.prefs.notifyDeliveries,
    };
    const [values, setValues] = React.useState(initial);
    const [submitted, setSubmitted] = React.useState(false);
    React.useEffect(() => { setValues(initial); setSubmitted(false); }, [member]);
    const set = (id, value) => setValues((v) => Object.assign({}, v, { [id]: value }));
    const dirty = Object.keys(initial).some((key) => values[key] !== initial[key]);

    const errors = {};
    if (!/^\d\d:\d\d$/.test(values.bedtime)) errors.bedtime = "Use HH:MM.";
    if (!/^\d\d:\d\d$/.test(values.wake)) errors.wake = "Use HH:MM.";
    if (!(values.comfortC >= 15 && values.comfortC <= 24)) errors.comfortC = "Between 15 and 24 degrees.";
    const valid = Object.keys(errors).length === 0;

    /* The preview executes the real evaluator on the real fixture, with this
       form's values swapped in — so it cannot disagree with what Save will do. */
    const previewMembers = data.members.map((m) => (m.id === member.id ? Object.assign({}, m, { prefs: Object.assign({}, m.prefs, values) }) : m));
    const previewTarget = comfortTarget(previewMembers, now);
    const currentTarget = comfortTarget(data.members, now);
    const routines = routinesForMember(routineRows(data, now), member.id);
    const grants = data.grants.filter((g) => g.holder === member.name);
    const theirRequests = requestsForMember(data.requests, member.id);
    const events = eventsForMember(data.events, member.id);

    return (
      <>
        <PageHead path="/people/:id" params={{ id: member.id }} crumb={member.name} title={member.name}
          lede={labelOf(HOUSEHOLD_ROLES, member.role) + " · " + (member.status === "active" ? (member.home ? "home now" : "out now") : statusValue(MEMBER_STATUS, member.status).label.toLowerCase()) + " · in the household since " + formatSince(member.since)}
          actions={<Transitions transitions={memberTransitions(member, viewer)} onRun={(t) => onMemberTransition(member, t)} />} />

        <Band>
          <Card>
            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-5)" }}>
              <Avatar size={72} src={member.avatar} alt={"Photograph of " + member.name} fallback={initialsOf(member.name)} />
              <div style={{ flex: "1 1 18rem", minWidth: 0 }}>
                <span className="hh-actions__row">
                  <StatusBadge values={MEMBER_STATUS} value={member.status} />
                  <Badge size="sm" variant={member.home ? "success" : "secondary"}>{member.home ? "Home" : "Out"}</Badge>
                  {MEMBER_FLAGS.countsForComfort(member, now) ? <Badge size="sm" variant="info">Counts towards the temperature</Badge> : null}
                </span>
                <p className="hh-card__note" style={{ marginTop: "var(--space-2)" }}>
                  {member.status === "invited" ? "Invited " + formatWhen(member.since, now) + " and has not accepted, so nothing they prefer is counted yet."
                    : member.status === "suspended" ? member.suspendedNote
                      : member.home ? (member.where ? "In the " + roomName(member.where).toLowerCase() + ". " : "") + (MEMBER_FLAGS.isAwake(member, now) ? "Up until " + member.prefs.bedtime + ", so their comfort preference counts." : "Asleep since " + member.prefs.bedtime + ", so their preference is ignored and the house holds " + formatTemp(HOUSE_POLICY.nightSetbackC) + "°.")
                        : "Out since " + formatWhen(member.lastSeenAt, now) + ", so their preference is ignored."}
                </p>
              </div>
              <div className="hh-dial">
                <span className="hh-dial__reading">{formatTemp(values.comfortC)}<span>°</span></span>
                <span className="hh-dial__target">what {member.name.split(" ")[0]} wants</span>
              </div>
            </div>
          </Card>
        </Band>

        <div className="hh-cols hh-cols--wide">
          <div>
            <Card title="Preferences" note="Every control here is a form field. Nothing changes in the house until you save.">
              <div className="hh-fields">
                <Field>
                  <FieldLabel>Comfortable at</FieldLabel>
                  <div style={{ display: "flex", alignItems: "center", gap: "var(--space-4)" }}>
                    <span style={{ flex: 1, minWidth: "8rem" }}>
                      <Slider min={15} max={24} step={0.5} value={values.comfortC} aria-label="Comfortable temperature" onValueChange={(v) => set("comfortC", Array.isArray(v) ? v[0] : v)} />
                    </span>
                    <span className="hh-row__value" style={{ minWidth: "3.5rem" }}>{formatTemp(values.comfortC)}°</span>
                  </div>
                  {submitted && errors.comfortC ? <FieldError>{errors.comfortC}</FieldError> : <FieldDescription>The house holds the warmest preference among the people home and awake.</FieldDescription>}
                </Field>
                <Field>
                  <FieldLabel htmlFor="hh-p-bed">Goes to bed</FieldLabel>
                  <Input id="hh-p-bed" value={values.bedtime} invalid={submitted && !!errors.bedtime} onChange={(e) => set("bedtime", e.target.value)} />
                  {submitted && errors.bedtime ? <FieldError>{errors.bedtime}</FieldError> : <FieldDescription>{routines.length ? routines.map((r) => r.name).join(" and ") + " reads this." : "Also decides when their preference stops counting."}</FieldDescription>}
                </Field>
                <Field>
                  <FieldLabel htmlFor="hh-p-wake">Gets up</FieldLabel>
                  <Input id="hh-p-wake" value={values.wake} invalid={submitted && !!errors.wake} onChange={(e) => set("wake", e.target.value)} />
                  {submitted && errors.wake ? <FieldError>{errors.wake}</FieldError> : null}
                </Field>
                <Field className="hh-fields__wide">
                  <FieldLabel>What the house does for them</FieldLabel>
                  <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2-5)" }}>
                    <span className="hh-person">
                      <Checkbox checked={values.lightsOnArrival} aria-label="Lights on when they arrive" onCheckedChange={(v) => set("lightsOnArrival", v)} />
                      <span className="hh-pair__value">Turn the hall light on when they arrive</span>
                    </span>
                    <span className="hh-person">
                      <Checkbox checked={values.notifyAlarm} aria-label="Tell them about the alarm" onCheckedChange={(v) => set("notifyAlarm", v)} />
                      <span className="hh-pair__value">Tell them when the alarm is triggered</span>
                    </span>
                    <span className="hh-person">
                      <Checkbox checked={values.notifyDeliveries} aria-label="Tell them about deliveries" onCheckedChange={(v) => set("notifyDeliveries", v)} />
                      <span className="hh-pair__value">Tell them when a delivery arrives</span>
                    </span>
                  </div>
                  <FieldDescription>Checkboxes, because they are submitted with this form rather than applying on their own.</FieldDescription>
                </Field>
              </div>
            </Card>

            <Card tint title="What saving would do" note="The real evaluator, run on the real house with these values swapped in.">
              <Rows>
                <Row title="The house would hold" value={formatTemp(previewTarget.c) + "°"}
                  note={previewTarget.because + (previewTarget.c !== currentTarget.c ? " · it holds " + formatTemp(currentTarget.c) + "° now" : " · unchanged")} />
                {routines.map((routine) => (
                  <Row key={routine.id} title={routine.name} value={values.bedtime}
                    note={routine.when !== values.bedtime ? "Runs at " + routine.when + " today · it stores the person, not the time" : "Unchanged"} />
                ))}
              </Rows>
            </Card>
          </div>

          <div>
            <Card title="Record">
              <Pairs items={[
                { label: "Role", value: labelOf(HOUSEHOLD_ROLES, member.role) },
                { label: "Standing", value: <StatusBadge values={MEMBER_STATUS} value={member.status} /> },
                { label: "Email", value: member.email || "None on the record" },
                { label: "Phone", value: member.phone || "None on the record" },
                { label: "Last seen", value: <Clock at={member.lastSeenAt} now={now} /> },
                { label: "May unlock doors", value: ROLE_CAN_UNLOCK[member.role] ? "Yes" : "No — they ask instead" },
                { label: "May arm the alarm", value: ROLE_CAN_ARM[member.role] ? "Yes" : "No" },
                { label: "May hand out access", value: ROLE_CAN_GRANT_ACCESS[member.role] ? "Yes" : "No" },
              ]} />
            </Card>

            {theirRequests.length ? (
              <Card title={"Their requests (" + theirRequests.length + ")"} note="What they have asked the house for.">
                <Rows>
                  {theirRequests.map((request) => (
                    <Row key={request.id} title={"“" + request.body + "”"}
                      note={statusValue(REQUEST_STATUS, request.status).label + " · asked " + formatWhen(request.askedAt, now) + (request.answeredAt ? ", answered " + formatWhen(request.answeredAt, now) + " by " + memberName(data.members, request.answeredById) : "") + (request.reason ? " — " + request.reason : "")}>
                      <Transitions transitions={requestTransitions(request, viewer)} onRun={(t) => onRequest(request, t)} />
                    </Row>
                  ))}
                </Rows>
              </Card>
            ) : null}

            {grants.length ? (
              <Card title={"Ways in they hold (" + grants.length + ")"} note="Their own code or key, managed on Access."
                actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/access")}>Access</Button>}>
                <Rows>
                  {grants.map((grant) => (
                    <Row key={grant.id} title={grant.label}
                      note={labelOf(ACCESS_KINDS, grant.kind) + " · " + grant.entryIds.map((id) => (entryById(data.entries, id) || {}).label).join(", ") + " · used " + formatCount(grant.uses) + " times"}>
                      <StatusBadge values={ACCESS_STATUS} value={grant.status} />
                    </Row>
                  ))}
                </Rows>
              </Card>
            ) : null}

            <Card title={"In the log (" + events.length + ")"} note="What they did, and what the house recorded about them.">
              {events.length ? (
                <Rows>
                  {events.map((event) => (
                    <Row key={event.id} title={event.line} note={event.note || labelOf(EVENT_KINDS, event.kind)}
                      value={<span className="hh-footnote" title={formatAbsolute(event.at)}>{formatWhen(event.at, now)}</span>} />
                  ))}
                </Rows>
              ) : <p className="hh-footnote">Nothing about them has reached the log yet.</p>}
            </Card>
          </div>
        </div>

        <FormBar dirty={dirty} label="Save preferences"
          note={previewTarget.c !== currentTarget.c
            ? "Saving moves the house from " + formatTemp(currentTarget.c) + "° to " + formatTemp(previewTarget.c) + "°, and every zone on Climate with it."
            : "Preferences are read live by Climate, Today and the bedtime routines."}
          onCancel={() => { setValues(initial); setSubmitted(false); }}
          onSave={() => { setSubmitted(true); if (valid) onSavePreferences(member, values); }} />
      </>
    );
  }

  function AccessScreen({ data, now, viewer, onNavigate, onAccessTransition, onNewGrant, onExport }) {
    const [tab, setTab] = React.useState("live");
    const { sort, onSort } = useSort("used", "desc");

    const live = grantsLive(data.grants);
    const expiring = grantsExpiringSoon(data.grants, now);
    const unused = grantsNeverUsed(data.grants);
    const base = tab === "live" ? live : tab === "ended" ? data.grants.filter((g) => g.status === "revoked" || g.status === "suspended") : data.grants;
    const rows = sortRows(base, sort, {
      used: (g) => g.lastUsedAt,
      uses: (g) => g.uses,
      label: (g) => g.label,
      ends: (g) => g.endsAt,
    });

    return (
      <>
        <PageHead path="/access" title="Access"
          lede={live.length + " ways into the house that are not a person: keypad codes, handed-out keys and windows of access opened for a job."}
          actions={
            <>
              <ExportMenu scope={"All " + data.grants.length + " grants"} onExport={(format) => onExport("Access", "Every way in", data.grants.length, format)} />
              <Button size="sm" onClick={onNewGrant}><Icon name="plus" />New access</Button>
            </>
          } />

        <Figures items={[
          { label: "Live", value: live.length, qualifier: live.filter((g) => g.kind === "code").length + " codes, " + live.filter((g) => g.kind === "key").length + " keys, " + live.filter((g) => g.kind === "window").length + " timed" },
          { label: "Expiring", value: expiring.length, qualifier: expiring.length ? expiring.map((g) => g.label.toLowerCase() + " " + formatCountdown(g.endsAt, now)).join(", ") : "nothing expires within " + HOUSE_POLICY.accessExpirySoonDays + " days" },
          { label: "Never used", value: unused.length, qualifier: unused.length ? unused.map((g) => g.label.toLowerCase()).join(", ") + " — handed over and never used" : "every live grant has been used" },
          { label: "Ended", value: data.grants.filter((g) => g.status === "revoked" || g.status === "suspended").length, qualifier: "kept rather than deleted, so the log still reads" },
        ]} />

        {unused.length ? (
          <Band>
            <Card tint>
              <Alert variant="info">
                <AlertTitle>{unused.length === 1 ? "One way in has never been used" : unused.length + " ways in have never been used"}</AlertTitle>
                <AlertDescription>
                  {unused.map((g) => g.label + " — " + g.holder + ", " + labelOf(ACCESS_KINDS, g.kind).toLowerCase() + " to " + g.entryIds.map((id) => (entryById(data.entries, id) || {}).label.toLowerCase()).join(" and ") + ", handed over " + formatWhen(g.startsAt, now)).join(" · ")}.
                  A key nobody uses is a key nobody is accounting for.
                </AlertDescription>
              </Alert>
            </Card>
          </Band>
        ) : null}

        <Band>
          <Card flush>
            <div style={{ padding: "0 var(--card-pad) var(--space-4)" }}>
              <Tabs value={tab} onValueChange={setTab}>
                <TabsList>
                  <TabsTab value="live" size="sm">Live ({live.length})</TabsTab>
                  <TabsTab value="ended" size="sm">Suspended and revoked ({data.grants.filter((g) => g.status === "revoked" || g.status === "suspended").length})</TabsTab>
                  <TabsTab value="all" size="sm">All ({data.grants.length})</TabsTab>
                </TabsList>
              </Tabs>
            </div>
            <TableWrap>
              <Table>
                <TableHeader>
                  <TableRow>
                    <TableHead><SortHead label="Grant" sortKey="label" sort={sort} onSort={onSort} /></TableHead>
                    <TableHead>Who holds it</TableHead>
                    <TableHead>Kind</TableHead>
                    <TableHead>Opens</TableHead>
                    <TableHead>Status</TableHead>
                    <TableHead><SortHead label="Last used" sortKey="used" sort={sort} onSort={onSort} /></TableHead>
                    <TableHead><SortHead label="Ends" sortKey="ends" sort={sort} onSort={onSort} /></TableHead>
                    <TableHead className="hh-num"><SortHead label="Uses" sortKey="uses" sort={sort} onSort={onSort} /></TableHead>
                    <TableHead>Action</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {rows.map((grant) => (
                    <TableRow key={grant.id} className={grant.status === "revoked" ? "hh-row--recessed" : undefined}>
                      <TableCell>
                        <span className="hh-person__text">
                          <span className="hh-person__name">{grant.label}</span>
                          <span className="hh-person__meta">{grant.note}</span>
                        </span>
                      </TableCell>
                      <TableCell>
                        {grant.providerId
                          ? <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/providers/" + grant.providerId)}>{grant.holder}</button>
                          : grant.holder}
                      </TableCell>
                      <TableCell>{labelOf(ACCESS_KINDS, grant.kind)}</TableCell>
                      <TableCell>{grant.entryIds.map((id) => (entryById(data.entries, id) || {}).label).join(", ")}</TableCell>
                      <TableCell>
                        <span className="hh-actions__row">
                          <StatusBadge values={ACCESS_STATUS} value={grant.status} />
                          {ACCESS_FLAGS.expiringSoon(grant, now) ? <Badge size="sm" variant="warning">{formatCountdown(grant.endsAt, now)}</Badge> : null}
                          {ACCESS_FLAGS.neverUsed(grant) ? <Badge size="sm" variant="secondary">Never used</Badge> : null}
                        </span>
                      </TableCell>
                      <TableCell><Clock at={grant.lastUsedAt} now={now} /></TableCell>
                      <TableCell>{grant.endsAt ? <Clock at={grant.endsAt} now={now} problem={grant.status === "active"} /> : <span className="hh-muted">No end</span>}</TableCell>
                      <TableCell className="hh-num">{formatCount(grant.uses)}</TableCell>
                      <TableCell><RowActions transitions={accessTransitions(grant, viewer)} onRun={(t) => onAccessTransition(grant, t)} label={"Actions for " + grant.label} /></TableCell>
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            </TableWrap>
            <TableFoot shown={rows.length} total={data.grants.length} noun="grants" />
          </Card>
        </Band>
      </>
    );
  }

  Object.assign(window, { PeopleScreen, PersonScreen, AccessScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="RoutinesScreen.jsx">
(function(){
/* Routines — what the house does on its own.
   Commit model: INSTANT. Running, pausing and resuming apply immediately with an
   undo toast; finishing a draft and fixing a broken routine each need two
   decisions, so they open a staged dialog; deleting one confirms.

   Three sections, each running its own predicate: what wants a person, what is
   running, what is paused. A broken routine is stated at full weight — the house
   is quietly not doing something — while a paused one is recessed, because it is
   waiting on a decision rather than on work. */
(() => {
  const { Button, Badge, Alert, AlertTitle, AlertDescription } = window.OriginDesignSystem_7c998b;

  function RoutineRow({ routine, data, now, viewer, onRoutineTransition, onNavigate }) {
    const devices = routine.deviceIds.map((id) => deviceById(data.devices, id)).filter(Boolean);
    const member = routine.memberId ? memberById(data.members, routine.memberId) : null;
    return (
      <Row recessed={routine.recessed}
        title={
          <>
            {routine.name}
            <StatusBadge values={ROUTINE_STATUS} value={routine.status} />
            {routine.broken ? <Badge size="sm" variant="warning">Broken</Badge> : null}
          </>
        }
        note={
          routine.broken
            ? "It names " + routine.missing.map((id) => deviceName(data.devices, id)).join(" and ") + ", which is not in the house — so it has not run since " + formatWhen(routine.lastRunAt, now) + "."
            : labelOf(TRIGGER_KINDS, routine.trigger)
              + (routine.when ? " · " + routine.when : "")
              + (member ? " · reads " + member.name.split(" ")[0] + "'s own bedtime" : "")
              + (routine.offsetMinutes ? " · " + Math.abs(routine.offsetMinutes) + " minutes " + (routine.offsetMinutes < 0 ? "before" : "after") : "")
        }
        meta={
          devices.map((d) => d.name.toLowerCase()).join(", ")
          + " · ran " + formatCount(routine.runs) + " times, last " + formatWhen(routine.lastRunAt, now)
        }>
        <RowActions transitions={routineTransitions(routine, data.devices, viewer)} onRun={(t) => onRoutineTransition(routine, t)} label={"Actions for " + routine.name} />
      </Row>
    );
  }

  function RoutinesScreen({ data, now, viewer, onNavigate, onRoutineTransition, onNewRoutine, onExport }) {
    const rows = routineRows(data, now);
    const needy = routinesNeedingYou(rows);
    const running = routinesRunning(rows);
    const paused = routinesPaused(rows);
    const target = comfortTarget(data.members, now);

    return (
      <>
        <PageHead path="/routines" title="Routines"
          lede={running.length + " routines run on their own. " + (needy.length ? needy.length + " want a person — a routine that names a device which has left the house is broken, and nothing had to be written on it for that to be true." : "None of them is broken.")}
          actions={
            <>
              <ExportMenu scope={"All " + rows.length + " routines"} onExport={(format) => onExport("Routines", "Every routine", rows.length, format)} />
              <Button size="sm" onClick={onNewRoutine}><Icon name="plus" />New routine</Button>
            </>
          } />

        <Figures items={[
          { label: "Running", value: running.length, unit: "of " + rows.length, qualifier: running.filter((r) => r.trigger === "presence").length + " on presence, " + running.filter((r) => r.trigger === "time").length + " on a clock, " + running.filter((r) => r.trigger === "sunset").length + " on sunset" },
          { label: "Wanting a person", value: needy.length, qualifier: needy.filter((r) => r.broken).length + " broken, " + needy.filter((r) => r.status === "draft").length + " never finished" },
          { label: "Times they have run", value: formatCount(rows.reduce((n, r) => n + r.runs, 0)), qualifier: "most of it " + rows.slice().sort((a, b) => b.runs - a.runs)[0].name.toLowerCase() },
          { label: "Reading a person", value: rows.filter((r) => r.memberId).length, qualifier: "they store the person, not the time, so moving a bedtime moves the routine" },
        ]} />

        {needy.length ? (
          <Band>
            <Card flush title={"Wanting a person (" + needy.length + ")"} note="Unfinished, or naming a device that is no longer in the house.">
              <div style={{ paddingInline: "var(--card-pad)" }}>
                <Rows>
                  {needy.map((routine) => (
                    <RoutineRow key={routine.id} routine={routine} data={data} now={now} viewer={viewer}
                      onRoutineTransition={onRoutineTransition} onNavigate={onNavigate} />
                  ))}
                </Rows>
              </div>
            </Card>
          </Band>
        ) : null}

        <div className="hh-cols hh-cols--wide">
          <div>
            <Card flush title={"Running (" + running.length + ")"} note="On, and every device they name is still in the house.">
              <div style={{ paddingInline: "var(--card-pad)" }}>
                <Rows>
                  {running.map((routine) => (
                    <RoutineRow key={routine.id} routine={routine} data={data} now={now} viewer={viewer}
                      onRoutineTransition={onRoutineTransition} onNavigate={onNavigate} />
                  ))}
                </Rows>
              </div>
            </Card>

            {paused.length ? (
              <Card flush title={"Paused (" + paused.length + ")"} note="Waiting on a decision rather than on work, so they are recessed.">
                <div style={{ paddingInline: "var(--card-pad)" }}>
                  <Rows>
                    {paused.map((routine) => (
                      <RoutineRow key={routine.id} routine={routine} data={data} now={now} viewer={viewer}
                        onRoutineTransition={onRoutineTransition} onNavigate={onNavigate} />
                    ))}
                  </Rows>
                </div>
              </Card>
            ) : null}
          </div>

          <div>
            <Card title="What the routines read" note="None of these numbers is stored on a routine; each one is read when it runs.">
              <Rows>
                <Row title="The comfort target" value={formatTemp(target.c) + "°"}
                  note={"Nobody home and awake means " + formatTemp(HOUSE_POLICY.awaySetbackC) + "° · everybody asleep means " + formatTemp(HOUSE_POLICY.nightSetbackC) + "°"} />
                <Row title="Sunset" value={HOUSE_POLICY.darkAfter}
                  note={"Evening lights runs 20 minutes before · " + (isDark(now) ? "dark now" : "still light")} />
                <Row title="Off-peak window" value={tariffWindow("off_peak").from}
                  note={"The battery routine starts here, at " + tariffWindow("off_peak").pencePerKwh + "p"} />
                <Row title="Quiet hours" value={HOUSE_POLICY.quietFrom + "–" + HOUSE_POLICY.quietTo}
                  note="Nothing pushes or rings between these" />
                {data.members.filter((m) => routinesForMember(rows, m.id).length).map((member) => (
                  <Row key={member.id} title={member.name.split(" ")[0] + "'s bedtime"} value={member.prefs.bedtime}
                    note={<>Set on <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/people/" + member.id)}>their own screen</button> · {routinesForMember(rows, member.id).map((r) => r.name.toLowerCase()).join(", ")}</>} />
                ))}
              </Rows>
            </Card>

            <Card tint title="Triggers the house understands" note="What a new routine can be built on.">
              <Rows>
                {TRIGGER_KINDS.map((trigger) => (
                  <Row key={trigger.value} title={trigger.label}
                    value={rows.filter((r) => r.trigger === trigger.value).length}
                    note={rows.filter((r) => r.trigger === trigger.value).map((r) => r.name.toLowerCase()).join(", ") || "nothing uses it yet"} />
                ))}
              </Rows>
            </Card>
          </div>
        </div>
      </>
    );
  }

  Object.assign(window, { RoutinesScreen, RoutineRow });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="LogScreen.jsx">
(function(){
/* Log — what happened. Commit model: INSTANT, and in practice read-only: a log
   is never work, so this destination carries no badge and no action beyond
   opening the record an entry names.

   A day ladder rather than a table: the question is "what has the house been
   doing", which is answered by time, and the kind filter narrows the ladder
   without moving the figures above it (§5.3). */
(() => {
  const { Button, Badge, Select, Avatar } = window.OriginDesignSystem_7c998b;

  const KIND_FILTER = [{ value: "all", label: "Everything" }].concat(EVENT_KINDS);

  function LogScreen({ data, now, viewer, onNavigate, onExport }) {
    const [kind, setKind] = React.useState("all");
    const [query, setQuery] = React.useState("");

    const all = eventsNewestFirst(data.events);
    const today = eventsToday(all, now);
    const people = all.filter((e) => e.actorId).reduce((list, e) => (list.indexOf(e.actorId) > -1 ? list : list.concat([e.actorId])), []);
    const byHouse = all.filter((e) => !e.actorId);

    const dirty = !!query || kind !== "all";
    const clear = () => { setQuery(""); setKind("all"); };
    const shown = all.filter((event) => {
      if (kind !== "all" && event.kind !== kind) return false;
      if (query && (event.line + " " + (event.note || "") + " " + (event.actorId ? memberName(data.members, event.actorId) : "")).toLowerCase().indexOf(query.toLowerCase()) < 0) return false;
      return true;
    });

    /* Grouped by day, in the order the ladder reads. */
    const days = shown.reduce((groups, event) => {
      const key = new Date(event.at).toDateString();
      const found = groups.find((g) => g.key === key);
      if (found) found.events.push(event);
      else groups.push({ key: key, at: event.at, events: [event] });
      return groups;
    }, []);

    return (
      <>
        <PageHead path="/log" title="Log"
          lede={all.length + " entries on the record. Today says what is true now and Routines what happens next; this is the only screen that says what happened."}
          actions={<ExportMenu scope={dirty ? shown.length + " entries matching the filters" : "All " + all.length + " entries"} onExport={(format) => onExport("Log", dirty ? "The filtered log" : "The whole log", shown.length, format)} />} />

        <Figures items={[
          { label: "Today", value: today.length, qualifier: today.filter((e) => e.actorId).length + " by somebody, " + today.filter((e) => !e.actorId).length + " by the house itself" },
          { label: "On the record", value: all.length, qualifier: "back to " + formatWhen(all[all.length - 1].at, now) },
          { label: "People who did something", value: people.length, qualifier: people.map((id) => memberName(data.members, id).split(" ")[0]).join(", ") },
          { label: "The house on its own", value: byHouse.length, qualifier: "routines, sensors and devices reporting without anybody asking" },
        ]} />

        <Band>
          <Card flush>
            <FilterBar query={query} onQuery={setQuery} placeholder="Search the log…" dirty={dirty} onClear={clear}
              selects={[{ id: "kind", label: "Kind", items: KIND_FILTER, value: kind, onChange: setKind }]} />
            {shown.length === 0 ? <NoResults noun="entries" onClear={clear} /> : (
              <div style={{ paddingInline: "var(--card-pad)" }}>
                {days.map((day) => (
                  <div key={day.key} style={{ marginBottom: "var(--space-5)" }}>
                    <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: "var(--space-3)", marginBottom: "var(--space-2)" }}>
                      <span className="hh-row__title">
                        {new Date(day.at).toDateString() === new Date(now).toDateString() ? "Today" : new Date(day.at).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" })}
                      </span>
                      <span className="hh-footnote">{day.events.length} {day.events.length === 1 ? "entry" : "entries"}</span>
                    </div>
                    <Rows>
                      {day.events.map((event) => {
                        const actor = event.actorId ? memberById(data.members, event.actorId) : null;
                        return (
                          <Row key={event.id}
                            title={
                              <>
                                <button type="button" className="hh-linkbtn" onClick={() => onNavigate(eventRoute(event))}>{event.line}</button>
                                <Badge size="sm" variant="secondary">{labelOf(EVENT_KINDS, event.kind)}</Badge>
                              </>
                            }
                            note={event.note || null}
                            value={<span className="hh-footnote" title={formatAbsolute(event.at)}>{new Date(event.at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}</span>}>
                            {actor ? (
                              <FloatingTip label={actor.name + " · " + labelOf(HOUSEHOLD_ROLES, actor.role)} side="left">
                                <span style={{ display: "inline-flex" }}><Avatar size={26} src={actor.avatar} alt={actor.name} fallback={initialsOf(actor.name)} /></span>
                              </FloatingTip>
                            ) : (
                              <FloatingTip label="The house did this on its own" side="left">
                                <span style={{ display: "inline-flex", color: "var(--muted-foreground)" }}><Icon name="house" size={18} /></span>
                              </FloatingTip>
                            )}
                          </Row>
                        );
                      })}
                    </Rows>
                  </div>
                ))}
                <div className="hh-tablefoot" style={{ paddingInline: 0 }}>
                  <span className="hh-footnote">Showing {shown.length} of {all.length} entries</span>
                </div>
              </div>
            )}
          </Card>
        </Band>
      </>
    );
  }

  Object.assign(window, { LogScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="SettingsScreens.jsx">
(function(){
/* Settings and your profile — the staged half of the product.
   Commit model: every screen in this file is STAGED. Nothing applies until Save,
   Save and Cancel are disabled until the form is dirty, checkboxes are submitted
   with the form rather than applying themselves, and errors render on validation
   rather than on first paint (§3).

   Five settings screens in one namespace, reached from the account menu, with the
   within-destination level as a tab strip derived from the route table — never a
   second set of destinations.

   Each screen has a real effect, stated on the screen: the setbacks are what
   every zone falls back to, the tariff windows price every figure on Energy and
   decide when the battery charges, the response order is who Sentinel rings, and
   a notification switched off stops arriving. */
(() => {
  const {
    Button, Badge, Input, Select, Checkbox, Switch, Slider, Avatar,
    Field, FieldLabel, FieldDescription, FieldError, Alert, AlertTitle, AlertDescription,
    Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
  } = window.OriginDesignSystem_7c998b;

  function SettingsTabs({ path, onNavigate }) {
    return (
      <div className="hh-tabs" role="tablist" aria-label="Settings">
        {HEARTH_SETTINGS_NAV.map((route) => (
          <button type="button" key={route.path} className="hh-tab" role="tab"
            aria-current={route.path === path ? "page" : undefined} aria-selected={route.path === path}
            onClick={() => onNavigate(route.path)}>{route.label}</button>
        ))}
      </div>
    );
  }

  function SettingsPage({ path, title, lede, onNavigate, dirty, note, label, onCancel, onSave, children }) {
    return (
      <>
        <PageHead path={path} title={title} lede={lede} />
        <SettingsTabs path={path} onNavigate={onNavigate} />
        {children}
        <FormBar dirty={dirty} note={note} label={label} onCancel={onCancel} onSave={onSave} />
      </>
    );
  }

  function useStaged(initial) {
    const [values, setValues] = React.useState(initial);
    const [submitted, setSubmitted] = React.useState(false);
    const set = (id, value) => setValues((v) => Object.assign({}, v, { [id]: value }));
    return {
      values: values, set: set, setValues: setValues, submitted: submitted, setSubmitted: setSubmitted,
      dirty: JSON.stringify(values) !== JSON.stringify(initial),
      reset: () => { setValues(initial); setSubmitted(false); },
    };
  }

  /* ── Residence ───────────────────────────────────────────────────────── */
  function ResidenceSettingsScreen({ data, now, residence, policy, onNavigate, onSave }) {
    const form = useStaged({
      name: residence.name, short: residence.short, address: residence.address, timezone: residence.timezone,
      away: policy.awaySetbackC, night: policy.nightSetbackC, frost: policy.frostC, boost: String(policy.boostMinutes),
      lowBattery: String(policy.lowBatteryPercent),
    });
    const { values, set, submitted } = form;
    const errors = {};
    if (!values.name.trim()) errors.name = "The house's name appears on every export.";
    if (!values.short.trim()) errors.short = "The short name is what the sidebar and the toasts use.";
    if (!values.address.trim()) errors.address = "Give the address the response company holds.";
    if (!(values.away >= 10 && values.away <= 20)) errors.away = "Between 10 and 20 degrees.";
    if (!(values.night >= 12 && values.night <= 22)) errors.night = "Between 12 and 22 degrees.";
    if (!/^\d+$/.test(values.boost) || Number(values.boost) < 15 || Number(values.boost) > 240) errors.boost = "Between 15 and 240 minutes.";
    if (!/^\d+$/.test(values.lowBattery) || Number(values.lowBattery) < 5 || Number(values.lowBattery) > 50) errors.lowBattery = "Between 5 and 50 per cent.";
    const valid = Object.keys(errors).length === 0;
    const zones = zoneRows(data, now);

    return (
      <SettingsPage path="/settings" title="Residence" onNavigate={onNavigate}
        lede={"What the house is called, and the numbers every zone and every device falls back on."}
        dirty={form.dirty} label="Save residence"
        note={"The setbacks are read live by all " + zones.length + " zones — saving them moves Climate and Today at once."}
        onCancel={form.reset} onSave={() => { form.setSubmitted(true); if (valid) onSave(values); }}>
        <Band>
          <Card title="The house">
            <div className="hh-fields">
              <Field>
                <FieldLabel htmlFor="hh-rs-name">Name</FieldLabel>
                <Input id="hh-rs-name" value={values.name} invalid={submitted && !!errors.name} onChange={(e) => set("name", e.target.value)} />
                {submitted && errors.name ? <FieldError>{errors.name}</FieldError> : <FieldDescription>Written into the header of every export.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-rs-short">Short name</FieldLabel>
                <Input id="hh-rs-short" value={values.short} invalid={submitted && !!errors.short} onChange={(e) => set("short", e.target.value)} />
                {submitted && errors.short ? <FieldError>{errors.short}</FieldError> : <FieldDescription>Used in the sidebar and in every message the house sends.</FieldDescription>}
              </Field>
              <Field className="hh-fields__wide">
                <FieldLabel htmlFor="hh-rs-address">Address</FieldLabel>
                <Input id="hh-rs-address" value={values.address} invalid={submitted && !!errors.address} onChange={(e) => set("address", e.target.value)} />
                {submitted && errors.address ? <FieldError>{errors.address}</FieldError> : <FieldDescription>What {providerName(data.providers, "prv_05")} gives the police.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel>Timezone</FieldLabel>
                <Select aria-label="Timezone" value={values.timezone} onValueChange={(v) => set("timezone", v)}
                  items={[{ value: "Europe/London", label: "Europe/London" }, { value: "Europe/Dublin", label: "Europe/Dublin" }, { value: "UTC", label: "UTC" }]} />
                <FieldDescription>Every clock in Hearth, including the tariff windows and quiet hours.</FieldDescription>
              </Field>
            </div>
          </Card>
        </Band>

        <Band>
          <Card title="What the house falls back to" note="These are not a zone's setting — they are what a zone uses when nobody's preference applies.">
            <div className="hh-fields">
              <Field>
                <FieldLabel>Away setback</FieldLabel>
                <div style={{ display: "flex", alignItems: "center", gap: "var(--space-4)" }}>
                  <span style={{ flex: 1, minWidth: "8rem" }}><Slider min={10} max={20} step={0.5} value={values.away} aria-label="Away setback" onValueChange={(v) => set("away", Array.isArray(v) ? v[0] : v)} /></span>
                  <span className="hh-row__value" style={{ minWidth: "3.5rem" }}>{formatTemp(values.away)}°</span>
                </div>
                {submitted && errors.away ? <FieldError>{errors.away}</FieldError> : <FieldDescription>Held when nobody is home. It is {formatTemp(policy.awaySetbackC)}° today.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel>Night setback</FieldLabel>
                <div style={{ display: "flex", alignItems: "center", gap: "var(--space-4)" }}>
                  <span style={{ flex: 1, minWidth: "8rem" }}><Slider min={12} max={22} step={0.5} value={values.night} aria-label="Night setback" onValueChange={(v) => set("night", Array.isArray(v) ? v[0] : v)} /></span>
                  <span className="hh-row__value" style={{ minWidth: "3.5rem" }}>{formatTemp(values.night)}°</span>
                </div>
                {submitted && errors.night ? <FieldError>{errors.night}</FieldError> : <FieldDescription>Held once everybody home is asleep.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel>Frost protection</FieldLabel>
                <div style={{ display: "flex", alignItems: "center", gap: "var(--space-4)" }}>
                  <span style={{ flex: 1, minWidth: "8rem" }}><Slider min={4} max={12} step={0.5} value={values.frost} aria-label="Frost protection" onValueChange={(v) => set("frost", Array.isArray(v) ? v[0] : v)} /></span>
                  <span className="hh-row__value" style={{ minWidth: "3.5rem" }}>{formatTemp(values.frost)}°</span>
                </div>
                <FieldDescription>What the annexe holds all winter.</FieldDescription>
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-rs-boost">Boost lasts</FieldLabel>
                <Input id="hh-rs-boost" value={values.boost} invalid={submitted && !!errors.boost} onChange={(e) => set("boost", e.target.value)} />
                {submitted && errors.boost ? <FieldError>{errors.boost}</FieldError> : <FieldDescription>Minutes, at 1.5° over the target.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-rs-batt">Low battery under</FieldLabel>
                <Input id="hh-rs-batt" value={values.lowBattery} invalid={submitted && !!errors.lowBattery} onChange={(e) => set("lowBattery", e.target.value)} />
                {submitted && errors.lowBattery ? <FieldError>{errors.lowBattery}</FieldError> : <FieldDescription>Per cent. {devicesLowBattery(data.devices).length} devices are under {policy.lowBatteryPercent}% today.</FieldDescription>}
              </Field>
            </div>
          </Card>
        </Band>

        <Band>
          <Card tint title="The house as it stands" note="Read from the fixture, not from this form.">
            <Pairs items={[
              { label: "Built", value: residence.built },
              { label: "Bedrooms", value: residence.bedrooms },
              { label: "Floor area", value: residence.floorAreaSqm + " m²" },
              { label: "EPC", value: residence.epc },
              { label: "Rooms in Hearth", value: ROOMS.length },
              { label: "Devices", value: devicesInHouse(data.devices).length },
              { label: "Zones", value: zones.length },
              { label: "Ways in", value: data.entries.length },
            ]} />
          </Card>
        </Band>
      </SettingsPage>
    );
  }

  /* ── Tariff and meters ───────────────────────────────────────────────── */
  function TariffSettingsScreen({ data, now, tariff, policy, onNavigate, onSave }) {
    const form = useStaged({
      supplier: tariff.supplier, name: tariff.name,
      offFrom: tariff.windows[0].from, offTo: tariff.windows[0].to, offRate: String(tariff.windows[0].pencePerKwh),
      peakFrom: tariff.windows[1].from, peakTo: tariff.windows[1].to, peakRate: String(tariff.windows[1].pencePerKwh),
      standardRate: String(tariff.windows[2].pencePerKwh),
      exportRate: String(tariff.exportPencePerKwh), standing: String(policy.standingChargePence),
    });
    const { values, set, submitted } = form;
    const errors = {};
    ["offFrom", "offTo", "peakFrom", "peakTo"].forEach((key) => { if (!/^\d\d:\d\d$/.test(values[key])) errors[key] = "Use HH:MM."; });
    ["offRate", "peakRate", "standardRate", "exportRate", "standing"].forEach((key) => { if (!/^\d+(\.\d+)?$/.test(values[key])) errors[key] = "A number of pence."; });
    if (!errors.offRate && !errors.standardRate && Number(values.offRate) >= Number(values.standardRate)) errors.offRate = "The off-peak rate has to be cheaper than the standard one, or nothing about the battery makes sense.";
    const valid = Object.keys(errors).length === 0;
    const days = data.energy;

    return (
      <SettingsPage path="/settings/tariff" title="Tariff and meters" onNavigate={onNavigate}
        lede="The rates every cost in Hearth is worked out from, and the windows the battery and the routines read."
        dirty={form.dirty} label="Save tariff"
        note={"Saving re-prices all " + days.length + " days on Energy and moves the battery's advice with them."}
        onCancel={form.reset} onSave={() => { form.setSubmitted(true); if (valid) onSave(values); }}>
        <Band>
          <Card title="Supplier">
            <div className="hh-fields">
              <Field>
                <FieldLabel htmlFor="hh-ts-supplier">Supplier</FieldLabel>
                <Input id="hh-ts-supplier" value={values.supplier} onChange={(e) => set("supplier", e.target.value)} />
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ts-name">Tariff name</FieldLabel>
                <Input id="hh-ts-name" value={values.name} onChange={(e) => set("name", e.target.value)} />
                <FieldDescription>Shown at the top of Energy.</FieldDescription>
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ts-standing">Standing charge</FieldLabel>
                <Input id="hh-ts-standing" value={values.standing} invalid={submitted && !!errors.standing} onChange={(e) => set("standing", e.target.value)} />
                {submitted && errors.standing ? <FieldError>{errors.standing}</FieldError> : <FieldDescription>Pence a day, added to every daily cost.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ts-export">Export rate</FieldLabel>
                <Input id="hh-ts-export" value={values.exportRate} invalid={submitted && !!errors.exportRate} onChange={(e) => set("exportRate", e.target.value)} />
                {submitted && errors.exportRate ? <FieldError>{errors.exportRate}</FieldError> : <FieldDescription>Pence a kWh for what the roof sends back.</FieldDescription>}
              </Field>
            </div>
          </Card>
        </Band>

        <Band>
          <Card title="Windows" note="Three rates. The strip below is drawn from these fields as you change them, so it cannot disagree with what you save.">
            <div className="hh-fields">
              <Field>
                <FieldLabel htmlFor="hh-ts-off-from">Off-peak from</FieldLabel>
                <Input id="hh-ts-off-from" value={values.offFrom} invalid={submitted && !!errors.offFrom} onChange={(e) => set("offFrom", e.target.value)} />
                {submitted && errors.offFrom ? <FieldError>{errors.offFrom}</FieldError> : null}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ts-off-to">Off-peak until</FieldLabel>
                <Input id="hh-ts-off-to" value={values.offTo} invalid={submitted && !!errors.offTo} onChange={(e) => set("offTo", e.target.value)} />
                {submitted && errors.offTo ? <FieldError>{errors.offTo}</FieldError> : null}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ts-off-rate">Off-peak rate</FieldLabel>
                <Input id="hh-ts-off-rate" value={values.offRate} invalid={submitted && !!errors.offRate} onChange={(e) => set("offRate", e.target.value)} />
                {submitted && errors.offRate ? <FieldError>{errors.offRate}</FieldError> : <FieldDescription>Pence a kWh.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ts-peak-from">Peak from</FieldLabel>
                <Input id="hh-ts-peak-from" value={values.peakFrom} invalid={submitted && !!errors.peakFrom} onChange={(e) => set("peakFrom", e.target.value)} />
                {submitted && errors.peakFrom ? <FieldError>{errors.peakFrom}</FieldError> : null}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ts-peak-to">Peak until</FieldLabel>
                <Input id="hh-ts-peak-to" value={values.peakTo} invalid={submitted && !!errors.peakTo} onChange={(e) => set("peakTo", e.target.value)} />
                {submitted && errors.peakTo ? <FieldError>{errors.peakTo}</FieldError> : null}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ts-peak-rate">Peak rate</FieldLabel>
                <Input id="hh-ts-peak-rate" value={values.peakRate} invalid={submitted && !!errors.peakRate} onChange={(e) => set("peakRate", e.target.value)} />
                {submitted && errors.peakRate ? <FieldError>{errors.peakRate}</FieldError> : <FieldDescription>Pence a kWh.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ts-std-rate">Standard rate</FieldLabel>
                <Input id="hh-ts-std-rate" value={values.standardRate} invalid={submitted && !!errors.standardRate} onChange={(e) => set("standardRate", e.target.value)} />
                {submitted && errors.standardRate ? <FieldError>{errors.standardRate}</FieldError> : <FieldDescription>Everything outside the two windows.</FieldDescription>}
              </Field>
            </div>
            <div style={{ marginTop: "var(--space-5)" }}>
              <DayStrip now={now} label="The windows as entered"
                toneOf={(hour) => {
                  const inWindow = (from, to) => {
                    if (!/^\d\d:\d\d$/.test(from) || !/^\d\d:\d\d$/.test(to)) return false;
                    const f = parseClock(from) / 60, t = parseClock(to) / 60;
                    return f <= t ? hour >= f && hour < t : hour >= f || hour < t;
                  };
                  if (inWindow(values.offFrom, values.offTo)) return "off";
                  if (inWindow(values.peakFrom, values.peakTo)) return "peak";
                  return undefined;
                }} />
              <div style={{ marginTop: "var(--space-4)" }}>
                <Legend items={[
                  { label: "Off-peak, " + values.offRate + "p", kind: "off" },
                  { label: "Peak, " + values.peakRate + "p", kind: "peak" },
                  { label: "Standard, " + values.standardRate + "p", kind: "soft" },
                ]} />
              </div>
            </div>
          </Card>
        </Band>

        <Band>
          <Card tint title="Meters" note="Read from the device list; a meter is a device like any other.">
            <Rows>
              {data.devices.filter((d) => d.kind === "meter").map((meter) => (
                <Row key={meter.id} title={<button type="button" className="hh-linkbtn" onClick={() => onNavigate("/devices/" + meter.id)}>{meter.name}</button>}
                  note={roomName(meter.room) + " · last read " + formatWhen(meter.lastSeenAt, now)}
                  value={<StatusBadge values={DEVICE_STATUS} value={meter.status} />} />
              ))}
            </Rows>
          </Card>
        </Band>
      </SettingsPage>
    );
  }

  /* ── Alarm and response ──────────────────────────────────────────────── */
  function SecuritySettingsScreen({ data, now, policy, response, onNavigate, onSave }) {
    const form = useStaged({
      darkAfter: policy.darkAfter,
      responseNumber: RESIDENCE.responseNumber,
      order: response.order.slice(),
      autoArm: response.autoArm,
      autoArmAt: response.autoArmAt,
      notifyPolice: response.notifyPolice,
    });
    const { values, set, submitted } = form;
    const errors = {};
    if (!/^\d\d:\d\d$/.test(values.darkAfter)) errors.darkAfter = "Use HH:MM.";
    if (!/^\d\d:\d\d$/.test(values.autoArmAt)) errors.autoArmAt = "Use HH:MM.";
    if (!values.order.length) errors.order = "Somebody has to be called.";
    const valid = Object.keys(errors).length === 0;
    const callable = activeMembers(data.members).filter((m) => m.role === "owner" || m.role === "adult" || m.role === "staff");
    const insecure = entriesInsecure(data, now);

    const move = (id, delta) => {
      const list = values.order.slice();
      const index = list.indexOf(id);
      const to = index + delta;
      if (index < 0 || to < 0 || to >= list.length) return;
      list.splice(to, 0, list.splice(index, 1)[0]);
      set("order", list);
    };

    return (
      <SettingsPage path="/settings/security" title="Alarm and response" onNavigate={onNavigate}
        lede={"Who is rung when the alarm goes, and the hour after which an unlocked door counts against the house. " + insecure.length + " ways in are insecure right now on those rules."}
        dirty={form.dirty} label="Save alarm settings"
        note={"The dark hour is what makes an unlocked door insecure — saving it moves the count on Today, Security and the sidebar."}
        onCancel={form.reset} onSave={() => { form.setSubmitted(true); if (valid) onSave(values); }}>
        <Band>
          <Card title="Rules">
            <div className="hh-fields">
              <Field>
                <FieldLabel htmlFor="hh-ss-dark">Dark after</FieldLabel>
                <Input id="hh-ss-dark" value={values.darkAfter} invalid={submitted && !!errors.darkAfter} onChange={(e) => set("darkAfter", e.target.value)} />
                {submitted && errors.darkAfter ? <FieldError>{errors.darkAfter}</FieldError> : <FieldDescription>After this, an unlocked door or an open window counts as insecure even with the alarm off.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ss-number">Response number</FieldLabel>
                <Input id="hh-ss-number" value={values.responseNumber} onChange={(e) => set("responseNumber", e.target.value)} />
                <FieldDescription>{providerName(data.providers, "prv_05")}'s control room.</FieldDescription>
              </Field>
              <Field>
                <FieldLabel>Arm automatically</FieldLabel>
                <span className="hh-person">
                  <Checkbox checked={values.autoArm} aria-label="Arm the house automatically" onCheckedChange={(v) => set("autoArm", v)} />
                  <span className="hh-pair__value">Arm — home once everybody is asleep</span>
                </span>
                <FieldDescription>Submitted with this form, like every control on this screen.</FieldDescription>
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ss-at">If nobody has armed it by</FieldLabel>
                <Input id="hh-ss-at" value={values.autoArmAt} invalid={submitted && !!errors.autoArmAt} onChange={(e) => set("autoArmAt", e.target.value)} />
                {submitted && errors.autoArmAt ? <FieldError>{errors.autoArmAt}</FieldError> : <FieldDescription>Only applies when nothing is open — anything open would fault the alarm instead.</FieldDescription>}
              </Field>
            </div>
          </Card>
        </Band>

        <Band>
          <Card title="Who Sentinel rings, in order" note="First to answer takes the call. Only adults and staff with live access can be on the list.">
            <Rows>
              {values.order.map((id, index) => {
                const member = memberById(data.members, id);
                return (
                  <Row key={id} title={<PersonCell name={member.name} avatar={member.avatar} size={28} meta={labelOf(HOUSEHOLD_ROLES, member.role) + " · " + (member.phone || "no number on the record")} />}
                    note={index === 0 ? "Called first" : index === values.order.length - 1 ? "Called last, then the police" : "Called " + (index + 1) + (index === 1 ? "nd" : index === 2 ? "rd" : "th")}>
                    <span className="hh-actions__row">
                      <Button variant="ghost" size="icon-sm" aria-label={"Move " + member.name + " earlier"} disabled={index === 0} onClick={() => move(id, -1)}><Icon name="arrow-up" size={14} /></Button>
                      <Button variant="ghost" size="icon-sm" aria-label={"Move " + member.name + " later"} disabled={index === values.order.length - 1} onClick={() => move(id, 1)}><Icon name="arrow-down" size={14} /></Button>
                      <Button variant="ghost" size="sm" disabled={values.order.length === 1} onClick={() => set("order", values.order.filter((x) => x !== id))}>Remove</Button>
                    </span>
                  </Row>
                );
              })}
              {callable.filter((m) => values.order.indexOf(m.id) < 0).map((member) => (
                <Row key={member.id} recessed title={<PersonCell name={member.name} avatar={member.avatar} size={28} meta={labelOf(HOUSEHOLD_ROLES, member.role) + " · not on the list"} />}
                  note={member.phone ? "Reachable on " + member.phone : "No number on the record, so they cannot be called"}>
                  <Button variant="outline" size="sm" disabled={!member.phone} onClick={() => set("order", values.order.concat([member.id]))}>Add</Button>
                </Row>
              ))}
            </Rows>
            {submitted && errors.order ? <p className="hh-footnote hh-danger" style={{ marginTop: "var(--space-3)" }}>{errors.order}</p> : null}
            <div style={{ marginTop: "var(--space-4)", paddingTop: "var(--space-3)", borderTop: "1px solid var(--border)" }}>
              <span className="hh-person">
                <Checkbox checked={values.notifyPolice} aria-label="Escalate to the police" onCheckedChange={(v) => set("notifyPolice", v)} />
                <span className="hh-pair__value">Escalate to the police if nobody answers</span>
              </span>
            </div>
          </Card>
        </Band>
      </SettingsPage>
    );
  }

  /* ── Notifications ───────────────────────────────────────────────────── */
  function NotificationSettingsScreen({ data, prefs, residence, onNavigate, onSave }) {
    const form = useStaged({ rows: prefs.rows.map((r) => Object.assign({}, r)), from: prefs.quietFrom, to: prefs.quietTo });
    const { values, set, submitted } = form;
    const setRow = (id, channel, on) => form.setValues((v) => Object.assign({}, v, { rows: v.rows.map((r) => (r.id === id ? Object.assign({}, r, { [channel]: on }) : r)) }));
    const errors = {};
    if (!/^\d\d:\d\d$/.test(values.from)) errors.from = "Use HH:MM.";
    if (!/^\d\d:\d\d$/.test(values.to)) errors.to = "Use HH:MM.";
    const valid = Object.keys(errors).length === 0;
    const silent = values.rows.filter((r) => !r.push && !r.sms && !r.email);

    return (
      <SettingsPage path="/settings/notifications" title="Notifications" onNavigate={onNavigate}
        lede="What the house tells you about, and when it leaves you alone. Each person also chooses whether they hear about the alarm and about deliveries on their own screen."
        dirty={form.dirty} label="Save notifications"
        note={silent.length ? silent.length + " of " + values.rows.length + " events would tell nobody anything." : "Every event reaches somebody by at least one channel."}
        onCancel={form.reset} onSave={() => { form.setSubmitted(true); if (valid) onSave(values); }}>
        <Band>
          <Card title="Events" note="Checkboxes, because they are submitted with this form rather than applying on their own.">
            <TableWrap>
              <Table>
                <TableHeader>
                  <TableRow>
                    <TableHead>Event</TableHead>
                    <TableHead>Push</TableHead>
                    <TableHead>Text</TableHead>
                    <TableHead>Email</TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {values.rows.map((row) => (
                    <TableRow key={row.id}>
                      <TableCell>
                        <span className="hh-actions__row">
                          {row.label}
                          {!row.push && !row.sms && !row.email ? <Badge size="sm" variant="secondary">Silent</Badge> : null}
                        </span>
                      </TableCell>
                      {["push", "sms", "email"].map((channel) => (
                        <TableCell key={channel}>
                          <Checkbox checked={!!row[channel]} aria-label={row.label + " — " + channel}
                            onCheckedChange={(on) => setRow(row.id, channel, on)} />
                        </TableCell>
                      ))}
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            </TableWrap>
          </Card>
        </Band>
        <Band>
          <Card title="Quiet hours" note={"Nothing is pushed or texted between these times, " + residence.timezone + ". The alarm ignores quiet hours."}>
            <div className="hh-fields">
              <Field>
                <FieldLabel htmlFor="hh-ns-from">From</FieldLabel>
                <Input id="hh-ns-from" value={values.from} invalid={submitted && !!errors.from} onChange={(e) => set("from", e.target.value)} />
                {submitted && errors.from ? <FieldError>{errors.from}</FieldError> : <FieldDescription>24-hour clock.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ns-to">Until</FieldLabel>
                <Input id="hh-ns-to" value={values.to} invalid={submitted && !!errors.to} onChange={(e) => set("to", e.target.value)} />
                {submitted && errors.to ? <FieldError>{errors.to}</FieldError> : null}
              </Field>
            </div>
          </Card>
        </Band>
        <Band>
          <Card tint title="Who hears what" note="Read from each person's own preferences, which are edited on their screen.">
            <Rows>
              {activeMembers(data.members).map((member) => (
                <Row key={member.id} title={<PersonCell name={member.name} avatar={member.avatar} size={28} meta={labelOf(HOUSEHOLD_ROLES, member.role)} onOpen={() => onNavigate("/people/" + member.id)} />}
                  note={[member.prefs.notifyAlarm ? "the alarm" : null, member.prefs.notifyDeliveries ? "deliveries" : null].filter(Boolean).join(" and ") || "nothing — they have both switched off"} />
              ))}
            </Rows>
          </Card>
        </Band>
      </SettingsPage>
    );
  }

  /* ── Data and privacy ────────────────────────────────────────────────── */
  function DataSettingsScreen({ data, now, dataPrefs, onNavigate, onSave, onExportAll }) {
    const form = useStaged({
      keepLogDays: String(dataPrefs.keepLogDays),
      keepClipsDays: String(dataPrefs.keepClipsDays),
      shareWithProviders: dataPrefs.shareWithProviders,
      presenceHistory: dataPrefs.presenceHistory,
    });
    const { values, set, submitted } = form;
    const errors = {};
    if (!/^\d+$/.test(values.keepLogDays) || Number(values.keepLogDays) < 7 || Number(values.keepLogDays) > 730) errors.keepLogDays = "Between 7 and 730 days.";
    if (!/^\d+$/.test(values.keepClipsDays) || Number(values.keepClipsDays) < 1 || Number(values.keepClipsDays) > 90) errors.keepClipsDays = "Between 1 and 90 days.";
    const valid = Object.keys(errors).length === 0;
    const cameras = data.devices.filter((d) => d.kind === "camera");

    return (
      <SettingsPage path="/settings/data" title="Data and privacy" onNavigate={onNavigate}
        lede="A house that watches itself keeps records about the people in it. This is how long it keeps them, and who else sees them."
        dirty={form.dirty} label="Save data settings"
        note={"The log holds " + data.events.length + " entries today; anything older than the window is deleted rather than hidden."}
        onCancel={form.reset} onSave={() => { form.setSubmitted(true); if (valid) onSave(values); }}>
        <Band>
          <Card title="How long the house remembers">
            <div className="hh-fields">
              <Field>
                <FieldLabel htmlFor="hh-ds-log">Keep the log for</FieldLabel>
                <Input id="hh-ds-log" value={values.keepLogDays} invalid={submitted && !!errors.keepLogDays} onChange={(e) => set("keepLogDays", e.target.value)} />
                {submitted && errors.keepLogDays ? <FieldError>{errors.keepLogDays}</FieldError> : <FieldDescription>Days. Everything on the Log screen, including who came and went.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-ds-clips">Keep camera clips for</FieldLabel>
                <Input id="hh-ds-clips" value={values.keepClipsDays} invalid={submitted && !!errors.keepClipsDays} onChange={(e) => set("keepClipsDays", e.target.value)} />
                {submitted && errors.keepClipsDays ? <FieldError>{errors.keepClipsDays}</FieldError> : <FieldDescription>Days, across {cameras.length} cameras.</FieldDescription>}
              </Field>
              <Field className="hh-fields__wide">
                <FieldLabel>What is shared</FieldLabel>
                <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2-5)" }}>
                  <span className="hh-person">
                    <Checkbox checked={values.shareWithProviders} aria-label="Share device history with providers" onCheckedChange={(v) => set("shareWithProviders", v)} />
                    <span className="hh-pair__value">Let a provider see the device history for the job they are on</span>
                  </span>
                  <span className="hh-person">
                    <Checkbox checked={values.presenceHistory} aria-label="Keep presence history" onCheckedChange={(v) => set("presenceHistory", v)} />
                    <span className="hh-pair__value">Keep a record of who was home and when</span>
                  </span>
                </div>
                <FieldDescription>
                  With presence history off, the Log still records what the house did — it stops recording which person did it.
                </FieldDescription>
              </Field>
            </div>
          </Card>
        </Band>
        <Band>
          <Card tint title="Take it with you" note="Everything Hearth holds about this house, in one file.">
            <Rows>
              <Row title="The whole record" value={<Button variant="outline" size="sm" onClick={() => onExportAll("CSV")}>Export</Button>}
                note={data.devices.length + " devices, " + data.events.length + " log entries, " + data.bills.length + " bills, " + data.jobs.length + " jobs, " + data.members.length + " people"} />
              <Row title="Providers who hold a way in" value={data.providers.filter((p) => p.keyHolder).length}
                note={data.providers.filter((p) => p.keyHolder).map((p) => p.name).join(", ") + " — revoke on Access"} />
            </Rows>
          </Card>
        </Band>
      </SettingsPage>
    );
  }

  /* ── Your profile ────────────────────────────────────────────────────── */
  const PHOTO_CHOICES = [(window.__resources["r009"]), (window.__resources["r005"]), (window.__resources["r010"]), (window.__resources["r011"])];
  const MAX_PHOTO_KB = 2048;

  function PhotoUpload({ value, name, fileName, onPick, onClear, error, onError }) {
    const [over, setOver] = React.useState(false);
    const inputRef = React.useRef(null);
    const take = (file) => {
      if (!file) return;
      if (file.type.indexOf("image/") !== 0) return onError("That is a " + (file.type || "file with no type") + ". Use a JPEG or a PNG.");
      if (file.size / 1024 > MAX_PHOTO_KB) return onError("That photograph is " + Math.round((file.size / 1024 / 1024) * 10) / 10 + " MB. The limit is 2 MB.");
      const reader = new FileReader();
      reader.onload = () => { onError(null); onPick(String(reader.result), file.name); };
      reader.onerror = () => onError("That file could not be read. Try saving it again.");
      reader.readAsDataURL(file);
    };
    return (
      <div className="hh-upload">
        <span className="hh-preview">
          <Avatar size={72} src={value || undefined} alt={value ? "Your photograph" : ""} fallback={initialsOf(name)} />
          <span className="hh-footnote">{value ? (fileName || "On the household record") : "No photograph"}</span>
        </span>
        <label className="hh-drop" data-over={over ? "true" : "false"} data-invalid={error ? "true" : "false"}
          onDragOver={(e) => { e.preventDefault(); setOver(true); }}
          onDragLeave={() => setOver(false)}
          onDrop={(e) => { e.preventDefault(); setOver(false); take(e.dataTransfer.files && e.dataTransfer.files[0]); }}>
          <Icon name="image-up" size={20} />
          <span className="hh-drop__title">Drop a photograph here, or choose a file</span>
          <span className="hh-footnote">JPEG or PNG, up to 2 MB. It is what the household sees beside your name.</span>
          <input ref={inputRef} type="file" accept="image/jpeg,image/png" aria-label="Upload a photograph"
            onChange={(e) => { take(e.target.files && e.target.files[0]); e.target.value = ""; }} />
          <span className="hh-actions__row" style={{ marginTop: "var(--space-1)" }}>
            <Button size="sm" variant="outline" onClick={(e) => { e.preventDefault(); inputRef.current && inputRef.current.click(); }}>Choose a file</Button>
            {value ? <Button size="sm" variant="ghost" onClick={(e) => { e.preventDefault(); onError(null); onClear(); }}>Remove photograph</Button> : null}
          </span>
        </label>
      </div>
    );
  }

  function ProfileScreen({ data, now, viewer, onNavigate, onSave }) {
    const initial = { name: viewer.name, email: viewer.email || "", phone: viewer.phone || "", avatar: viewer.avatar || null };
    const [values, setValues] = React.useState(initial);
    const [fileName, setFileName] = React.useState(null);
    const [photoError, setPhotoError] = React.useState(null);
    const [submitted, setSubmitted] = React.useState(false);
    React.useEffect(() => { setValues(initial); setFileName(null); setPhotoError(null); setSubmitted(false); }, [viewer]);
    const set = (id, value) => setValues((v) => Object.assign({}, v, { [id]: value }));
    const dirty = Object.keys(initial).some((key) => values[key] !== initial[key]);

    const errors = {};
    if (!values.name.trim() || values.name.trim().split(/\s+/).length < 2) errors.name = "Give the name the household sees.";
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(values.email.trim())) errors.email = "Enter a valid email address.";
    if (values.phone && !/^[\d ]{9,}$/.test(values.phone.trim())) errors.phone = "Enter a contact number, or leave it blank.";
    const valid = Object.keys(errors).length === 0;
    const inResponse = true;

    return (
      <>
        <PageHead path="/account/profile" title="Your profile"
          lede="Your name, your photograph and how the house reaches you. Your comfort preference, your bedtime and what the house does when you arrive are on your own person screen."
          actions={<Button variant="outline" size="sm" onClick={() => onNavigate("/people/" + viewer.id)}>Your preferences</Button>} />

        <Band>
          <Card title="Photograph" note="Shown beside your name in the log, in the response order and on every screen that names you.">
            <PhotoUpload value={values.avatar} name={values.name} fileName={fileName} error={photoError} onError={setPhotoError}
              onPick={(dataUrl, name) => { set("avatar", dataUrl); setFileName(name); }}
              onClear={() => { set("avatar", null); setFileName(null); }} />
            {photoError ? <p className="hh-footnote hh-danger" style={{ marginTop: "var(--space-3)" }}>{photoError}</p> : null}
            <div className="hh-photos" style={{ marginTop: "var(--space-4)" }}>
              <span className="hh-footnote" style={{ width: "100%" }}>Or use one already on the household record:</span>
              {PHOTO_CHOICES.map((src) => (
                <button type="button" key={src} className="hh-photo" aria-pressed={values.avatar === src}
                  aria-label={values.avatar === src ? "Current photograph, selected" : "Use this photograph"}
                  onClick={() => { setPhotoError(null); set("avatar", src); setFileName(null); }}>
                  <Avatar size={46} src={src} alt="" fallback={initialsOf(values.name)} />
                </button>
              ))}
            </div>
          </Card>
        </Band>

        <Band>
          <Card title="How the house reaches you">
            <div className="hh-fields">
              <Field>
                <FieldLabel htmlFor="hh-me-name">Name</FieldLabel>
                <Input id="hh-me-name" value={values.name} invalid={submitted && !!errors.name} onChange={(e) => set("name", e.target.value)} />
                {submitted && errors.name ? <FieldError>{errors.name}</FieldError> : <FieldDescription>Saving rewrites your name everywhere the log already names you.</FieldDescription>}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-me-email">Email</FieldLabel>
                <Input id="hh-me-email" type="email" autoComplete="email" value={values.email} invalid={submitted && !!errors.email} onChange={(e) => set("email", e.target.value)} />
                {submitted && errors.email ? <FieldError>{errors.email}</FieldError> : null}
              </Field>
              <Field>
                <FieldLabel htmlFor="hh-me-phone">Phone</FieldLabel>
                <Input id="hh-me-phone" value={values.phone} invalid={submitted && !!errors.phone} onChange={(e) => set("phone", e.target.value)} />
                {submitted && errors.phone ? <FieldError>{errors.phone}</FieldError> : <FieldDescription>{inResponse ? "Sentinel Response calls this number first when the alarm goes." : "Not on the response list."}</FieldDescription>}
              </Field>
            </div>
          </Card>
        </Band>

        <Band>
          <Card tint title="Set by the household" note="Your role decides what you can do; only the owner changes it, on People.">
            <Pairs items={[
              { label: "Role", value: labelOf(HOUSEHOLD_ROLES, viewer.role) },
              { label: "Standing", value: <StatusBadge values={MEMBER_STATUS} value={viewer.status} /> },
              { label: "May unlock doors", value: ROLE_CAN_UNLOCK[viewer.role] ? "Yes" : "No" },
              { label: "May arm the alarm", value: ROLE_CAN_ARM[viewer.role] ? "Yes" : "No" },
              { label: "May hand out access", value: ROLE_CAN_GRANT_ACCESS[viewer.role] ? "Yes" : "No" },
              { label: "Your preferences", value: <button type="button" className="hh-linkbtn" onClick={() => onNavigate("/people/" + viewer.id)}>On your person screen</button> },
            ]} />
          </Card>
        </Band>

        <FormBar dirty={dirty} label="Save profile"
          note={values.avatar && values.avatar.indexOf("data:") === 0 ? "The photograph you dropped is a preview until you save." : "Saving rewrites your name and photograph everywhere the house names you."}
          onCancel={() => { setValues(initial); setFileName(null); setPhotoError(null); setSubmitted(false); }}
          onSave={() => { setSubmitted(true); if (valid) onSave(values); }} />
      </>
    );
  }

  Object.assign(window, {
    SettingsTabs, SettingsPage, useStaged, PhotoUpload, PHOTO_CHOICES,
    ResidenceSettingsScreen, TariffSettingsScreen, SecuritySettingsScreen, NotificationSettingsScreen, DataSettingsScreen, ProfileScreen,
  });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="AuthScreens.jsx">
(function(){
/* Auth — its own shell, and the only shell in the product with NO navigation
   owner (AGENTS §7.1): no sidebar, no account menu, no destinations.
   Layout variant, one for the product: SPLIT, with the form side the wider of
   the two. The brand side is a neutral plane carrying one piece of art — a dot
   grid faded out from the bottom-left, geometry rather than illustration.
   Every screen here is STAGED. One state per screen, and a server error and a
   field error never render for the same submission.
   Strategy: password, one credential. No providers are offered because this
   project ships no official provider marks (§7.4) — an entry point without a
   screen behind it is worse than none. */
(() => {
  const {
    Button, Input, Alert, AlertTitle, AlertDescription, Field, FieldLabel, FieldDescription, FieldError, Separator,
  } = window.OriginDesignSystem_7c998b;

  function AuthShell({ title, lede, children, foot }) {
    return (
      <div className="hh-auth">
        <aside className="hh-auth__side">
          <span className="hh-auth__art" aria-hidden="true" />
          <span className="hh-auth__mark">
            <img src={HEARTH_MARK} alt="" width="22" height="22" />
            Hearth
          </span>
          <p className="hh-auth__claim">One place for the house: what it is doing, what it costs, and who is in it.</p>
          <p className="hh-auth__fine">{RESIDENCE.address}. Hearth can unlock doors and disarm the alarm, so everything you open is recorded against your name.</p>
        </aside>
        <main className="hh-auth__formside">
          <div className="hh-auth__form">
            <div>
              <h1 className="hh-auth__title">{title}</h1>
              <p className="hh-auth__lede">{lede}</p>
            </div>
            {children}
            {foot ? <p className="hh-auth__foot">{foot}</p> : null}
          </div>
        </main>
      </div>
    );
  }

  function SignInScreen({ onSignIn, onNavigate }) {
    const [email, setEmail] = React.useState("");
    const [password, setPassword] = React.useState("");
    const [reveal, setReveal] = React.useState(false);
    const [submitted, setSubmitted] = React.useState(false);
    const [rejected, setRejected] = React.useState(false);
    const dirty = !!email || !!password;

    const errors = {};
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim())) errors.email = "Enter a valid email address.";
    if (password.length < 8) errors.password = "Your password is at least 8 characters.";
    const valid = Object.keys(errors).length === 0;

    const submit = () => {
      setSubmitted(true);
      if (!valid) { setRejected(false); return; }
      /* One state per submission: a server error only where there is no field
         error, and the password field is the only thing cleared. */
      if (email.trim().toLowerCase().indexOf("@blackthorn.house") < 0) { setRejected(true); setPassword(""); return; }
      setRejected(false);
      onSignIn();
    };

    return (
      <AuthShell title="Sign in" lede="Use your household account. Every adult in the house has their own."
        foot={<>Not in the household yet? Whoever set the house up can invite you — ask them, rather than making a second account.</>}>
        {rejected ? (
          <Alert variant="error">
            <AlertTitle>Email or password is incorrect</AlertTitle>
            <AlertDescription>Check the address and try again. After five attempts the account locks for fifteen minutes.</AlertDescription>
          </Alert>
        ) : null}
        <div className="hh-auth__fields">
          <Field>
            <FieldLabel htmlFor="hh-si-email">Email</FieldLabel>
            <Input id="hh-si-email" type="email" autoComplete="email" value={email} placeholder="sofia@blackthorn.house"
              invalid={submitted && !!errors.email} onChange={(e) => setEmail(e.target.value)} />
            {submitted && errors.email ? <FieldError>{errors.email}</FieldError> : null}
          </Field>
          <Field>
            <span className="hh-auth__row">
              <FieldLabel htmlFor="hh-si-password">Password</FieldLabel>
              <Button variant="link" size="xs" onClick={() => onNavigate("/forgot-password")}>Forgot password?</Button>
            </span>
            <Input id="hh-si-password" type={reveal ? "text" : "password"} autoComplete="current-password" value={password}
              invalid={submitted && !!errors.password} onChange={(e) => setPassword(e.target.value)}
              endAddon={
                <Button variant="ghost" size="icon-xs" aria-label={reveal ? "Hide password" : "Show password"} onClick={() => setReveal(!reveal)}>
                  <Icon name={reveal ? "eye-off" : "eye"} size={14} />
                </Button>
              } />
            {submitted && errors.password ? <FieldError>{errors.password}</FieldError> : null}
          </Field>
          <Button disabled={!dirty} onClick={submit}>Sign in</Button>
        </div>
      </AuthShell>
    );
  }

  function ForgotPasswordScreen({ email, onSubmit, onNavigate }) {
    const [value, setValue] = React.useState(email || "");
    const [submitted, setSubmitted] = React.useState(false);
    const error = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value.trim()) ? null : "Enter a valid email address.";
    return (
      <AuthShell title="Forgot password" lede="We will send a link that sets a new password. It works once and expires after an hour."
        foot={<>Remembered it? <Button variant="link" size="xs" onClick={() => onNavigate("/signin")}>Back to sign in</Button></>}>
        <div className="hh-auth__fields">
          <Field>
            <FieldLabel htmlFor="hh-fp-email">Email</FieldLabel>
            <Input id="hh-fp-email" type="email" autoComplete="username" value={value} placeholder="sofia@blackthorn.house"
              invalid={submitted && !!error} onChange={(e) => setValue(e.target.value)} />
            {submitted && error ? <FieldError>{error}</FieldError> : <FieldDescription>The address the household invitation went to.</FieldDescription>}
          </Field>
          <Button disabled={!value} onClick={() => { setSubmitted(true); if (!error) onSubmit(value.trim()); }}>Send reset link</Button>
        </div>
      </AuthShell>
    );
  }

  function ResetLinkSentScreen({ email, onNavigate }) {
    return (
      <AuthShell title="Check your email"
        lede={"If an account exists for " + email + ", we have sent a link that sets a new password. It expires in " + HEARTH_RESET_LINK.expiresInMinutes + " minutes."}
        foot={<>Nothing within ten minutes? Check the junk folder. Another adult in the household can also reset it for you.</>}>
        <div className="hh-auth__fields">
          <Button variant="outline" onClick={() => onNavigate("/forgot-password")}>Send it again</Button>
          <Separator />
          <Button variant="link" onClick={() => onNavigate("/signin")}>Back to sign in</Button>
        </div>
      </AuthShell>
    );
  }

  function SetNewPasswordScreen({ now, onSubmit, onNavigate }) {
    const [value, setValue] = React.useState("");
    const [reveal, setReveal] = React.useState(false);
    const [submitted, setSubmitted] = React.useState(false);
    const met = PASSWORD_RULES.map((rule) => ({ id: rule.id, label: rule.label, ok: rule.test(value) }));
    const valid = met.every((r) => r.ok);
    const minutesLeft = Math.max(0, HEARTH_RESET_LINK.expiresInMinutes - Math.round((now - HEARTH_RESET_LINK.issuedAt) / 6e4));
    return (
      <AuthShell title="Set a new password"
        lede={"For " + HEARTH_RESET_LINK.email + ". This link expires in " + minutesLeft + " minutes and works once."}
        foot={<>Expired while you were reading? <Button variant="link" size="xs" onClick={() => onNavigate("/reset-password/expired")}>See what to do</Button></>}>
        <div className="hh-auth__fields">
          <Field>
            <FieldLabel htmlFor="hh-rp-password">New password</FieldLabel>
            <Input id="hh-rp-password" type={reveal ? "text" : "password"} autoComplete="new-password" value={value}
              invalid={submitted && !valid} onChange={(e) => setValue(e.target.value)}
              endAddon={
                <Button variant="ghost" size="icon-xs" aria-label={reveal ? "Hide password" : "Show password"} onClick={() => setReveal(!reveal)}>
                  <Icon name={reveal ? "eye-off" : "eye"} size={14} />
                </Button>
              } />
            {submitted && !valid ? <FieldError>Your password does not meet every requirement yet.</FieldError> : null}
          </Field>
          <div className="hh-checklist">
            {met.map((rule) => (
              <span className="hh-checklist__item" key={rule.id} data-met={rule.ok ? "true" : "false"}>
                <Icon name={rule.ok ? "check" : "dot"} size={14} />{rule.label}
              </span>
            ))}
          </div>
          <Button disabled={!value} onClick={() => { setSubmitted(true); if (valid) onSubmit(); }}>Set new password</Button>
        </div>
      </AuthShell>
    );
  }

  function ResetLinkExpiredScreen({ onNavigate }) {
    return (
      <AuthShell title="That link has expired"
        lede={"A reset link works once and lasts " + HEARTH_RESET_LINK.expiresInMinutes + " minutes. Ask for a new one and it will arrive within a minute."}
        foot={<>If you did not ask for a reset, tell the other adults in the household — somebody may be trying the account.</>}>
        <div className="hh-auth__fields">
          <Button onClick={() => onNavigate("/forgot-password")}>Send a new link</Button>
          <Button variant="link" onClick={() => onNavigate("/signin")}>Back to sign in</Button>
        </div>
      </AuthShell>
    );
  }

  function PasswordResetDoneScreen({ email, onNavigate }) {
    return (
      <AuthShell title="Password changed"
        lede={"The password for " + email + " has been changed, and every other session has been signed out — including the tablet in the kitchen."}
        foot={<>Did not change it yourself? Sign in, then suspend your own access on People and ask another adult to help.</>}>
        <div className="hh-auth__fields">
          <Button onClick={() => onNavigate("/signin")}>Sign in</Button>
        </div>
      </AuthShell>
    );
  }

  Object.assign(window, { AuthShell, SignInScreen, ForgotPasswordScreen, ResetLinkSentScreen, SetNewPasswordScreen, ResetLinkExpiredScreen, PasswordResetDoneScreen });
})();

})();
</script>
<script type="text/babel" data-presets="react" data-file="HouseCommits.jsx">
(function(){
/* Hearth — every commit the house makes.
   Kept out of App.jsx so the router stays readable. The factory is given the
   commit machinery it needs and nothing else.

   Reversibility, decided once (§3):
     · instant and harmless   → a bare control (a switch, a filter, a day pick)
     · instant, consequential → applies immediately plus an undo toast
     · irreversible           → a confirm dialog: removing a device, removing
                                somebody from the household, revoking access,
                                cancelling a job, calling the response company
     · needs two decisions    → a staged dialog opened from an instant screen:
                                setting a device up, booking or quoting a job,
                                finishing or fixing a routine, denying a request,
                                inviting somebody, opening a new way in
   Nothing here computes a total: a commit writes a status, a time or a row, and
   metrics.js sums the rest — so every figure that claims to derive from a commit
   actually moves. */
function makeHouseCommits(ctx) {
  const data = ctx.data, setData = ctx.setData, patch = ctx.patch, restore = ctx.restore;
  const pushToast = ctx.pushToast, setForm = ctx.setForm, setConfirm = ctx.setConfirm;
  const viewer = ctx.viewer, now = ctx.now, setPath = ctx.setPath, logEvent = ctx.logEvent, unlogEvent = ctx.unlogEvent;

  /* ── Devices ───────────────────────────────────────────────────────────── */
  const toggleDevice = (device) => {
    patch("devices", device.id, { on: !device.on, watts: !device.on ? device.watts || 20 : 0 });
    /* Harmless: a light is a switch, and the switch itself is the feedback. */
  };

  const onDeviceTransition = (device, transition) => {
    if (transition.id === "turn_on" || transition.id === "turn_off") return toggleDevice(device);
    if (transition.id === "lock" || transition.id === "unlock") {
      const locking = transition.id === "lock";
      const entry = data.entries.find((e) => e.deviceId === device.id);
      patch("devices", device.id, { on: locking });
      if (entry) patch("entries", entry.id, { status: locking ? "locked" : "unlocked", changedAt: now, byId: viewer.id });
      const event = logEvent("security", { type: entry ? "entry" : "device", id: entry ? entry.id : device.id },
        (entry ? entry.label : device.name) + (locking ? " locked" : " unlocked"), viewer.name.split(" ")[0] + ", from Hearth");
      return pushToast({
        variant: locking ? "success" : undefined,
        title: locking ? "Locked" : "Unlocked",
        description: (entry ? entry.label : device.name) + (locking ? " is locked." : " is unlocked — " + (isDark(now) ? "it is dark, so it counts against the house." : "it will count against the house after " + HOUSE_POLICY.darkAfter + ".")),
        onUndo: () => { restore("devices", device); if (entry) restore("entries", entry); unlogEvent(event); },
      });
    }
    if (transition.id === "retry") {
      patch("devices", device.id, { status: "online", lastSeenAt: now, battery: device.battery === 0 ? 3 : device.battery });
      const event = logEvent("device", { type: "device", id: device.id }, device.name + " reconnected", "Retried from Hearth");
      return pushToast({
        variant: "success", title: "Reconnected",
        description: device.name + " is reporting again" + (device.battery === 0 ? ", on a nearly flat battery — it will drop out again within the day." : "."),
        onUndo: () => { restore("devices", device); unlogEvent(event); },
      });
    }
    if (transition.id === "battery_replaced") {
      patch("devices", device.id, { battery: 100, status: device.status === "offline" ? "online" : device.status, lastSeenAt: now });
      const event = logEvent("device", { type: "device", id: device.id }, device.name + " battery replaced", "Was " + device.battery + "%");
      return pushToast({ variant: "success", title: "Battery replaced", description: device.name + " is at 100%.", onUndo: () => { restore("devices", device); unlogEvent(event); } });
    }
    if (transition.id === "retire") {
      const routines = routinesForDevice(routineRows(data, now), device.id);
      patch("devices", device.id, { status: "retired", on: false, watts: 0 });
      const event = logEvent("device", { type: "device", id: device.id }, device.name + " switched out of the house", null);
      return pushToast({
        title: "Switched out of the house",
        description: device.name + " is out." + (routines.length ? " " + routines.length + (routines.length === 1 ? " routine that names it is now broken." : " routines that name it are now broken.") : ""),
        onUndo: () => { restore("devices", device); unlogEvent(event); },
      });
    }
    if (transition.id === "restore") {
      patch("devices", device.id, { status: "online", lastSeenAt: now });
      const event = logEvent("device", { type: "device", id: device.id }, device.name + " switched back on", null);
      return pushToast({ variant: "success", title: "Back in the house", description: device.name + " is reporting again, and any routine that names it works.", onUndo: () => { restore("devices", device); unlogEvent(event); } });
    }
    if (transition.id === "set_up") {
      return setForm({
        title: "Set up " + device.name,
        description: "It has been on the network since " + formatWhen(device.addedAt, now) + " without being told where it is or what it does.",
        submitLabel: "Set up device",
        initial: { room: device.room || "garden", name: device.name },
        fields: [
          { id: "name", kind: "input", label: "Name", hint: "What the household will call it." },
          { id: "room", kind: "select", label: "Room", options: ROOMS },
        ],
        validate: (v) => (!v.name || v.name.trim().length < 3 ? { name: "Give it a name." } : {}),
        run: (v) => {
          const jobs = data.jobs.filter((j) => j.deviceId === device.id && j.status !== "done");
          patch("devices", device.id, { status: "online", on: true, name: v.name.trim(), room: v.room, lastSeenAt: now });
          const event = logEvent("device", { type: "device", id: device.id }, v.name.trim() + " set up", roomName(v.room));
          pushToast({
            variant: "success", title: "Set up",
            description: v.name.trim() + " is in the " + roomName(v.room).toLowerCase() + " and reporting." + (jobs.length ? " " + jobs[0].title + " no longer blocks the monitoring renewal." : ""),
            onUndo: () => { restore("devices", device); unlogEvent(event); },
          });
        },
      });
    }
    if (transition.id === "remove") {
      const routines = routinesForDevice(routineRows(data, now), device.id);
      const jobs = jobsForDevice(jobRows(data, now), device.id);
      return setConfirm({
        title: "Remove " + device.name + "?",
        description: "It leaves the house for good, with its history."
          + (routines.length ? " " + routines.length + (routines.length === 1 ? " routine names it and will break." : " routines name it and will break.") : "")
          + (jobs.length ? " " + jobs.length + (jobs.length === 1 ? " job is against it." : " jobs are against it.") : "")
          + " Switching it out of the house instead keeps the record.",
        confirmLabel: "Remove device",
        run: () => {
          const snapshot = data.devices;
          setData((d) => Object.assign({}, d, { devices: d.devices.filter((x) => x.id !== device.id) }));
          logEvent("device", { type: "device", id: device.id }, device.name + " removed", "With its history");
          setPath("/devices");
          pushToast({ variant: "error", title: "Removed", description: device.name + " is gone. " + routinesNeedingYou(routineRows(Object.assign({}, data, { devices: snapshot.filter((x) => x.id !== device.id) }), now)).length + " routines now want a person." });
        },
      });
    }
  };

  const bulkOff = (devices) => {
    const snapshot = data.devices;
    const ids = devices.map((d) => d.id);
    setData((d) => Object.assign({}, d, { devices: d.devices.map((x) => (ids.indexOf(x.id) > -1 ? Object.assign({}, x, { on: false, watts: 0 }) : x)) }));
    pushToast({
      title: devices.length + (devices.length === 1 ? " device off" : " devices off"),
      description: devices.map((d) => d.name.toLowerCase()).join(", ") + " · " + formatWatts(devices.reduce((n, d) => n + d.watts, 0)) + " no longer being drawn",
      onUndo: () => setData((d) => Object.assign({}, d, { devices: snapshot })),
    });
  };

  const allOffDownstairs = () => {
    const downstairs = ["kitchen", "hall", "living", "study"];
    const lit = data.devices.filter((d) => d.on && d.status === "online" && d.kind === "light" && downstairs.indexOf(d.room) > -1);
    if (!lit.length) return;
    bulkOff(lit);
  };

  /* Taking a device INTO the house is two decisions, so it is a staged dialog:
     what it is, where it lives, and then proof that it answers. Nothing is
     written until the last stage — a half-paired device would be a lie the whole
     product then has to carry. */
  const pairDevice = (found) => setForm({
    title: found && found.announced ? "Pair " + found.announced.toLowerCase() : "Pair a device",
    submitLabel: "Take it into the house",
    initial: {
      name: "", kind: found ? found.suggestKind : "light", room: found ? found.suggestRoom : "kitchen",
      dbm: found ? found.dbm : -55, discoveredId: found ? found.id : null, answered: false,
    },
    steps: [
      {
        title: "What it is",
        description: found
          ? "The hub heard this announce itself " + formatWhen(found.foundAt, now) + " on a " + labelOf(SIGNAL_BANDS, signalBand(found.dbm)).toLowerCase() + " signal. Say what it is before the house takes it in."
          : "Hold the device's button until it flashes, then tell the house what to expect.",
        nextLabel: "Choose a room",
        fields: [
          { id: "kind", kind: "select", label: "Kind", options: DEVICE_KINDS, hint: "This decides what control the house gives it — a switch, a lock button, or nothing at all for a sensor." },
        ],
      },
      {
        title: "Where it lives",
        description: "A device's room is what puts it on a room card, in the draw-by-room figures and in reach of a routine.",
        nextLabel: "Test it",
        fields: [
          { id: "room", kind: "select", label: "Room", options: ROOMS },
          { id: "name", kind: "input", label: "Name", placeholder: "Landing light", hint: "What it will be called everywhere in the house." },
        ],
        validate: (v) => {
          const errors = {};
          if (!v.name || v.name.trim().length < 3) errors.name = "Give it a name.";
          else if (data.devices.some((d) => d.name.toLowerCase() === v.name.trim().toLowerCase())) errors.name = "There is already a device called that.";
          return errors;
        },
      },
      {
        title: "Prove it answers",
        description: "The hub asks the device to flash. Confirm it did, and the reading it came back with becomes its link — which is what every signal bar and connection test in the house reads afterwards.",
        fields: [
          { id: "reading", kind: "note", label: "What came back", body: (v) => "A " + labelOf(SIGNAL_BANDS, signalBand(v.dbm)).toLowerCase() + " signal at " + v.dbm + " dBm from the " + roomName(v.room).toLowerCase() + ", against " + averageLatency(data) + "ms average across the house." },
          { id: "answered", kind: "checkbox", label: "Confirm", checkboxLabel: "It flashed and answered the hub" },
        ],
        validate: (v) => (v.answered ? {} : { answered: "The house will not take in a device that has not answered. Retry the pairing if it did not flash." }),
      },
    ],
    run: (v) => {
      const id = "dev_" + Math.random().toString(36).slice(2, 6);
      const latency = Math.max(9, Math.round((Math.abs(v.dbm) - 38) * 2.2));
      const device = { id: id, name: v.name.trim(), kind: v.kind, room: v.room, status: "online", on: false, watts: 0, battery: v.kind === "sensor" || v.kind === "lock" ? 100 : null, lastSeenAt: now, addedAt: now };
      /* A camera is a device PLUS a camera record — the shutter, the floodlight,
         the two-way audio. Without the second one it would sit on Devices and be
         invisible to the live view, the tiles, the shutters and every camera
         count, because all of those read cameraRows. */
      const camera = v.kind === "camera"
        ? { deviceId: id, view: "Not aimed yet — " + roomName(v.room).toLowerCase(), privacy: false, recording: true, talk: false, floodlight: false, floodlightOn: false, nightVision: true, resolution: "1080p", clipSeconds: 30 }
        : null;
      setData((d) => Object.assign({}, d, {
        devices: d.devices.concat([device]),
        cameras: camera ? d.cameras.concat([camera]) : d.cameras,
        links: Object.assign({}, d.links, { [id]: { dbm: v.dbm, latencyMs: latency, firmware: "1.0.0", latest: "1.0.0", testedAt: now, result: "pass" } }),
        discovered: v.discoveredId ? d.discovered.filter((x) => x.id !== v.discoveredId) : d.discovered,
      }));
      const event = logEvent("device", { type: "device", id: id }, device.name + " paired", "Answered in " + latency + "ms from the " + roomName(v.room).toLowerCase());
      pushToast({
        variant: "success", title: device.name + " is in the house",
        description: camera
          ? "It answered in " + latency + "ms and is recording with its shutter open, so it counts among the cameras watching — point it at something on the cameras screen."
          : "It answered in " + latency + "ms, so it is online rather than waiting to be set up — nothing is added to the " + devicesNeedingYou(data.devices).length + " devices wanting a person.",
        onUndo: () => {
          setData((d) => Object.assign({}, d, {
            devices: d.devices.filter((x) => x.id !== id),
            cameras: camera ? d.cameras.filter((c) => c.deviceId !== id) : d.cameras,
            discovered: found ? d.discovered.concat([found]) : d.discovered,
          }));
          unlogEvent(event);
        },
      });
    },
  });

  /* ── The radio: tests and firmware ───────────────────────────────────────
     A test writes down what came back and nothing else. It is not undoable — a
     reading is not a decision — so it applies with a plain toast, and every
     "weak", "behind" and "stale" on any screen is derived from these records. */
  const setLink = (id, changes) => setData((d) => Object.assign({}, d, { links: Object.assign({}, d.links, { [id]: Object.assign({}, d.links[id] || {}, changes) }) }));

  /* Only an OFFLINE device can fail a test. Updating, never set up and switched
     out of the house already mean something else in this product's vocabulary —
     nothing can be asked of them — so the test leaves them out rather than
     inventing a failure the cameras screen would then contradict. */
  const testable = (device) => device.status === "online" || device.status === "offline";
  const resultFor = (device, link) => (device.status === "offline" ? "fail" : link && link.latencyMs != null && link.latencyMs > 60 ? "slow" : "pass");

  const testDevice = (device) => {
    const link = data.links[device.id];
    if (!testable(device)) {
      return pushToast({
        title: device.name + " cannot be tested",
        description: statusValue(DEVICE_STATUS, device.status).label + " — " + (device.status === "updating" ? "nothing can be asked of it until it finishes." : device.status === "setup_pending" ? "it has to be set up before the hub can ask it anything." : "it has been switched out of the house."),
      });
    }
    const result = resultFor(device, link);
    setLink(device.id, { testedAt: now, result: result });
    pushToast({
      variant: result === "fail" ? "warning" : "success",
      title: result === "fail" ? device.name + " did not answer" : device.name + " answered",
      description: result === "fail"
        ? "Nothing has been heard from it since " + formatWhen(device.lastSeenAt, now) + ", so it stays among the devices wanting a person."
        : "Back in " + link.latencyMs + "ms on a " + labelOf(SIGNAL_BANDS, signalBand(link.dbm)).toLowerCase() + " signal" + (result === "slow" ? " — slower than the " + averageLatency(data) + "ms average, which is what a weak signal looks like." : "."),
    });
  };

  const runHouseTest = () => {
    const rows = devicesInHouse(data.devices).filter(testable);
    const skipped = devicesInHouse(data.devices).filter((d) => !testable(d));
    const failing = rows.filter((d) => resultFor(d, data.links[d.id]) === "fail");
    const slow = rows.filter((d) => resultFor(d, data.links[d.id]) === "slow");
    setData((d) => {
      const links = Object.assign({}, d.links);
      rows.forEach((device) => { links[device.id] = Object.assign({}, links[device.id] || {}, { testedAt: now, result: resultFor(device, links[device.id]) }); });
      return Object.assign({}, d, { links: links, hub: Object.assign({}, d.hub, { testedAt: now }) });
    });
    pushToast({
      variant: failing.length ? "warning" : "success",
      title: rows.length + " devices asked",
      description: (failing.length
        ? failing.length + " did not answer at all" + (slow.length ? ", " + slow.length + " were slow" : "") + ". Everything on the devices screen moves with this."
        : slow.length
          ? "Everything answered, " + slow.length + " of them slowly. Average " + averageLatency(data) + "ms."
          : "Everything answered inside " + averageLatency(data) + "ms on average.")
        + (skipped.length ? " " + skipped.length + (skipped.length === 1 ? " device was" : " devices were") + " left out — " + skipped.map((d) => d.name + " is " + statusValue(DEVICE_STATUS, d.status).label.toLowerCase()).join(", ") + "." : ""),
    });
  };

  /* An update is consequential and takes the device out of reach while it runs,
     so it applies at once with the way back offered. */
  const updateFirmware = (devices) => {
    const ids = devices.map((d) => d.id);
    const before = devices.map((d) => ({ id: d.id, status: d.status, firmware: (data.links[d.id] || {}).firmware }));
    setData((d) => {
      const links = Object.assign({}, d.links);
      ids.forEach((id) => { links[id] = Object.assign({}, links[id] || {}, { firmware: (links[id] || {}).latest }); });
      return Object.assign({}, d, { links: links, devices: d.devices.map((x) => (ids.indexOf(x.id) > -1 && x.status === "online" ? Object.assign({}, x, { status: "updating" }) : x)) });
    });
    pushToast({
      variant: "success",
      title: ids.length === 1 ? devices[0].name + " is updating" : ids.length + " devices are updating",
      description: "While a device updates nothing can be asked of it, so its row recedes rather than going grey and disabled.",
      onUndo: () => setData((d) => {
        const links = Object.assign({}, d.links);
        before.forEach((b) => { links[b.id] = Object.assign({}, links[b.id] || {}, { firmware: b.firmware }); });
        return Object.assign({}, d, { links: links, devices: d.devices.map((x) => { const b = before.find((y) => y.id === x.id); return b ? Object.assign({}, x, { status: b.status }) : x; }) });
      }),
    });
  };

  /* ── Cameras ───────────────────────────────────────────────────────────── */
  const patchCamera = (camera, changes) => setData((d) => Object.assign({}, d, { cameras: d.cameras.map((c) => (c.deviceId === camera.deviceId ? Object.assign({}, c, changes) : c)) }));

  /* Closing a shutter stops the house recording, which is exactly the kind of
     move that wants the way back offered rather than a confirmation first. */
  const cameraPrivacy = (camera) => {
    const closing = !camera.privacy;
    patchCamera(camera, { privacy: closing });
    const event = logEvent("device", { type: "device", id: camera.deviceId }, camera.name + (closing ? " shutter closed" : " shutter opened"), closing ? "Nothing is recorded until it is opened" : null);
    pushToast({
      variant: closing ? "warning" : "success",
      title: closing ? camera.name + " has stopped recording" : camera.name + " is watching again",
      description: closing
        ? "It is still one of the four cameras, but it no longer counts as watching — the count in the sidebar moves with it."
        : "Motion from this camera is kept " + DATA_PREFS.keepClipsDays + " days, then deleted.",
      onUndo: () => { patchCamera(camera, { privacy: !closing }); unlogEvent(event); },
    });
  };

  const cameraAction = (camera, transition) => {
    if (transition.id === "privacy_on" || transition.id === "privacy_off") return cameraPrivacy(camera);
    if (transition.id === "commission_camera") return setPath("/devices/" + camera.deviceId);
    if (transition.id === "test_device") return testDevice(camera.device);
    if (transition.id === "floodlight") {
      const lighting = !camera.floodlightOn;
      patchCamera(camera, { floodlightOn: lighting });
      return pushToast({
        title: camera.name + " floodlight " + (lighting ? "on" : "off"),
        description: lighting ? "It turns itself off again after ten minutes." : "Back to whatever the routines ask of it.",
        onUndo: () => patchCamera(camera, { floodlightOn: !lighting }),
      });
    }
    if (transition.id === "clip_now") {
      const id = "clp_" + Math.random().toString(36).slice(2, 6);
      const clip = { id: id, deviceId: camera.deviceId, at: now, kind: "motion", seconds: camera.clipSeconds, note: "Recorded by hand from the live view" };
      setData((d) => Object.assign({}, d, { clips: d.clips.concat([clip]) }));
      return pushToast({
        variant: "success", title: camera.clipSeconds + "-second clip recorded",
        description: "It joins the " + (clipsToday(data.clips, now).length + 1) + " clips from today and is kept " + DATA_PREFS.keepClipsDays + " days.",
        onUndo: () => setData((d) => Object.assign({}, d, { clips: d.clips.filter((c) => c.id !== id) })),
      });
    }
    if (transition.id === "talk") return pushToast({ title: "Talking through " + camera.name, description: "The porch speaker is open until you close it. Nothing is recorded of your side." });
    return undefined;
  };

  /* ── Climate ───────────────────────────────────────────────────────────── */
  const zoneStep = (zone, delta) => {
    /* Stepping a living zone is an override on top of the derived target, not a
       new stored temperature: the chain stays the source. */
    if (zone.targetC == null) return;
    const stored = data.zones.find((z) => z.id === zone.id);
    if (stored.targetC != null) {
      patch("zones", zone.id, { targetC: Math.round((stored.targetC + delta) * 2) / 2 });
      return pushToast({
        title: zoneName(zone.id) + " set to " + formatTemp(stored.targetC + delta) + "°",
        description: "This zone holds its own setpoint rather than following anybody's preference.",
        onUndo: () => restore("zones", stored),
      });
    }
    const member = comfortSetter(data.members, now);
    if (!member) return pushToast({ title: "Nothing to change", description: "Nobody is home and awake, so the house is on its away setback — change that in Settings." });
    const next = Math.round((member.prefs.comfortC + delta) * 2) / 2;
    patch("members", member.id, { prefs: Object.assign({}, member.prefs, { comfortC: next }) });
    const event = logEvent("climate", { type: "zone", id: zone.id }, zoneName(zone.id) + " " + (delta > 0 ? "warmer" : "cooler") + ", " + formatTemp(next) + "°", member.name.split(" ")[0] + "'s preference moved");
    pushToast({
      title: formatTemp(next) + "° wanted",
      description: "The target comes from " + member.name + "'s comfort preference, so that is what moved. Every zone following the household target moved with it.",
      onUndo: () => { restore("members", member); unlogEvent(event); },
    });
  };

  const zoneMode = (zone, mode) => {
    const stored = data.zones.find((z) => z.id === zone.id);
    patch("zones", zone.id, { status: mode, overrideUntil: mode === "boost" ? now + HOUSE_POLICY.boostMinutes * MINUTE_MS : null });
    const event = logEvent("climate", { type: "zone", id: zone.id }, zoneName(zone.id) + " " + statusValue(ZONE_STATUS, mode).label.toLowerCase(), mode === "boost" ? HOUSE_POLICY.boostMinutes + " minutes" : null);
    pushToast({
      title: zoneName(zone.id) + " — " + statusValue(ZONE_STATUS, mode).label.toLowerCase(),
      description: mode === "boost" ? "Holding 1.5° over the target until " + formatWhen(now + HOUSE_POLICY.boostMinutes * MINUTE_MS, now) + "."
        : mode === "comfort" ? "Following the household target of " + formatTemp(comfortTarget(data.members, now).c) + "°."
          : "Nothing will be called for in this zone.",
      onUndo: () => { restore("zones", stored); unlogEvent(event); },
    });
  };

  const editPreference = (member) => setForm({
    title: member.name + "'s comfort preference",
    description: "The house holds the warmest preference among the people home and awake. " + (MEMBER_FLAGS.countsForComfort(member, now) ? "Theirs counts right now." : "Theirs is ignored until they are home and up."),
    submitLabel: "Save preference",
    initial: { comfortC: String(member.prefs.comfortC), bedtime: member.prefs.bedtime },
    fields: [
      { id: "comfortC", kind: "input", label: "Comfortable at", hint: "Degrees, between 15 and 24." },
      { id: "bedtime", kind: "input", label: "Goes to bed", hint: "HH:MM. Also when their preference stops counting." },
    ],
    validate: (v) => {
      const errors = {};
      const c = Number(v.comfortC);
      if (!(c >= 15 && c <= 24)) errors.comfortC = "Between 15 and 24 degrees.";
      if (!/^\d\d:\d\d$/.test(v.bedtime || "")) errors.bedtime = "Use HH:MM.";
      return errors;
    },
    run: (v) => {
      const before = comfortTarget(data.members, now).c;
      patch("members", member.id, { prefs: Object.assign({}, member.prefs, { comfortC: Number(v.comfortC), bedtime: v.bedtime }) });
      const after = comfortTarget(data.members.map((m) => (m.id === member.id ? Object.assign({}, m, { prefs: Object.assign({}, m.prefs, { comfortC: Number(v.comfortC), bedtime: v.bedtime }) }) : m)), now).c;
      pushToast({
        variant: "success", title: "Preference saved",
        description: member.name + " wants " + formatTemp(Number(v.comfortC)) + "° · " + (after !== before ? "the house moves from " + formatTemp(before) + "° to " + formatTemp(after) + "°" : "the house still holds " + formatTemp(after) + "°"),
        onUndo: () => restore("members", member),
      });
    },
  });

  const savePreferences = (member, values) => {
    const before = comfortTarget(data.members, now).c;
    patch("members", member.id, { prefs: Object.assign({}, member.prefs, values) });
    const after = comfortTarget(data.members.map((m) => (m.id === member.id ? Object.assign({}, m, { prefs: Object.assign({}, m.prefs, values) }) : m)), now).c;
    pushToast({
      variant: "success", title: "Preferences saved",
      description: after !== before ? "The house moves from " + formatTemp(before) + "° to " + formatTemp(after) + "°, and every zone following the household target with it."
        : "The house still holds " + formatTemp(after) + "° · the bedtime routines read the new times.",
      onUndo: () => restore("members", member),
    });
  };

  /* ── Security ──────────────────────────────────────────────────────────── */
  const onArm = (transition) => {
    const before = data.house;
    const next = { arm_home: "armed_home", arm_away: "armed_away", disarm: "disarmed", clear_fault: "disarmed" }[transition.id];
    if (transition.id === "call_response") return callResponse();
    setData((d) => Object.assign({}, d, { house: Object.assign({}, d.house, { alarm: next, alarmChangedAt: now, alarmChangedById: viewer.id, lastArmedAwayAt: next === "armed_away" ? now : d.house.lastArmedAwayAt }) }));
    const event = logEvent("security", { type: "house", id: "alarm" }, "Alarm " + statusValue(ALARM_STATUS, next).label.toLowerCase(), viewer.name.split(" ")[0]);
    const insecureAfter = entriesInsecure(Object.assign({}, data, { house: Object.assign({}, data.house, { alarm: next }) }), now);
    pushToast({
      variant: next === "disarmed" ? undefined : "success",
      title: "Alarm " + statusValue(ALARM_STATUS, next).label.toLowerCase(),
      description: insecureAfter.length ? insecureAfter.length + (insecureAfter.length === 1 ? " way in is" : " ways in are") + " not secure on these rules: " + insecureAfter.map((e) => e.label.toLowerCase()).join(", ") + "." : "Everything is shut and locked.",
      onUndo: () => { setData((d) => Object.assign({}, d, { house: before })); unlogEvent(event); },
    });
  };

  const callResponse = () => setConfirm({
    title: "Call Sentinel Response?",
    description: "The control room answers on " + RESIDENCE.responseNumber + " and will send somebody, then ring the household in order. A call is logged with the insurer and counts towards the policy's false-alarm allowance.",
    confirmLabel: "Call the control room",
    run: () => {
      setData((d) => Object.assign({}, d, { house: Object.assign({}, d.house, { responseCalled: true }) }));
      logEvent("security", { type: "house", id: "alarm" }, "Sentinel Response called", viewer.name.split(" ")[0] + " · " + RESIDENCE.responseNumber);
      pushToast({ variant: "error", title: "Control room called", description: "They will ring " + memberName(data.members, RESPONSE.order[0]).split(" ")[0] + " first, then " + memberName(data.members, RESPONSE.order[1]).split(" ")[0] + "." });
    },
  });

  const onEntry = (entry, transition) => {
    if (transition.id === "open_device") return setPath("/devices/" + transition.deviceId);
    if (transition.id === "acknowledge") {
      patch("entries", entry.id, { status: "locked", changedAt: now, byId: viewer.id });
      const event = logEvent("security", { type: "entry", id: entry.id }, entry.label + " shut", viewer.name.split(" ")[0] + " confirmed it");
      return pushToast({
        variant: "success", title: entry.label + " shut",
        description: "Recorded against your name. If the sensor disagrees it will report open again within a minute.",
        onUndo: () => { restore("entries", entry); unlogEvent(event); },
      });
    }
    const locking = transition.id === "lock";
    const device = entry.deviceId ? deviceById(data.devices, entry.deviceId) : null;
    patch("entries", entry.id, { status: locking ? "locked" : "unlocked", changedAt: now, byId: viewer.id });
    if (device) patch("devices", device.id, { on: locking });
    const event = logEvent("security", { type: "entry", id: entry.id }, entry.label + (locking ? " locked" : " unlocked"), viewer.name.split(" ")[0] + ", from Hearth");
    pushToast({
      variant: locking ? "success" : undefined,
      title: entry.label + (locking ? " locked" : " unlocked"),
      description: locking ? entriesInsecure(data, now).length - 1 + " ways in still not secure." : isDark(now) ? "It is dark, so this counts against the house." : "It will count against the house after " + HOUSE_POLICY.darkAfter + ".",
      onUndo: () => { restore("entries", entry); if (device) restore("devices", device); unlogEvent(event); },
    });
  };

  /* ── People and requests ───────────────────────────────────────────────── */
  const onRequest = (request, transition) => {
    const member = memberById(data.members, request.memberId);
    if (transition.id === "grant") {
      patch("requests", request.id, { status: "granted", answeredAt: now, answeredById: viewer.id, reason: null });
      /* Granting an unlock actually unlocks it — an answer nobody acts on is not
         an answer. */
      let extra = "";
      if (request.kind === "unlock") {
        const entry = entryById(data.entries, request.subject);
        if (entry) { patch("entries", entry.id, { status: "unlocked", changedAt: now, byId: viewer.id }); extra = " " + entry.label + " is unlocked."; }
      }
      if (request.kind === "lights") {
        const device = deviceById(data.devices, request.subject);
        if (device) { patch("devices", device.id, { on: true, watts: device.watts || 9 }); extra = " " + device.name + " is on."; }
      }
      const event = logEvent("person", { type: "request", id: request.id }, member.name + "'s request granted", request.body);
      return pushToast({
        variant: "success", title: "Granted",
        description: member.name.split(" ")[0] + " has been told." + extra,
        onUndo: () => {
          restore("requests", request);
          if (request.kind === "unlock") restore("entries", entryById(data.entries, request.subject));
          if (request.kind === "lights") restore("devices", deviceById(data.devices, request.subject));
          unlogEvent(event);
        },
      });
    }
    if (transition.id === "deny") {
      return setForm({
        title: "Deny " + member.name.split(" ")[0] + "'s request",
        description: "“" + request.body + "” — they see the reason, so it is worth writing one.",
        submitLabel: "Deny request",
        initial: { reason: "" },
        fields: [{ id: "reason", kind: "text", label: "Why", placeholder: "The bike can stay in the passage tonight — I will bring it in.", hint: "Shown to them, and kept on the request." }],
        validate: (v) => (!v.reason || v.reason.trim().length < 8 ? { reason: "Give a reason — a sentence is enough." } : {}),
        run: (v) => {
          patch("requests", request.id, { status: "denied", answeredAt: now, answeredById: viewer.id, reason: v.reason.trim() });
          const event = logEvent("person", { type: "request", id: request.id }, member.name + "'s request denied", v.reason.trim());
          pushToast({ title: "Denied", description: member.name.split(" ")[0] + " has been told why.", onUndo: () => { restore("requests", request); unlogEvent(event); } });
        },
      });
    }
  };

  const onMemberTransition = (member, transition) => {
    if (transition.id === "remove_member") {
      return setConfirm({
        title: "Remove " + member.name + " from the household?",
        description: "Their access ends immediately and their preferences stop counting towards the temperature. "
          + (RESPONSE.order.indexOf(member.id) > -1 ? "They are number " + (RESPONSE.order.indexOf(member.id) + 1) + " in the response order, so that changes too. " : "")
          + "The log keeps what they did.",
        confirmLabel: "Remove from household",
        run: () => {
          patch("members", member.id, { status: "removed", home: false });
          logEvent("person", { type: "member", id: member.id }, member.name + " removed from the household", null);
          pushToast({ variant: "error", title: "Removed", description: member.name + " has no access. The house now holds " + formatTemp(comfortTarget(data.members.map((m) => (m.id === member.id ? Object.assign({}, m, { status: "removed" }) : m)), now).c) + "°." });
        },
      });
    }
    if (transition.id === "resend_invite") {
      const event = logEvent("person", { type: "member", id: member.id }, "Invitation resent to " + member.name, member.email);
      return pushToast({ variant: "success", title: "Invitation sent", description: "Sent to " + member.email + " · it expires in seven days.", onUndo: () => unlogEvent(event) });
    }
    const next = { suspend_member: "suspended", restore_member: "active" }[transition.id];
    patch("members", member.id, { status: next, suspendedNote: next === "suspended" ? "Suspended " + new Date(now).toLocaleDateString("en-GB", { day: "numeric", month: "long" }) + " by " + viewer.name + "." : undefined });
    const event = logEvent("person", { type: "member", id: member.id }, member.name + (next === "suspended" ? " suspended" : " restored"), viewer.name.split(" ")[0]);
    pushToast({
      variant: next === "active" ? "success" : undefined,
      title: next === "suspended" ? "Access suspended" : "Access restored",
      description: member.name + (next === "suspended" ? " cannot open the house or see it in Hearth." : " has their access back."),
      onUndo: () => { restore("members", member); unlogEvent(event); },
    });
  };

  const invitePerson = () => setForm({
    title: "New person",
    description: "They arrive invited. Nothing they prefer counts, and no door opens for them, until they accept.",
    submitLabel: "Send invitation",
    initial: { name: "", email: "", role: "adult" },
    fields: [
      { id: "name", kind: "input", label: "Name", placeholder: "Dee Osman" },
      { id: "email", kind: "input", label: "Email", placeholder: "dee.osman@example.com" },
      { id: "role", kind: "radio", label: "In the household as", options: HOUSEHOLD_ROLES.filter((r) => r.value !== "owner").map((role) => ({ value: role.value, label: role.label + " — " + (ROLE_CAN_UNLOCK[role.value] ? "can unlock doors and arm the alarm" : "asks an adult instead") })) },
    ],
    validate: (v) => {
      const errors = {};
      if (!v.name || v.name.trim().split(/\s+/).length < 2) errors.name = "Give a full name.";
      if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test((v.email || "").trim())) errors.email = "Enter a valid email address.";
      else if (data.members.some((m) => (m.email || "").toLowerCase() === v.email.trim().toLowerCase())) errors.email = "Somebody in the household already has that address.";
      return errors;
    },
    run: (v) => {
      const id = "mem_" + Math.random().toString(36).slice(2, 6);
      const member = {
        id: id, name: v.name.trim(), role: v.role, status: "invited", home: false, avatar: null,
        email: v.email.trim().toLowerCase(), phone: null, since: now, lastSeenAt: null, where: null,
        prefs: { comfortC: 20, bedtime: "23:00", wake: "07:00", lightsOnArrival: false, notifyAlarm: false, notifyDeliveries: false },
      };
      setData((d) => Object.assign({}, d, { members: d.members.concat([member]) }));
      const event = logEvent("person", { type: "member", id: id }, member.name + " invited to the household", labelOf(HOUSEHOLD_ROLES, v.role));
      pushToast({
        variant: "success", title: "Invitation sent",
        description: member.name + " · " + labelOf(HOUSEHOLD_ROLES, v.role).toLowerCase() + " · nothing counts for them until they accept",
        onUndo: () => { setData((d) => Object.assign({}, d, { members: d.members.filter((m) => m.id !== id) })); unlogEvent(event); },
      });
    },
  });

  /* ── Access ────────────────────────────────────────────────────────────── */
  const onAccessTransition = (grant, transition) => {
    if (transition.id === "revoke_access") {
      return setConfirm({
        title: "Revoke " + grant.label + "?",
        description: grant.holder + " loses " + labelOf(ACCESS_KINDS, grant.kind).toLowerCase() + " access to " + grant.entryIds.map((id) => (entryById(data.entries, id) || {}).label.toLowerCase()).join(" and ") + " immediately, and it cannot be turned back on — a new grant would have to be issued. Suspending it keeps the record and can be undone.",
        confirmLabel: "Revoke access",
        run: () => {
          patch("grants", grant.id, { status: "revoked", endsAt: now });
          logEvent("security", { type: "grant", id: grant.id }, grant.label + " revoked", grant.holder);
          pushToast({ variant: "error", title: "Revoked", description: grant.label + " no longer opens anything. " + (grantsLive(data.grants).length - 1) + " ways in are still live." });
        },
      });
    }
    const next = { suspend_access: "suspended", restore_access: "active" }[transition.id];
    patch("grants", grant.id, { status: next });
    const event = logEvent("security", { type: "grant", id: grant.id }, grant.label + (next === "suspended" ? " suspended" : " restored"), grant.holder);
    pushToast({
      variant: next === "active" ? "success" : undefined,
      title: next === "suspended" ? "Suspended" : "Restored",
      description: grant.label + (next === "suspended" ? " will not open anything until it is restored." : " opens " + grant.entryIds.map((id) => (entryById(data.entries, id) || {}).label.toLowerCase()).join(" and ") + " again."),
      onUndo: () => { restore("grants", grant); unlogEvent(event); },
    });
  };

  const newGrant = () => setForm({
    title: "New access",
    description: "A code, a key or a window of time. It starts live, so give it an end date unless somebody needs it indefinitely.",
    submitLabel: "Open access",
    initial: { label: "", holder: "", kind: "code", entry: "ent_01", days: "7" },
    fields: [
      { id: "label", kind: "input", label: "What it is for", placeholder: "Boiler service" },
      { id: "holder", kind: "input", label: "Who holds it", placeholder: "Wexler Heating" },
      { id: "kind", kind: "radio", label: "Kind", options: ACCESS_KINDS.map((k) => ({ value: k.value, label: k.label })) },
      { id: "entry", kind: "select", label: "Opens", options: ENTRIES.map((e) => ({ value: e.id, label: e.label })) },
      { id: "days", kind: "input", label: "Lasts", hint: "Days. Leave 0 for no end date." },
    ],
    validate: (v) => {
      const errors = {};
      if (!v.label || v.label.trim().length < 3) errors.label = "Say what it is for.";
      if (!v.holder || v.holder.trim().length < 3) errors.holder = "Name who holds it.";
      if (!/^\d+$/.test(v.days || "")) errors.days = "A whole number of days, or 0.";
      return errors;
    },
    run: (v) => {
      const id = "grn_" + Math.random().toString(36).slice(2, 6);
      const days = Number(v.days);
      const grant = {
        id: id, label: v.label.trim(), kind: v.kind, holder: v.holder.trim(), providerId: null,
        entryIds: [v.entry], status: "active", startsAt: now, endsAt: days ? now + days * DAY_MS : null,
        lastUsedAt: null, uses: 0, note: "Opened by " + viewer.name + " on " + new Date(now).toLocaleDateString("en-GB", { day: "numeric", month: "long" }) + ".",
      };
      setData((d) => Object.assign({}, d, { grants: d.grants.concat([grant]) }));
      const event = logEvent("security", { type: "grant", id: id }, grant.label + " access opened", grant.holder);
      pushToast({
        variant: "success", title: "Access opened",
        description: grant.holder + " can open " + (entryById(data.entries, v.entry) || {}).label.toLowerCase() + (days ? " for " + days + " days" : " with no end date") + " · it has never been used yet",
        onUndo: () => { setData((d) => Object.assign({}, d, { grants: d.grants.filter((g) => g.id !== id) })); unlogEvent(event); },
      });
    },
  });

  /* ── Upkeep ────────────────────────────────────────────────────────────── */
  const onJobTransition = (job, transition) => {
    if (transition.id === "cancel_job") {
      return setConfirm({
        title: "Cancel " + job.title + "?",
        description: "It comes off the list for good." + (job.quoteMinor ? " The " + formatPounds(job.quoteMinor) + " quote goes with it." : "") + (job.spent ? " The consumable is still past its interval, so the machine will keep asking." : ""),
        confirmLabel: "Cancel job",
        run: () => {
          patch("jobs", job.id, { status: "cancelled" });
          logEvent("upkeep", { type: "job", id: job.id }, job.title + " cancelled", viewer.name.split(" ")[0]);
          pushToast({ variant: "error", title: "Cancelled", description: job.title + " is off the list." });
        },
      });
    }
    if (transition.id === "request_quote" || transition.id === "book" || transition.id === "reschedule") {
      const trades = TRADES.filter((t) => data.providers.some((p) => p.trade === t.value));
      const booking = transition.id !== "request_quote";
      return setForm({
        title: booking ? (transition.id === "book" ? "Book " + job.title : "Move the visit") : "Ask for a quote",
        description: booking
          ? "Whoever comes needs a way in. " + (job.room ? roomName(job.room) + "." : "The visit covers the whole house.")
          : "Sent with what the house knows about the job: " + (job.device ? job.device.name + ", " : "") + (job.dueAt ? "due " + formatWhen(job.dueAt, now) : "no date yet") + ".",
        submitLabel: booking ? "Book the visit" : "Send the request",
        initial: { provider: job.providerId || (data.providers[0] || {}).id, day: "3", time: "08:30" },
        fields: [
          { id: "provider", kind: "select", label: "Who", options: data.providers.map((p) => ({ value: p.id, label: p.name + " — " + labelOf(TRADES, p.trade) })) },
          { id: "day", kind: "select", label: "When", when: () => booking, options: [1, 2, 3, 4, 5, 6, 7].map((n) => ({ value: String(n), label: new Date(now + n * DAY_MS).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "short" }) })) },
          { id: "time", kind: "input", label: "At", when: () => booking, hint: "HH:MM. Somebody has to be here to let them in." },
        ],
        validate: (v) => (booking && !/^\d\d:\d\d$/.test(v.time || "") ? { time: "Use HH:MM." } : {}),
        run: (v) => {
          const [h, m] = (v.time || "08:30").split(":").map(Number);
          const at = booking ? dayAt(Number(v.day), h, m) : null;
          patch("jobs", job.id, booking
            ? { status: "scheduled", providerId: v.provider, scheduledAt: at }
            : { status: "quoted", providerId: v.provider });
          const event = logEvent("upkeep", { type: "job", id: job.id },
            booking ? job.title + " booked with " + providerName(data.providers, v.provider) : "Quote asked of " + providerName(data.providers, v.provider) + " for " + job.title,
            booking ? formatAbsolute(at) : null);
          pushToast({
            variant: "success",
            title: booking ? "Booked" : "Quote requested",
            description: booking
              ? providerName(data.providers, v.provider) + " · " + formatWhen(at, now) + " · " + (grantsForProvider(data.grants, v.provider).filter((g) => g.status === "active").length ? "they already hold a way in" : "they hold no way in — open one on Access")
              : providerName(data.providers, v.provider) + " will come back with a price.",
            onUndo: () => { restore("jobs", job); unlogEvent(event); },
          });
        },
      });
    }
    if (transition.id === "accept_quote") {
      patch("jobs", job.id, { status: "scheduled", costMinor: job.quoteMinor, scheduledAt: dayAt(4, 9, 0) });
      const event = logEvent("upkeep", { type: "job", id: job.id }, job.title + " quote accepted", formatPounds(job.quoteMinor) + " · " + providerName(data.providers, job.providerId));
      return pushToast({
        variant: "success", title: "Quote accepted",
        description: formatPounds(job.quoteMinor) + " to " + providerName(data.providers, job.providerId) + " · booked for " + formatWhen(dayAt(4, 9, 0), now) + " · committed spend is now " + formatPounds(committedSpend(jobRows(data, now)) + job.quoteMinor - (job.costMinor || 0)),
        onUndo: () => { restore("jobs", job); unlogEvent(event); },
      });
    }
    if (transition.id === "start_job") {
      patch("jobs", job.id, { status: "in_progress" });
      const event = logEvent("upkeep", { type: "job", id: job.id }, providerName(data.providers, job.providerId) + " arrived for " + job.title, null);
      return pushToast({ title: "Marked as started", description: job.title + " is under way.", onUndo: () => { restore("jobs", job); unlogEvent(event); } });
    }
    if (transition.id === "complete_job") {
      /* Completing a consumable resets the machine's hours: the job's due state
         is read off the device, so this is what actually clears it. */
      patch("jobs", job.id, { status: "done", dueAt: job.lifeHours ? now : job.dueAt });
      if (job.lifeHours && job.device) patch("devices", job.device.id, { runtimeHours: 0 });
      const event = logEvent("upkeep", { type: "job", id: job.id }, job.title + " done", job.costMinor ? formatPounds(job.costMinor) : null);
      return pushToast({
        variant: "success", title: "Done",
        description: job.lifeHours && job.device
          ? job.device.name + "'s hours are back to zero, so the next change is " + formatCount(job.lifeHours) + " hours away."
          : job.title + " is off the list · " + jobsOpen(jobRows(data, now)).length + " jobs still open",
        onUndo: () => { restore("jobs", job); if (job.lifeHours && job.device) restore("devices", job.device); unlogEvent(event); },
      });
    }
  };

  const newJob = () => setForm({
    title: "New job",
    description: "Something the house needs doing. A consumable is measured in the hours its machine runs; everything else in days.",
    submitLabel: "Add job",
    initial: { title: "", kind: "service", room: "", days: "30" },
    fields: [
      { id: "title", kind: "input", label: "What needs doing", placeholder: "Service the water softener" },
      { id: "kind", kind: "select", label: "Kind", options: JOB_KINDS },
      { id: "room", kind: "select", label: "Where", options: [{ value: "", label: "The whole house" }].concat(ROOMS) },
      { id: "days", kind: "input", label: "Due in", hint: "Days from today." },
    ],
    validate: (v) => {
      const errors = {};
      if (!v.title || v.title.trim().length < 4) errors.title = "Say what needs doing.";
      if (!/^\d+$/.test(v.days || "")) errors.days = "A whole number of days.";
      return errors;
    },
    run: (v) => {
      const id = "job_" + Math.random().toString(36).slice(2, 6);
      const job = {
        id: id, title: v.title.trim(), kind: v.kind, providerId: null, status: "planned",
        dueAt: now + Number(v.days) * DAY_MS, scheduledAt: null, costMinor: 0, quoteMinor: null,
        deviceId: null, lifeHours: null, ranHours: null, room: v.room || null,
        note: "Added by " + viewer.name + " on " + new Date(now).toLocaleDateString("en-GB", { day: "numeric", month: "long" }) + ".",
      };
      setData((d) => Object.assign({}, d, { jobs: d.jobs.concat([job]) }));
      const event = logEvent("upkeep", { type: "job", id: id }, job.title + " added to upkeep", "Due " + formatAbsolute(job.dueAt).split(",")[0]);
      pushToast({
        variant: "success", title: "Job added",
        description: job.title + " · due " + formatWhen(job.dueAt, now) + " · nobody booked yet",
        onUndo: () => { setData((d) => Object.assign({}, d, { jobs: d.jobs.filter((j) => j.id !== id) })); unlogEvent(event); },
      });
    },
  });

  const newProvider = () => setForm({
    title: "New provider",
    description: "Somebody the house uses. Their access is a separate decision, made on Access.",
    submitLabel: "Add provider",
    initial: { name: "", trade: "heating", contact: "", phone: "" },
    fields: [
      { id: "name", kind: "input", label: "Name", placeholder: "Bovingdon Property Care" },
      { id: "trade", kind: "select", label: "Trade", options: TRADES },
      { id: "contact", kind: "input", label: "Who to ask for", placeholder: "Lorna Pike" },
      { id: "phone", kind: "input", label: "Phone", placeholder: "01628 900 512" },
    ],
    validate: (v) => {
      const errors = {};
      if (!v.name || v.name.trim().length < 3) errors.name = "Give their name.";
      else if (data.providers.some((p) => p.name.toLowerCase() === v.name.trim().toLowerCase())) errors.name = "They are already on the list.";
      if (!/^[\d ]{9,}$/.test((v.phone || "").trim())) errors.phone = "Give a phone number.";
      return errors;
    },
    run: (v) => {
      const id = "prv_" + Math.random().toString(36).slice(2, 6);
      const provider = { id: id, name: v.name.trim(), trade: v.trade, contact: v.contact.trim() || v.name.trim(), phone: v.phone.trim(), email: "", since: now, hourlyMinor: 0, keyHolder: false, note: "Added by " + viewer.name + "." };
      setData((d) => Object.assign({}, d, { providers: d.providers.concat([provider]) }));
      pushToast({
        variant: "success", title: "Provider added",
        description: provider.name + " · " + labelOf(TRADES, v.trade).toLowerCase() + " · they hold no way in",
        onUndo: () => setData((d) => Object.assign({}, d, { providers: d.providers.filter((p) => p.id !== id) })),
      });
    },
  });

  /* ── Money ─────────────────────────────────────────────────────────────── */
  const onBillTransition = (bill, transition) => {
    if (transition.id === "pay") {
      patch("bills", bill.id, { status: "paid", paidAt: now });
      const event = logEvent("money", { type: "bill", id: bill.id }, bill.supplier + " bill paid", formatPoundsExact(bill.amountMinor));
      return pushToast({
        variant: "success", title: "Marked as paid",
        description: formatPoundsExact(bill.amountMinor) + " to " + bill.supplier + " · " + formatPounds(billsOutstanding(data.bills) - bill.amountMinor) + " still owed",
        onUndo: () => { restore("bills", bill); unlogEvent(event); },
      });
    }
    if (transition.id === "dispute") {
      return setForm({
        title: "Query the " + bill.supplier + " bill",
        description: formatPoundsExact(bill.amountMinor) + " for " + bill.period + (bill.forecastMinor ? ", " + formatPoundsExact(bill.amountMinor - bill.forecastMinor) + " above the forecast" : "") + ".",
        submitLabel: "Send the query",
        initial: { reason: "" },
        fields: [{ id: "reason", kind: "text", label: "What is wrong with it", placeholder: "The reading jumped 40% with nobody in the house for a fortnight.", hint: "Kept on the bill so the next person reading it knows why it is open." }],
        validate: (v) => (!v.reason || v.reason.trim().length < 12 ? { reason: "Say what is wrong — a sentence is enough." } : {}),
        run: (v) => {
          patch("bills", bill.id, { status: "disputed", note: v.reason.trim() });
          const event = logEvent("money", { type: "bill", id: bill.id }, bill.supplier + " bill queried", v.reason.trim());
          pushToast({ title: "Queried", description: bill.supplier + " · " + formatPoundsExact(bill.amountMinor) + " · it stays in what the house owes until it is settled.", onUndo: () => { restore("bills", bill); unlogEvent(event); } });
        },
      });
    }
    if (transition.id === "resolve_dispute") {
      patch("bills", bill.id, { status: "due" });
      const event = logEvent("money", { type: "bill", id: bill.id }, bill.supplier + " query closed", null);
      return pushToast({ title: "Query closed", description: bill.supplier + " · " + formatPoundsExact(bill.amountMinor) + " is due again.", onUndo: () => { restore("bills", bill); unlogEvent(event); } });
    }
    if (transition.id === "confirm_reading") {
      patch("bills", bill.id, { status: "due" });
      const event = logEvent("money", { type: "bill", id: bill.id }, "Meter reading sent to " + bill.supplier, null);
      return pushToast({ variant: "success", title: "Reading sent", description: bill.supplier + " will re-issue the bill against the real reading.", onUndo: () => { restore("bills", bill); unlogEvent(event); } });
    }
  };

  const newBill = () => setForm({
    title: "New bill",
    description: "Something that has arrived and has to be paid. The forecast is what the house expected, so a bill above it is flagged rather than filed quietly.",
    submitLabel: "Add bill",
    initial: { kind: "energy", supplier: "", period: "", amount: "", forecast: "", days: "14" },
    fields: [
      { id: "kind", kind: "select", label: "For", options: BILL_KINDS },
      { id: "supplier", kind: "input", label: "Supplier", placeholder: "Octave Energy" },
      { id: "period", kind: "input", label: "Period", placeholder: "October" },
      { id: "amount", kind: "input", label: "Amount in pounds", placeholder: "412.80" },
      { id: "forecast", kind: "input", label: "Expected in pounds", hint: "Leave blank if the house had no forecast." },
      { id: "days", kind: "input", label: "Due in", hint: "Days from today." },
    ],
    validate: (v) => {
      const errors = {};
      if (!v.supplier || v.supplier.trim().length < 3) errors.supplier = "Name the supplier.";
      if (!v.period || v.period.trim().length < 3) errors.period = "What period is it for?";
      if (!/^\d+(\.\d{1,2})?$/.test(v.amount || "")) errors.amount = "Pounds and pence.";
      if (v.forecast && !/^\d+(\.\d{1,2})?$/.test(v.forecast)) errors.forecast = "Pounds and pence, or blank.";
      if (!/^\d+$/.test(v.days || "")) errors.days = "A whole number of days.";
      return errors;
    },
    run: (v) => {
      const id = "bil_" + Math.random().toString(36).slice(2, 6);
      const amount = Math.round(Number(v.amount) * 100);
      const forecast = v.forecast ? Math.round(Number(v.forecast) * 100) : null;
      const bill = { id: id, kind: v.kind, supplier: v.supplier.trim(), period: v.period.trim(), amountMinor: amount, forecastMinor: forecast, status: "due", issuedAt: now, dueAt: now + Number(v.days) * DAY_MS, paidAt: null, note: null };
      setData((d) => Object.assign({}, d, { bills: d.bills.concat([bill]) }));
      const event = logEvent("money", { type: "bill", id: id }, v.supplier.trim() + " bill for " + v.period.trim() + " arrived", formatPoundsExact(amount) + (forecast && amount > forecast ? ", above the forecast" : ""));
      pushToast({
        variant: "success", title: "Bill added",
        description: formatPoundsExact(amount) + " to " + bill.supplier + " · due " + formatWhen(bill.dueAt, now) + (forecast && amount > forecast ? " · " + formatPoundsExact(amount - forecast) + " above forecast" : ""),
        onUndo: () => { setData((d) => Object.assign({}, d, { bills: d.bills.filter((b) => b.id !== id) })); unlogEvent(event); },
      });
    },
  });

  /* ── Routines ──────────────────────────────────────────────────────────── */
  const onRoutineTransition = (routine, transition) => {
    if (transition.id === "delete_routine") {
      return setConfirm({
        title: "Delete " + routine.name + "?",
        description: "It has run " + formatCount(routine.runs) + " times. Deleting it removes the routine and its history; pausing it leaves both in place.",
        confirmLabel: "Delete routine",
        run: () => {
          setData((d) => Object.assign({}, d, { routines: d.routines.filter((r) => r.id !== routine.id) }));
          logEvent("routine", { type: "routine", id: routine.id }, routine.name + " deleted", null);
          pushToast({ variant: "error", title: "Deleted", description: routine.name + " is gone." });
        },
      });
    }
    if (transition.id === "run_now") {
      patch("routines", routine.id, { lastRunAt: now, runs: routine.runs + 1 });
      const event = logEvent("routine", { type: "routine", id: routine.id }, routine.name + " ran", "By hand, " + viewer.name.split(" ")[0]);
      return pushToast({
        title: routine.name + " ran",
        description: routine.deviceIds.map((id) => deviceName(data.devices, id).toLowerCase()).join(", ") + " · run " + formatCount(routine.runs + 1) + " times in total",
        onUndo: () => { restore("routines", routine); unlogEvent(event); },
      });
    }
    if (transition.id === "pause_routine" || transition.id === "resume_routine") {
      const next = transition.id === "pause_routine" ? "paused" : "on";
      patch("routines", routine.id, { status: next });
      const event = logEvent("routine", { type: "routine", id: routine.id }, routine.name + (next === "paused" ? " paused" : " resumed"), viewer.name.split(" ")[0]);
      return pushToast({
        variant: next === "on" ? "success" : undefined,
        title: next === "paused" ? "Paused" : "Resumed",
        description: routine.name + (next === "paused" ? " will not run until it is resumed." : " runs again — " + labelOf(TRIGGER_KINDS, routine.trigger).toLowerCase() + "."),
        onUndo: () => { restore("routines", routine); unlogEvent(event); },
      });
    }
    if (transition.id === "finish_routine") {
      return setForm({
        title: "Finish " + routine.name,
        description: routine.note,
        submitLabel: "Finish routine",
        initial: { trigger: routine.trigger, seconds: "20" },
        fields: [
          { id: "trigger", kind: "select", label: "Runs when", options: TRIGGER_KINDS },
          { id: "seconds", kind: "input", label: "Clip length", hint: "Seconds of video to keep each time it fires." },
        ],
        validate: (v) => (!/^\d+$/.test(v.seconds || "") || Number(v.seconds) < 5 || Number(v.seconds) > 120 ? { seconds: "Between 5 and 120 seconds." } : {}),
        run: (v) => {
          const missing = routine.deviceIds.filter((id) => !data.devices.some((d) => d.id === id && d.status !== "retired"));
          patch("routines", routine.id, { status: missing.length ? "draft" : "on", trigger: v.trigger });
          const event = logEvent("routine", { type: "routine", id: routine.id }, routine.name + " finished", v.seconds + "-second clips");
          pushToast({
            variant: missing.length ? undefined : "success",
            title: missing.length ? "Saved, but it still cannot run" : "Finished",
            description: missing.length
              ? "It names " + missing.map((id) => deviceName(data.devices, id)).join(" and ") + ", which is not in the house yet — set that up and it starts."
              : routine.name + " runs " + labelOf(TRIGGER_KINDS, v.trigger).toLowerCase() + ", keeping " + v.seconds + " seconds each time.",
            onUndo: () => { restore("routines", routine); unlogEvent(event); },
          });
        },
      });
    }
    if (transition.id === "fix_routine") {
      const missing = routine.deviceIds.filter((id) => !data.devices.some((d) => d.id === id && d.status !== "retired"));
      const available = devicesInHouse(data.devices).filter((d) => routine.deviceIds.indexOf(d.id) < 0);
      return setForm({
        title: "Fix " + routine.name,
        description: "It names " + missing.map((id) => deviceName(data.devices, id)).join(" and ") + ", which is no longer in the house. Point it at something that is, or drop it from the routine.",
        submitLabel: "Fix routine",
        initial: { action: "swap", device: (available[0] || {}).id },
        fields: [
          { id: "action", kind: "radio", label: "What to do", options: [{ value: "swap", label: "Use another device instead" }, { value: "drop", label: "Drop it from the routine" }] },
          { id: "device", kind: "select", label: "Use instead", when: (v) => v.action === "swap", options: available.map((d) => ({ value: d.id, label: d.name + " — " + roomName(d.room) })) },
        ],
        validate: (v) => (v.action === "swap" && !v.device ? { device: "Pick a device." } : {}),
        run: (v) => {
          const kept = routine.deviceIds.filter((id) => missing.indexOf(id) < 0);
          patch("routines", routine.id, { deviceIds: v.action === "swap" ? kept.concat([v.device]) : kept });
          const event = logEvent("routine", { type: "routine", id: routine.id }, routine.name + " fixed",
            v.action === "swap" ? "Now uses " + deviceName(data.devices, v.device) : "Dropped " + missing.map((id) => deviceName(data.devices, id)).join(" and "));
          pushToast({
            variant: "success", title: "Fixed",
            description: v.action === "swap" ? routine.name + " now uses " + deviceName(data.devices, v.device) + "." : routine.name + " no longer names anything that has left the house.",
            onUndo: () => { restore("routines", routine); unlogEvent(event); },
          });
        },
      });
    }
  };

  const newRoutine = () => setForm({
    title: "New routine",
    description: "Something the house does on its own. It starts unfinished, which is honest: a routine with no devices chosen cannot run.",
    submitLabel: "Add routine",
    initial: { name: "", trigger: "time", device: (devicesInHouse(data.devices)[0] || {}).id },
    fields: [
      { id: "name", kind: "input", label: "Name", placeholder: "Warm the study before eight" },
      { id: "trigger", kind: "select", label: "Runs when", options: TRIGGER_KINDS },
      { id: "device", kind: "select", label: "Acts on", options: devicesInHouse(data.devices).map((d) => ({ value: d.id, label: d.name + " — " + roomName(d.room) })) },
    ],
    validate: (v) => (!v.name || v.name.trim().length < 4 ? { name: "Give it a name." } : {}),
    run: (v) => {
      const id = "rou_" + Math.random().toString(36).slice(2, 6);
      const routine = { id: id, name: v.name.trim(), trigger: v.trigger, status: "draft", deviceIds: [v.device], memberId: null, offsetMinutes: 0, lastRunAt: null, runs: 0, note: "Added by " + viewer.name + " and not finished." };
      setData((d) => Object.assign({}, d, { routines: d.routines.concat([routine]) }));
      pushToast({
        variant: "success", title: "Routine added",
        description: routine.name + " is unfinished, so it counts towards the " + (routinesNeedingYou(routineRows(data, now)).length + 1) + " routines wanting a person.",
        onUndo: () => setData((d) => Object.assign({}, d, { routines: d.routines.filter((r) => r.id !== id) })),
      });
    },
  });

  /* ── Battery, exports and the staged saves ─────────────────────────────── */
  const setBatteryMode = (mode) => {
    const before = data.battery;
    setData((d) => Object.assign({}, d, { battery: Object.assign({}, d.battery, { mode: mode }) }));
    pushToast({
      title: "Battery — " + (mode === "tariff" ? "following the tariff" : mode === "solar" ? "storing the roof" : "holding charge"),
      description: batteryAdvice(Object.assign({}, data.battery, { mode: mode }), now).detail,
      onUndo: () => setData((d) => Object.assign({}, d, { battery: before })),
    });
  };

  const onExport = (label, scope, rows, format) => {
    pushToast({
      variant: "success", title: "Export ready",
      description: label + " · " + scope + " · " + formatCount(rows) + (rows === 1 ? " record" : " records") + " · " + format,
    });
  };

  return {
    toggleDevice: toggleDevice, onDeviceTransition: onDeviceTransition, bulkOff: bulkOff, allOffDownstairs: allOffDownstairs,
    newDevice: pairDevice, pairDevice: pairDevice, testDevice: testDevice, runHouseTest: runHouseTest, updateFirmware: updateFirmware,
    cameraAction: cameraAction, cameraPrivacy: cameraPrivacy,
    zoneStep: zoneStep, zoneMode: zoneMode, editPreference: editPreference, savePreferences: savePreferences,
    onArm: onArm, callResponse: callResponse, onEntry: onEntry,
    onRequest: onRequest, onMemberTransition: onMemberTransition, invitePerson: invitePerson,
    onAccessTransition: onAccessTransition, newGrant: newGrant,
    onJobTransition: onJobTransition, newJob: newJob, newProvider: newProvider,
    onBillTransition: onBillTransition, newBill: newBill,
    onRoutineTransition: onRoutineTransition, newRoutine: newRoutine,
    setBatteryMode: setBatteryMode, onExport: onExport,
  };
}
window.makeHouseCommits = makeHouseCommits;

})();
</script>
<script type="text/babel" data-presets="react" data-file="App.jsx">
(function(){
/* Hearth — routing, the fixture in state, and the settings the product acts on.
   The commits live in HouseCommits.jsx behind makeHouseCommits(ctx); this file
   is the router and the commit index.

   Auth renders outside the shell (§7.1): it has no navigation owner, so it is
   returned before HearthShell is reached rather than nested inside it. */
function App(props) {
  const options = props || {};
  const [path, setPath] = React.useState(options.startPath || "/");
  const [dark, setDark] = React.useState(options.theme === "dark");
  const [read, setRead] = React.useState(false);
  const [toasts, setToasts] = React.useState([]);
  const [confirm, setConfirm] = React.useState(null);
  const [form, setForm] = React.useState(null);
  const [period, setPeriod] = React.useState("14");
  const [authEmail, setAuthEmail] = React.useState(HEARTH_RESET_LINK.email);

  const [data, setData] = React.useState({
    house: HOUSE_STATE, members: MEMBERS, zones: ZONES_STATE, entries: ENTRIES, devices: DEVICES,
    hub: HUB, links: DEVICE_LINK, discovered: DISCOVERED, cameras: CAMERAS_STATE, clips: CLIPS,
    energy: ENERGY_DAYS, water: WATER_DAYS, battery: BATTERY, solar: SOLAR,
    bills: BILLS, contracts: CONTRACTS, jobs: JOBS, providers: PROVIDERS, grants: GRANTS,
    routines: ROUTINES, requests: REQUESTS, events: EVENTS,
  });
  /* The settings the product acts on. Each one is read by a screen that does
     something with it, never stored twice. */
  const [residence, setResidence] = React.useState(RESIDENCE);
  const [policy, setPolicy] = React.useState(HOUSE_POLICY);
  const [tariff, setTariff] = React.useState(TARIFF);
  const [response, setResponse] = React.useState(RESPONSE);
  const [prefs, setPrefs] = React.useState(NOTIFICATION_PREFS);
  const [dataPrefs, setDataPrefs] = React.useState(DATA_PREFS);

  const now = HEARTH_NOW;
  /* The signed-in person is a member record, not a second literal: the profile
     screen edits the same entry every permission predicate reads. */
  const viewer = data.members.find((m) => m.id === HEARTH_SIGNED_IN);
  const palette = useCommandPalette(!!confirm || !!form);

  React.useEffect(() => { if (options.startPath) setPath(options.startPath); }, [options.startPath]);
  React.useEffect(() => { setDark(options.theme === "dark"); }, [options.theme]);
  React.useEffect(() => {
    document.documentElement.classList.toggle("dark", dark);
    document.body.classList.toggle("dark", dark);
  }, [dark]);
  /* Anything scoped to one record closes when the route changes: a live commit
     closure must not outlive the screen it was opened from. */
  React.useEffect(() => { setConfirm(null); setForm(null); }, [path]);

  const dismiss = (id) => setToasts((list) => list.filter((t) => t.id !== id));
  const pushToast = (toast) => {
    const id = "t" + Date.now() + Math.random().toString(36).slice(2, 6);
    setToasts((list) => list.concat([Object.assign({ id: id }, toast)]));
    setTimeout(() => dismiss(id), 7000);
  };

  const patch = (key, id, fields) => setData((d) => Object.assign({}, d, { [key]: d[key].map((r) => (r.id === id ? Object.assign({}, r, fields) : r)) }));
  const restore = (key, snapshot) => { if (snapshot) setData((d) => Object.assign({}, d, { [key]: d[key].map((r) => (r.id === snapshot.id ? snapshot : r)) })); };
  const logEvent = (kind, subject, line, note) => {
    const id = "evt_" + Math.random().toString(36).slice(2, 7);
    setData((d) => Object.assign({}, d, { events: [{ id: id, at: now, kind: kind, actorId: viewer.id, subject: subject, line: line, note: note || null }].concat(d.events) }));
    return id;
  };
  const unlogEvent = (id) => setData((d) => Object.assign({}, d, { events: d.events.filter((e) => e.id !== id) }));

  const house = makeHouseCommits({
    data: data, setData: setData, patch: patch, restore: restore, pushToast: pushToast,
    setForm: setForm, setConfirm: setConfirm, viewer: viewer, now: now, setPath: setPath,
    logEvent: logEvent, unlogEvent: unlogEvent,
  });

  /* ── The staged saves ──────────────────────────────────────────────────── */
  const saveResidence = (values) => {
    const beforeResidence = residence, beforePolicy = policy;
    setResidence(Object.assign({}, residence, { name: values.name.trim(), short: values.short.trim(), address: values.address.trim(), timezone: values.timezone }));
    setPolicy(Object.assign({}, policy, { awaySetbackC: values.away, nightSetbackC: values.night, frostC: values.frost, boostMinutes: Number(values.boost), lowBatteryPercent: Number(values.lowBattery) }));
    /* The policy object is what enums.js reads at render, so writing into it is
       what makes the setting real rather than decorative. */
    Object.assign(HOUSE_POLICY, { awaySetbackC: values.away, nightSetbackC: values.night, frostC: values.frost, boostMinutes: Number(values.boost), lowBatteryPercent: Number(values.lowBattery) });
    pushToast({
      variant: "success", title: "Residence saved",
      description: values.short.trim() + " · away " + formatTemp(values.away) + "°, night " + formatTemp(values.night) + "° · " + devicesLowBattery(data.devices).length + " devices now read low",
      onUndo: () => { setResidence(beforeResidence); setPolicy(beforePolicy); Object.assign(HOUSE_POLICY, beforePolicy); },
    });
  };

  const saveTariff = (values) => {
    const before = tariff, beforePolicy = policy;
    const next = Object.assign({}, tariff, {
      supplier: values.supplier.trim(), name: values.name.trim(),
      exportPencePerKwh: Number(values.exportRate),
      windows: [
        Object.assign({}, tariff.windows[0], { from: values.offFrom, to: values.offTo, pencePerKwh: Number(values.offRate) }),
        Object.assign({}, tariff.windows[1], { from: values.peakFrom, to: values.peakTo, pencePerKwh: Number(values.peakRate) }),
        Object.assign({}, tariff.windows[2], { pencePerKwh: Number(values.standardRate) }),
      ],
    });
    setTariff(next);
    /* metrics.costOf reads TARIFF and HOUSE_POLICY directly — one source, so a
       saved rate re-prices every day on Energy rather than a copy of it. */
    Object.assign(TARIFF, next);
    Object.assign(HOUSE_POLICY, { standingChargePence: Number(values.standing) });
    setPolicy(Object.assign({}, policy, { standingChargePence: Number(values.standing) }));
    pushToast({
      variant: "success", title: "Tariff saved",
      description: "The last " + period + " days now cost " + formatPounds(costSum(data.energy.slice(-Number(period)))) + " · " + batteryAdvice(data.battery, now).line.toLowerCase(),
      onUndo: () => { setTariff(before); Object.assign(TARIFF, before); Object.assign(HOUSE_POLICY, beforePolicy); setPolicy(beforePolicy); },
    });
  };

  const saveSecuritySettings = (values) => {
    const beforePolicy = policy, beforeResponse = response, beforeResidence = residence;
    Object.assign(HOUSE_POLICY, { darkAfter: values.darkAfter });
    setPolicy(Object.assign({}, policy, { darkAfter: values.darkAfter }));
    setResidence(Object.assign({}, residence, { responseNumber: values.responseNumber.trim() }));
    setResponse(Object.assign({}, response, { order: values.order, autoArm: values.autoArm, autoArmAt: values.autoArmAt, notifyPolice: values.notifyPolice }));
    Object.assign(RESPONSE, { order: values.order, autoArm: values.autoArm, autoArmAt: values.autoArmAt, notifyPolice: values.notifyPolice });
    const insecure = entriesInsecure(data, now);
    pushToast({
      variant: "success", title: "Alarm settings saved",
      description: "Dark after " + values.darkAfter + " · " + insecure.length + (insecure.length === 1 ? " way in is" : " ways in are") + " insecure on the new rules · " + memberName(data.members, values.order[0]).split(" ")[0] + " is called first",
      onUndo: () => { Object.assign(HOUSE_POLICY, beforePolicy); setPolicy(beforePolicy); setResponse(beforeResponse); Object.assign(RESPONSE, beforeResponse); setResidence(beforeResidence); },
    });
  };

  const saveNotifications = (values) => {
    const before = prefs;
    setPrefs(Object.assign({}, prefs, { rows: values.rows, quietFrom: values.from, quietTo: values.to }));
    const silent = values.rows.filter((r) => !r.push && !r.sms && !r.email);
    pushToast({
      variant: "success", title: "Notifications saved",
      description: (values.rows.length - silent.length) + " of " + values.rows.length + " events reach somebody · quiet " + values.from + "–" + values.to,
      onUndo: () => setPrefs(before),
    });
  };

  const saveDataPrefs = (values) => {
    const before = dataPrefs;
    setDataPrefs({ keepLogDays: Number(values.keepLogDays), keepClipsDays: Number(values.keepClipsDays), shareWithProviders: values.shareWithProviders, presenceHistory: values.presenceHistory });
    pushToast({
      variant: "success", title: "Data settings saved",
      description: "Log kept " + values.keepLogDays + " days, clips " + values.keepClipsDays + " · " + (values.presenceHistory ? "who was home is recorded" : "who was home is no longer recorded"),
      onUndo: () => setDataPrefs(before),
    });
  };

  const saveProfile = (values) => {
    const before = viewer;
    patch("members", viewer.id, { name: values.name.trim(), email: values.email.trim(), phone: values.phone.trim() || null, avatar: values.avatar });
    const uploaded = values.avatar && values.avatar.indexOf("data:") === 0;
    pushToast({
      variant: "success", title: "Profile saved",
      description: values.name.trim() + " · " + (uploaded ? "your uploaded photograph" : values.avatar ? "your household photograph" : "no photograph") + " is now beside your name everywhere the house records it",
      onUndo: () => restore("members", before),
    });
  };

  /* Harness chrome, not product chrome: the floating page menu reaches every
     registered screen, including the auth ones the shell cannot render. */
  const preview = (
    <TemplateMenu product="Hearth" path={path} onNavigate={setPath} groups={HEARTH_PAGES(data)}
      dark={dark} onToggleTheme={() => setDark(!dark)} />
  );

  if (HEARTH_AUTH_PATHS.indexOf(path) > -1) {
    const authScreen = () => {
      if (path === "/forgot-password") return <ForgotPasswordScreen email={authEmail} onSubmit={(value) => { setAuthEmail(value); setPath("/forgot-password/sent"); }} onNavigate={setPath} />;
      if (path === "/forgot-password/sent") return <ResetLinkSentScreen email={authEmail} onNavigate={setPath} />;
      if (path === "/reset-password") return <SetNewPasswordScreen now={now} onSubmit={() => setPath("/reset-password/done")} onNavigate={setPath} />;
      if (path === "/reset-password/expired") return <ResetLinkExpiredScreen onNavigate={setPath} />;
      if (path === "/reset-password/done") return <PasswordResetDoneScreen email={HEARTH_RESET_LINK.email} onNavigate={setPath} />;
      return <SignInScreen onSignIn={() => setPath("/")} onNavigate={setPath} />;
    };
    return (<>{authScreen()}{preview}</>);
  }

  const match = (pattern) => {
    const m = path.match(pattern);
    return m ? m[1] : null;
  };
  const deviceId = match(/^\/devices\/([^/]+)$/);
  const personId = match(/^\/people\/([^/]+)$/);
  const providerId = match(/^\/providers\/([^/]+)$/);
  const jobId = match(/^\/upkeep\/([^/]+)$/);

  const screen = () => {
    if (path === "/") {
      return <TodayScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onArm={house.onArm} onToggleDevice={house.toggleDevice} onDeviceTransition={house.onDeviceTransition}
        onRequest={house.onRequest} onJobTransition={house.onJobTransition} onExport={house.onExport} />;
    }
    if (path === "/rooms") {
      return <RoomsScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onToggleDevice={house.toggleDevice} onDeviceTransition={house.onDeviceTransition}
        onZoneStep={house.zoneStep} onAllOff={house.allOffDownstairs} onNewDevice={house.newDevice} />;
    }
    if (path === "/cameras") {
      return <CamerasScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onCameraAction={house.cameraAction} onCameraPrivacy={house.cameraPrivacy}
        onDeviceTransition={house.onDeviceTransition} onExport={house.onExport} />;
    }
    if (deviceId && data.devices.some((d) => d.id === deviceId)) {
      return <DeviceScreen data={data} now={now} viewer={viewer} deviceId={deviceId} onNavigate={setPath}
        onToggleDevice={house.toggleDevice} onDeviceTransition={house.onDeviceTransition}
        onTestDevice={house.testDevice} onUpdateFirmware={house.updateFirmware} onExport={house.onExport} />;
    }
    if (path === "/devices") {
      return <DevicesScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onToggleDevice={house.toggleDevice} onDeviceTransition={house.onDeviceTransition}
        onPairDevice={house.pairDevice} onRunHouseTest={house.runHouseTest} onTestDevice={house.testDevice}
        onUpdateFirmware={house.updateFirmware} onExport={house.onExport} />;
    }
    if (path === "/climate") {
      return <ClimateScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onZoneStep={house.zoneStep} onZoneMode={house.zoneMode} onEditPreference={house.editPreference} onExport={house.onExport} />;
    }
    if (path === "/security") {
      return <SecurityScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onArm={house.onArm} onEntry={house.onEntry} onCallResponse={house.callResponse}
        onDeviceTransition={house.onDeviceTransition} onExport={house.onExport} />;
    }
    if (path === "/energy") {
      return <EnergyScreen data={data} now={now} viewer={viewer} period={period} onPeriodChange={setPeriod}
        onBatteryMode={house.setBatteryMode} onNavigate={setPath} onExport={house.onExport} />;
    }
    if (path === "/spend") {
      return <SpendScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onBillTransition={house.onBillTransition} onNewBill={house.newBill} onExport={house.onExport} />;
    }
    if (jobId && data.jobs.some((j) => j.id === jobId)) {
      return <JobScreen data={data} now={now} viewer={viewer} jobId={jobId} onNavigate={setPath}
        onJobTransition={house.onJobTransition} onExport={house.onExport} />;
    }
    if (path === "/upkeep") {
      return <UpkeepScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onJobTransition={house.onJobTransition} onNewJob={house.newJob} onExport={house.onExport} />;
    }
    if (providerId && data.providers.some((p) => p.id === providerId)) {
      return <ProviderScreen data={data} now={now} viewer={viewer} providerId={providerId} onNavigate={setPath}
        onJobTransition={house.onJobTransition} onAccessTransition={house.onAccessTransition} onExport={house.onExport} />;
    }
    if (path === "/providers") {
      return <ProvidersScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onNewProvider={house.newProvider} onExport={house.onExport} />;
    }
    if (personId && data.members.some((m) => m.id === personId)) {
      return <PersonScreen data={data} now={now} viewer={viewer} memberId={personId} onNavigate={setPath}
        onSavePreferences={house.savePreferences} onMemberTransition={house.onMemberTransition} onRequest={house.onRequest} />;
    }
    if (path === "/people") {
      return <PeopleScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onRequest={house.onRequest} onMemberTransition={house.onMemberTransition} onInvite={house.invitePerson} onExport={house.onExport} />;
    }
    if (path === "/access") {
      return <AccessScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onAccessTransition={house.onAccessTransition} onNewGrant={house.newGrant} onExport={house.onExport} />;
    }
    if (path === "/routines") {
      return <RoutinesScreen data={data} now={now} viewer={viewer} onNavigate={setPath}
        onRoutineTransition={house.onRoutineTransition} onNewRoutine={house.newRoutine} onExport={house.onExport} />;
    }
    if (path === "/log") return <LogScreen data={data} now={now} viewer={viewer} onNavigate={setPath} onExport={house.onExport} />;
    if (path === "/account/profile") return <ProfileScreen data={data} now={now} viewer={viewer} onNavigate={setPath} onSave={saveProfile} />;
    if (path === "/settings") return <ResidenceSettingsScreen data={data} now={now} residence={residence} policy={policy} onNavigate={setPath} onSave={saveResidence} />;
    if (path === "/settings/tariff") return <TariffSettingsScreen data={data} now={now} tariff={tariff} policy={policy} onNavigate={setPath} onSave={saveTariff} />;
    if (path === "/settings/security") return <SecuritySettingsScreen data={data} now={now} policy={policy} response={response} onNavigate={setPath} onSave={saveSecuritySettings} />;
    if (path === "/settings/notifications") return <NotificationSettingsScreen data={data} prefs={prefs} residence={residence} onNavigate={setPath} onSave={saveNotifications} />;
    if (path === "/settings/data") return <DataSettingsScreen data={data} now={now} dataPrefs={dataPrefs} onNavigate={setPath} onSave={saveDataPrefs} onExportAll={(format) => house.onExport("Everything", "The whole record", data.events.length + data.devices.length + data.bills.length, format)} />;
    return <RouteNotBuilt path={path} onNavigate={setPath} />;
  };

  const recordGroups = [
    { label: "Not secure", items: entriesInsecure(data, now).map((e) => ({ value: e.id, label: e.label + " · " + statusValue(ENTRY_STATUS, e.status).label.toLowerCase(), route: "/security" })) },
    { label: "Waiting on you", items: requestsWaiting(data.requests).map((r) => ({ value: r.id, label: memberName(data.members, r.memberId) + " · " + r.body.slice(0, 44) + "…", route: "/people" })) },
    /* The four registers are off the sidebar, so the palette is the one place
       that names them as destinations: derived from the route table — an
       offSidebar route whose PARENT is a sidebar destination — never a second
       list to keep in step. The auth flow fails that test on its parent, which
       is how it stays out of the palette (§7.1). */
    { label: "Registers", items: HEARTH_NAV.filter((r) => r.offSidebar && r.parent && !r.group && r.path.indexOf(":") < 0 && HEARTH_ROUTES[r.parent] && !HEARTH_ROUTES[r.parent].offSidebar).map((r) => ({ value: r.path, label: r.label + " · under " + HEARTH_ROUTES[r.parent].label, route: r.path })) },
    { label: "Rooms", items: ROOMS.map((room) => ({ value: room.value, label: room.label, route: "/rooms" })) },
    { label: "Cameras", items: cameraRows(data).map((c) => ({ value: c.id, label: c.name + " · " + (c.watching ? "watching" : c.shuttered ? "shutter closed" : statusValue(DEVICE_STATUS, c.device ? c.device.status : "offline").label.toLowerCase()), route: "/cameras" })) },
    { label: "Devices", items: data.devices.map((d) => ({ value: d.id, label: d.name + " · " + roomName(d.room), route: "/devices/" + d.id })) },
    { label: "People", items: data.members.map((m) => ({ value: m.id, label: m.name + " · " + labelOf(HOUSEHOLD_ROLES, m.role), route: "/people/" + m.id })) },
    { label: "Jobs", items: data.jobs.map((j) => ({ value: j.id, label: j.title + " · " + statusValue(JOB_STATUS, j.status).label, route: "/upkeep/" + j.id })) },
    { label: "Providers", items: data.providers.map((p) => ({ value: p.id, label: p.name + " · " + labelOf(TRADES, p.trade), route: "/providers/" + p.id })) },
  ];

  return (
    <HearthShell
      path={path}
      onNavigate={setPath}
      dark={dark}
      onToggleTheme={() => setDark(!dark)}
      data={data}
      now={now}
      viewer={viewer}
      notifications={NOTIFICATIONS}
      unread={read ? 0 : NOTIFICATIONS.length}
      onRead={() => setRead(true)}
      palette={palette}
      toasts={toasts}
      onDismissToast={dismiss}
    >
      {screen()}
      <CommandPalette
        open={palette.open}
        onClose={() => palette.setOpen(false)}
        routes={HEARTH_ROUTES}
        recordGroups={recordGroups}
        onNavigate={setPath}
        placeholder="Search devices, rooms, people, jobs…"
      />
      <ConfirmDialog request={confirm} onCancel={() => setConfirm(null)} onConfirm={() => { confirm.run(); setConfirm(null); }} />
      <FormDialog request={form} onCancel={() => setForm(null)} onSubmit={(values) => { form.run(values); setForm(null); }} />
      {preview}
    </HearthShell>
  );
}
window.__hearthReal = App;
if (!window.HearthApp) window.HearthApp = App;

})();
</script>
<script type="text/babel" data-presets="react" data-file="mount.jsx">
(function(){
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(window.HearthApp, {}));
})();
</script>
</body>
</html>
