/* 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,
});
