/* 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;
