Ctrl K
ComunidadGuías…

app.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// ══════════════════════════════════════════════════════════════
// Active frontend — uses atomic REST endpoints (apiPost/apiPatch/apiDelete).
// ══════════════════════════════════════════════════════════════

// ─── P-02: lazy vendor-lib loader ────────────────────────────
// The four vendor libs (jsbarcode, qrcode, chart, html5-qrcode) total
// ~660 KB raw / ~195 KB gzip. Loading them as eager <script defer> tags
// in index.html still costs parse+compile time on every page load, even
// for phones that never open the camera or print labels.
//
// Strategy (fallback per the audit's punch-list — full async wiring of
// every callsite was scoped down): inject them *after* DOMContentLoaded
// when the browser is idle. Critical-path JS (login, dashboard render,
// scan log replay) doesn't depend on these libs, so deferring them
// doesn't change any user-visible flow except on a *very* fresh first
// load when a user instantly taps the camera FAB before idle fires —
// the loader synchronously promotes to active loading in that case.
const _loadedScripts = new Map();
function loadScript(src) {
  if (_loadedScripts.has(src)) return _loadedScripts.get(src);
  const p = new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = src;
    s.async = false;
    s.onload = resolve;
    s.onerror = () => reject(new Error('Failed to load ' + src));
    document.head.appendChild(s);
  });
  _loadedScripts.set(src, p);
  return p;
}
let _vendorLibsReady = null;
function loadVendorLibs() {
  if (_vendorLibsReady) return _vendorLibsReady;
  _vendorLibsReady = Promise.all([
    loadScript('/lib/jsbarcode.min.js'),
    loadScript('/lib/qrcode.min.js'),
    loadScript('/lib/chart.min.js'),
    loadScript('/lib/html5-qrcode.min.js')
  ]);
  return _vendorLibsReady;
}
// Kick off the idle preload once the DOM is ready. Never blocks the main
// render path. Falls back to setTimeout on browsers without
// requestIdleCallback.
function _kickIdleVendorPreload() {
  const ric = window.requestIdleCallback || ((cb) => setTimeout(cb, 200));
  ric(() => {
    loadVendorLibs().catch((err) => console.warn('Vendor lib preload failed:', err));
  });
}
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', _kickIdleVendorPreload, { once: true });
} else {
  _kickIdleVendorPreload();
}

// ─── I18N ────────────────────────────────────────────────────
let currentLang = localStorage.getItem('mp-lang') || 'de';
const LOCALE_MAP = { en: 'en-GB', de: 'de-DE', pt: 'pt-BR' };
// Kilogramm in der Sprache des Benutzers. `toFixed` liefert immer einen Punkt,
// und "13.650 kg" liest sich auf Deutsch als dreizehntausend statt als 13,65.
const fmtKg = (v, d) => Number(v).toLocaleString(LOCALE_MAP[currentLang] || 'de-DE', { minimumFractionDigits: d, maximumFractionDigits: d });
function loc() {
  return LOCALE_MAP[currentLang];
}
function fmtDt(d) {
  if (!(d instanceof Date)) d = new Date(d);
  const dd = String(d.getDate()).padStart(2, '0'),
    mm = String(d.getMonth() + 1).padStart(2, '0'),
    yy = String(d.getFullYear()).slice(-2);
  return dd + '.' + mm + '.' + yy;
}
function fmtDtTime(d) {
  if (!(d instanceof Date)) d = new Date(d);
  return fmtDt(d) + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
function fmtDtShort(d) {
  if (!(d instanceof Date)) d = new Date(d);
  return String(d.getDate()).padStart(2, '0') + '.' + String(d.getMonth() + 1).padStart(2, '0');
}
function isoWeekNumber(d) {
  if (!(d instanceof Date)) d = new Date(d);
  const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
  tmp.setUTCDate(tmp.getUTCDate() + 4 - (tmp.getUTCDay() || 7));
  const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
  return Math.ceil(((tmp - yearStart) / 864e5 + 1) / 7);
}
function t(key, params) {
  const str = (LANG[currentLang] && LANG[currentLang][key]) || (LANG['en'] && LANG['en'][key]) || key;
  if (!params) return str;
  return str.replace(/\{(\w+)\}/g, (_, k) => (params[k] !== undefined ? params[k] : '{' + k + '}'));
}
function tp(key, n) {
  return t(key + (n === 1 ? '.one' : '.other'), { n });
}
function setLang(lang) {
  currentLang = lang;
  localStorage.setItem('mp-lang', lang);
  document.getElementById('lang-sel').value = lang;
  // P-03: lazy-load the new locale on switch. The first switch fetches the
  // file; subsequent switches reuse the cached translations.
  loadLang(lang)
    .then(() => {
      translatePage();
      refresh();
      renderUsbSetup();
      renderUsbKey();
    })
    .catch((err) => console.error('Locale load failed:', err));
}
function translatePage() {
  document.querySelectorAll('[data-i18n]').forEach((el) => {
    el.textContent = t(el.dataset.i18n);
  });
  document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
    el.placeholder = t(el.dataset.i18nPlaceholder);
  });
  document.querySelectorAll('[data-i18n-title]').forEach((el) => {
    el.title = t(el.dataset.i18nTitle);
  });
  document.querySelectorAll('[data-i18n-html]').forEach((el) => {
    el.innerHTML = t(el.dataset.i18nHtml);
  });
  document.querySelectorAll('[data-i18n-aria-label]').forEach((el) => {
    el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
  });
  document.documentElement.lang = currentLang;
}
// P-03: i18n strings live in /lang/<code>.js. Loaded on demand by `loadLang`
// (kicked off at startup with the user's current locale, and again whenever
// `setLang` switches locales). Bundling all three locales in app.js used to
// add ~174 KB raw / ~52 KB gzip to every page load — now only the active
// locale's bytes are fetched and parsed. Other locales are pulled lazily
// the first time the user picks them.
//
// `window.LANG` is populated by each lang/<code>.js file (each file does
// `window.LANG['<code>'] = { ... }`). The `LANG` reference below is just
// a stable handle for `t()` / `tp()` / `translatePage()` — it points at the
// same object, so once a locale file has loaded its strings are visible
// here without any additional plumbing.
const LANG = (window.LANG = window.LANG || {});
const _langPromises = new Map();
function loadLang(code) {
  if (LANG[code]) return Promise.resolve();
  let p = _langPromises.get(code);
  if (p) return p;
  // ?v=2 is a one-time cache-buster. /lang/*.js was served
  // "max-age=31536000, immutable" against an unversioned URL, so any client that
  // had fetched a locale kept it for a year and never saw newly added keys —
  // t() then printed the raw key. The header is now max-age=300, but a client
  // already holding the immutable copy would never ask again; a different URL is
  // the only thing that reaches it. Future string changes need no bump.
  p = loadScript('/lang/' + code + '.js?v=2');
  _langPromises.set(code, p);
  return p;
}

// ─── CONSTANTS ───────────────────────────────────────────────
// USED gibt es nur auf dem Telefon und auf dem gedruckten Etikett: gebucht wird
// ein REMOVE mit dem Grund "spawn" (siehe verbrauchtScan).
const ACTIONS = ['ADD', 'MOVE', 'MOVE_BATCH', 'REMOVE', 'HARVEST', 'CONTAM', 'USED'];
// The colour a zone falls back to when its row carries none, the shade the role
// picker suggests, and the default in the new-zone form — one table instead of
// the three separate literals these used to be, which is how the KPI strip and
// the zone editor came to disagree about what "fruiting" looks like.
//
// Must stay equal to ZONE_SEED_COLOR in db.js; test/zonenfarben.test.js holds
// the two together, because nothing else would notice them drifting apart.
const ZONE_ROLE_COLOR = {
  spawn: '#926bb6',
  incubation: '#4d829b',
  fruiting: '#438871',
  contaminated: '#ef4444'
};
let ZONES = [],
  ALL_RACKS = [],
  LOCS = [],
  RACK_ZONE = {},
  // id -> full zone object. Built once per applyData / zone edit so callers
  // that need .role / .color / etc. can do an O(1) lookup instead of zones.find().
  // Hot path: dashboard contam-rate (renderOverviewKPIs) iterated all scan-log
  // entries x zones.find() = O(scanLog * zones).
  ZONE_BY_ID = {};
// batchId -> batch. Same reasoning as ZONE_BY_ID: the dashboard resolved a batch
// per task row with batches.find(), which is every row x every batch of string
// comparison on every render, every 30s poll and every tab tap.
//
// Revalidated on length rather than rebuilt at each write site: createBatch and
// createGrainBatch push optimistically and splice back on failure, so a map built
// only in applyData would miss a just-created batch until the next sync. Length is
// the only thing those paths change, and a push-then-splice rollback moves it
// twice, so both directions self-correct.
let _batchById = new Map();
function batchById(id) {
  if (_batchById.size !== batches.length) _batchById = new Map(batches.map((b) => [b.batchId, b]));
  return _batchById.get(id);
}
const toZone = (loc) => {
  if (!loc) return loc;
  if (RACK_ZONE[loc]) return RACK_ZONE[loc];
  if (ZONES.includes(loc)) return loc;
  const z = ZONES.find((z) => loc.startsWith(z + '_'));
  return z || loc;
};
// ABBR removed — kuerzel comes from mushroomStrains (Pilzsorten) now.
// Fallback ramp for species with no entry in SP_REAL below.
const SP_COLORS = [
  '#e11d48',
  '#0284c7',
  '#059669',
  '#d97706',
  '#7c3aed',
  '#0d9488',
  '#ea580c',
  '#db2777',
  '#0891b2',
  '#65a30d'
];
// Species colours approximate what the mushroom actually looks like, so the dot
// is recognisable instead of decorative. Keyed on the species name with any
// " (KUERZEL)" suffix stripped — batches carry both "Blue Oyster" and
// "Blue Oyster (BO)", which the old raw-string key treated as two species and
// coloured differently.
//
// Realism is bent where it would cost legibility: Shiitake, Chestnut, Pioppino
// and Maitake are all brown in life, which at a 10px dot is four of the same
// colour. They are spread across hue and roughly 25 / 40 / 65 / 85 lightness so
// no two read alike — chestnut pushed orange, pioppino cooled to olive, maitake
// taken fully grey. Phoenix is nudged toward salmon so it parts from King Oyster
// tan, and Pearl Oyster to blue-grey so it parts from Maitake.
const SP_REAL = {
  'pink oyster': '#E8618C',
  'blue oyster': '#4A7FB5',
  'yellow oyster': '#E8B92E',
  cordyceps: '#E8701A',
  chestnut: '#C8721F',
  reishi: '#8E2B20',
  shiitake: '#4A2E18',
  pioppino: '#6E6046',
  'king oyster': '#C8A275',
  'phoenix oyster': '#D9A08A',
  'phoenix oyster mushroom': '#D9A08A',
  maitake: '#A9A49B',
  'pearl oyster': '#8FA3B0',
  'lions mane': '#EDE7DA',
  "lion's mane": '#EDE7DA',
  'black pearl king oyster': '#2B2F36'
};
let REF_GROUPS = [];
// ZPL label dimensions in dots, populated from /api/data labelDims
// (server reads LABEL_WIDTH_DOTS / LABEL_HEIGHT_DOTS env). Default
// 400×240 = 50×30mm at 203dpi (Zebra GK420d small label).
const labelDims = { widthDots: 400, heightDots: 240 };
// Whether the harvest feed reports releases, from /api/data harvestRelease.
// Default off, so a server that does not send the flag hides the field rather
// than offering one that goes nowhere.
const harvestRelease = { on: false };
const KNOWN_ZONE_I18N = {
  SPAWN: 'dash.zoneSpawn',
  INC: 'dash.zoneInc',
  TENT1: 'dash.zoneTent1',
  TENT2: 'dash.zoneTent2',
  TENT3: 'dash.zoneTent3',
  CONTAM: 'dash.zoneContam'
};
// The English names db.js seeds these zones with. Only a zone still carrying
// its seed name is translated; one renamed in Settings → Zones shows the name
// it was given, or "Großes Zelt" comes back as "Zelt 1".
const KNOWN_ZONE_SEED_NAME = {
  SPAWN: 'Spawn Run',
  INC: 'Incubation',
  TENT1: 'Tent 1',
  TENT2: 'Tent 2',
  TENT3: 'Tent 3',
  CONTAM: 'Contamination'
};
function zoneI18nKey(z) {
  const key = KNOWN_ZONE_I18N[z.id];
  return key && (!z.name || z.name === KNOWN_ZONE_SEED_NAME[z.id]) ? key : null;
}
function zoneDisplayName(id) {
  if (!id) return id;
  const z = zones.find((x) => x.id === id);
  if (z) {
    const key = zoneI18nKey(z);
    return key ? t(key) : z.name;
  }
  if (KNOWN_ZONE_I18N[id]) return t(KNOWN_ZONE_I18N[id]);
  // Try as rack ID: find parent zone and return "ZoneName / rackSuffix"
  for (const zone of zones) {
    const rack = zone.racks.find((r) => r.id === id);
    if (rack) return (zone.name || zone.id) + '/' + (id.slice(zone.id.length + 1) || id);
  }
  return id;
}
function rebuildZoneConstants() {
  ZONES = zones.map((z) => z.id);
  ALL_RACKS = zones.flatMap((z) => z.racks.map((r) => r.id));
  LOCS = [...ZONES, ...ALL_RACKS];
  RACK_ZONE = {};
  ZONE_BY_ID = {};
  zones.forEach((z) => {
    ZONE_BY_ID[z.id] = z;
    z.racks.forEach((r) => {
      RACK_ZONE[r.id] = z.id;
    });
  });
  ZONE_LABELS = {};
  ZONE_COLORS = {};
  zones.forEach((z) => {
    ZONE_LABELS[z.id] = zoneI18nKey(z) || z.name;
    ZONE_COLORS[z.id] = z.color;
  });
  locColor = { ...ZONE_COLORS };
  // Actions + Quantities stay as text barcodes; Zones + Racks use numeric barcodes
  REF_GROUPS = [
    {
      g: 'Actions',
      items: ['ADD', 'MOVE', 'MOVE_BATCH', 'REMOVE', 'HARVEST', 'CONTAM'].map((a) => ({ val: a, label: a }))
    }
  ];
  REF_GROUPS.push({
    g: 'Zones',
    items: ZONES.map((z) => {
      const bc = barcodeByEntity.get('zone:' + z);
      return { val: bc ? String(bc) : z, label: z };
    })
  });
  zones
    .filter((z) => z.racks.length > 0)
    .forEach((z) => {
      const rIds = z.racks.map((r) => r.id);
      for (let i = 0; i < rIds.length; i += 5) {
        const chunk = rIds.slice(i, i + 5);
        const label = z.name + ' Racks ' + (i + 1) + '–' + (i + chunk.length);
        REF_GROUPS.push({
          g: label,
          items: chunk.map((r) => {
            const bc = barcodeByEntity.get('rack:' + r);
            return { val: bc ? String(bc) : r, label: r };
          })
        });
      }
    });
}

// ─── DATA ────────────────────────────────────────────────────
let mushroomStrains = [],
  // Die Stämme je Pilzsorte (v87) — das Vokabular zum Stamm-Feld, siehe STÄMME.
  staemme = [],
  batches = [],
  scanLog = [],
  movements = [],
  manualTasks = [],
  experiments = [],
  workPackages = [], // Arbeitspakete des Vorhabens, siehe ARBEITSPAKETE
  projectNotes = [],
  projectDocs = null, // Vorhaben-Texte, erst beim Öffnen eines Reiters geladen
  projectDocsStamp = null,
  harvests = [],
  blockNotes = [], // gute Blöcke und Notizen je Block, siehe BLOCKBUCH
  cultures = [],
  inventory = {},
  teamMembers = [],
  caldav = {},
  duckdns = {},
  zones = [],
  suppliers = [],
  pickupLocations = [],
  weekRhythm = {},
  rhythmTasks = [],
  recurringTasks = [];
// Numeric barcode registry: Map<number, {type, id}> and reverse Map<string, number>
let barcodeRegistry = new Map(),
  barcodeByEntity = new Map();
let appUsers = [];
let calEvSelectedAssignees = [];
let calTaskSelectedAssignees = [];
let scan = { action: null, from: null, to: null, count: 0, harvestBag: null };
let confirmCb = null,
  noteId = null,
  saving = false,
  lastHash = '';
const spMap = {};
// "Blue Oyster (BO)" and "Blue Oyster" must land on the same key, or the same
// mushroom gets two colours — which is what happened, since batches store both.
function _spKey(s) {
  return String(s || '')
    .replace(/\s*\([^)]*\)\s*$/, '')
    .trim()
    .toLowerCase();
}
// Anything outside SP_REAL is coloured from a hash of its own name, so it is at
// least STABLE. The previous code indexed the ramp by insertion order
// (SP_COLORS[Object.keys(spMap).length % …]), so a species took whatever colour
// was next when it happened to be drawn first — different per card, different
// after a reload.
function _spHashColor(k) {
  let h = 0;
  for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) >>> 0;
  return SP_COLORS[h % SP_COLORS.length];
}
const spColor = (s) => {
  const k = _spKey(s);
  if (!k) return '#888888';
  if (!spMap[k]) spMap[k] = SP_REAL[k] || _spHashColor(k);
  return spMap[k];
};
const spDot = (s) => `<span class="sp-dot" style="background:${spColor(s)}"></span>`;

// ─── HTML ESCAPING ──────────────────────────────────────────
function esc(s) {
  if (s == null) return '';
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}
// Parse a user-entered decimal that may use a German comma as the decimal
// separator ("1,5" → 1.5, "1.234,5" → 1234.5). The app's default language is
// German and the quantity inputs are type=text inputmode=decimal, so mobile
// keyboards offer a comma — plain parseFloat('847,5') silently returns 847.
// Returns NaN for unparseable input; callers apply `|| 0` / Number.isFinite
// exactly as before.
function parseDecimal(v) {
  if (v == null) return NaN;
  let s = String(v).trim();
  if (s.includes(',')) s = s.replace(/\./g, '').replace(',', '.');
  return parseFloat(s);
}
// Record a scan entry's server-side id once its POST resolves. If the user
// undid the entry before the POST landed (no _serverId yet → the undo set
// _undoPending), delete the now-known row so it doesn't resurface on the next
// sync.
function setEntryServerId(entry, id) {
  if (!entry || !id) return;
  entry._serverId = id;
  if (entry._undoPending) apiDelete('/api/scan-log/' + id);
}
// Undo an entry the server has never seen because it is still in the service
// worker's offline queue. A DELETE would 404 — the only way to honour the undo
// is to drop it from the queue before it replays.
function dropQueuedScanEntry(entry) {
  if (!entry || !entry.client_uuid) return false;
  if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return false;
  navigator.serviceWorker.controller.postMessage({ type: 'drop-queued-scan', clientUuid: entry.client_uuid });
  return true;
}
function safeHref(url) {
  if (!url) return '';
  const u = String(url).trim();
  return /^https?:\/\//i.test(u) ? esc(u) : '';
}
function safeColor(c, fallback) {
  if (!c) return fallback || '#16a34a';
  return /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : fallback || '#16a34a';
}

// ─── AUTH ────────────────────────────────────────────────────
let currentUser = null;
let ovPeriod = localStorage.getItem('mp-ov-period') || 'week';
async function authFetch(url, opts) {
  const r = await fetch(url, opts);
  if (r.status === 401) {
    window.location.href = '/login.html';
    throw new Error('unauthorized');
  }
  return r;
}
function _apiCall(method, path, body) {
  _mutating++;
  setSyncStatus('busy', 'Saving...');
  const opts = { method, headers: {} };
  if (body) {
    opts.headers['Content-Type'] = 'application/json';
    opts.body = JSON.stringify(body);
  }
  return authFetch(path, opts)
    .then((r) => {
      return r
        .json()
        .catch(() => ({}))
        .then((d) => {
          _mutating--;
          if (!r.ok) {
            const msg = d.error || 'HTTP ' + r.status;
            setSyncStatus('err', msg);
            return d.error ? d : { error: msg };
          }
          if (_mutating === 0) setSyncStatus('ok', 'Saved · gerade eben');
          return d;
        });
    })
    .catch((e) => {
      _mutating--;
      setSyncStatus('err', 'Save error: ' + (e.message || 'check server'));
      console.error('API error:', method, path, e);
      return { error: e.message || 'Network error' };
    });
}
function apiPost(path, body) {
  return _apiCall('POST', path, body);
}
function apiPatch(path, body) {
  return _apiCall('PATCH', path, body);
}
function apiPut(path, body) {
  return _apiCall('PUT', path, body);
}
function apiDelete(path) {
  return _apiCall('DELETE', path);
}
// Lightweight GET for reads — doesn't toggle the mutating/sync indicator.
async function apiGet(path) {
  const r = await authFetch(path);
  if (!r.ok) {
    const d = await r.json().catch(() => ({}));
    throw new Error(d.error || 'HTTP ' + r.status);
  }
  return r.json();
}
async function invDelta(mat, deltaKg, type, ref) {
  return apiPost('/api/inventory/delta', { mat, deltaKg, type, ref });
}
async function invDeltas(deltas) {
  for (const d of deltas) await invDelta(d.mat, d.deltaKg, d.type, d.ref);
}
async function invSetAbsolute(mat, value, type, ref) {
  return apiPost('/api/inventory/set', { mat, value, type, ref });
}
async function saveInvConfig() {
  return apiPost('/api/inventory/config', {
    thresholds: inventory.thresholds,
    avgComposition: inventory.avgComposition
  });
}
async function loadCurrentUser() {
  try {
    const r = await authFetch('/api/auth/me');
    currentUser = await r.json();
    // Die Kachel-Reihenfolge hängt am Benutzer; vorher galt die ohne Namen.
    syncWkTileOrderUp();
    applyWkTileOrder();
  } catch (e) {
    if (e.message !== 'unauthorized') console.error('Auth check failed:', e);
  }
  showServerTab();
  showMcpTab();
  showCameraTab();
  showAdminNav();
  loadUsbBackup();
}
function showAdminNav() {
  const btn = document.getElementById('n-settings');
  if (btn && currentUser && currentUser.role === 'admin') btn.style.display = '';
}

// MCP, camera and server are admin-only sub-tabs, and every sub-tab now carries
// two buttons: one in the strip, one in the sidebar list. Reveal them together —
// a half-shown pair is a sub-tab reachable from one layout but not the other.
function showAdminSubTab(sub) {
  if (!currentUser || currentUser.role !== 'admin') return;
  for (const id of ['st-settings-' + sub, 'sn-settings-' + sub]) {
    const btn = document.getElementById(id);
    if (btn) btn.style.display = '';
  }
}

// ─── SYNC ────────────────────────────────────────────────────
// Die Antwort auf /api/data, oder ein Fehler, wenn es keine Daten sind.
//
// Ohne Server antwortet nicht der Browser mit einem Fehler, sondern der Service
// Worker mit einem 503 und {error:'offline'} (sw.js, _apiFailureBody). Das ist
// gültiges JSON, und applyData() nahm es als Stand: jede Liste leer, das
// Barcode-Register leer, die Anzeige "synchronisiert". Ein kurzer Aussetzer im
// WLAN reichte, und danach war jedes Etikett "unbekannt", bis die nächste
// Abfrage durchkam — oder bis jemand die App neu startete, was am Ende der
// einzige Ausweg war, den man sah.
async function dataResponse(r) {
  if (!r.ok) throw new Error('HTTP ' + r.status);
  return r.json();
}
async function loadData() {
  setSyncStatus('busy', 'Syncing...');
  try {
    const r = await authFetch('/api/data');
    const d = await dataResponse(r);
    // Das ETag gehoert zu dem Stand, der jetzt gezeichnet wird. Ohne es fragte
    // die naechste Abfrage (etwa auf die eigene Meldung "data-changed" hin)
    // ohne If-None-Match und lud denselben Stand noch einmal ganz.
    const etag = r.headers && r.headers.get ? r.headers.get('ETag') : null;
    if (etag) _lastEtag = etag;
    lastHash = JSON.stringify(d);
    applyData(d);
    lastSyncTime = Date.now();
    setSyncStatus('ok', t('sync.syncedAt', { time: formatRelativeTime(lastSyncTime) }));
    refresh();
    // Gut-Knöpfe und Notizliste folgen dem neuen Stand; das Notizfeld nicht,
    // in dem tippt vielleicht gerade jemand.
    renderBlockGoodButtons();
    renderBiNotesList();
    if (_bbOpen()) loadBlockBook();
    // The remaining-kilogram figures are what the next Charge gets planned
    // against, so they have to move with every sync — but they come down inside
    // /api/data now rather than costing a second round-trip on every SSE event.
    if (typeof refreshSubstrateBatches === 'function') refreshSubstrateBatches(d.substrateBatches);
    // Nach einem Neuladen wegen einer neuen Version: zurück auf die Seite, auf
    // der man war. Dann erst der Stand-Vergleich, der selbst neu laden kann.
    restoreAfterUpdate();
    noteServerBuild(d.build);
  } catch (e) {
    if (e.message !== 'unauthorized') setSyncStatus('err', 'Sync error');
  }
}
// Last-scan-per-bag lookup. The four hot spots that need it (dashboard
// harvest tasks, batch-row bag chips, bag-info modal, getBatchLoc) all
// previously did `[...scanLog].reverse().find(...)` once per bag — O(N×M)
// for N bags and M scan-log entries. Building this map once per call site
// turns the per-bag work into a single O(1) Map.get(). Iterating forward
// and overwriting means the last entry for a given bag wins, equivalent
// to the original `reverse().find()`. Cheap to build, so we don't bother
// with global memoisation + invalidation across the 14 scanLog mutation
// sites.
function buildLastScanByBag() {
  const m = new Map();
  for (const e of scanLog) {
    const k = (e.bag || '').toUpperCase();
    if (k) m.set(k, e);
  }
  return m;
}
// Ob /api/data jemals angekommen ist. Ohne diesen Unterschied ist eine leere
// Liste nicht von einer ungeladenen zu unterscheiden, und die Seiten behaupten
// "noch nichts angelegt", während in Wahrheit noch nichts da ist.
let datenGeladen = false;
function applyData(d) {
  // P-05: invalidate per-batch status cache. scanLog is replaced wholesale,
  // so the lazy rebuild on next getStatus() will pick up the new state.
  _statusByBatch = null;
  _rhythmIndex = null;
  _placementByBag = null;
  // Same reason, same wholesale replacement: the search index is built from
  // these arrays and every one of them is about to be a different array.
  gsIndexInvalidate();
  mushroomStrains = d.mushroomStrains || [];
  staemme = d.staemme || [];
  batches = d.batches || [];
  scanLog = d.scanLog || [];
  movements = d.movements || d.scanLog || [];
  manualTasks = d.manualTasks || [];
  experiments = d.experiments || [];
  workPackages = d.workPackages || [];
  projectNotes = d.projectNotes || [];
  // Die Vorhaben-Texte kommen nicht mit, nur ihre Marke. Hat jemand sie ersetzt,
  // gilt der geladene Stand als veraltet und wird beim nächsten Öffnen eines
  // Reiters neu geholt. Neu gezeichnet wird hier nichts: im Notizfeld darunter
  // tippt vielleicht gerade jemand.
  if ((d.projectDocsStamp || '') !== projectDocsStamp) {
    if (projectDocsStamp !== null) projectDocs = null;
    projectDocsStamp = d.projectDocsStamp || '';
  }
  harvests = d.harvests || [];
  blockNotes = d.blockNotes || [];
  cultures = d.cultures || [];
  inventory = d.inventory || defaultInventory();
  teamMembers = d.teamMembers || [];
  caldav = d.caldav || {};
  duckdns = d.duckdns || {};
  calendarEvents = d.calendarEvents || [];
  zones = d.zones || [];
  suppliers = d.suppliers || [];
  pickupLocations = d.pickupLocations || [];
  weekRhythm = d.weekRhythm || {};
  rhythmTasks = d.rhythmTasks || [];
  recurringTasks = d.recurringTasks || [];
  datenGeladen = true;
  // Build barcode registry from server data
  barcodeRegistry = new Map();
  barcodeByEntity = new Map();
  for (const bc of d.barcodes || []) {
    barcodeRegistry.set(bc.barcode, { type: bc.entity_type, id: bc.entity_id });
    barcodeByEntity.set(bc.entity_type + ':' + bc.entity_id, bc.barcode);
  }
  rebuildZoneConstants();
  // ZPL label dimensions (set by server from LABEL_WIDTH_DOTS / LABEL_HEIGHT_DOTS
  // env vars). Fall back to the default 50×30mm @ 203dpi if missing.
  if (d.labelDims && typeof d.labelDims.widthDots === 'number' && typeof d.labelDims.heightDots === 'number') {
    labelDims.widthDots = d.labelDims.widthDots;
    labelDims.heightDots = d.labelDims.heightDots;
  }
  batches.forEach((b) => spColor(b.species));
  cultures.forEach((c) => spColor(c.species));
  fillStrainSelects();
  fillCultureSelect('nb-culture', ['PD', 'LC', 'G2G', 'GS']);
  fillCultureSelect('gs-culture', ['PD', 'LC', 'SY', 'GS']);
  updateTodoBadge();
  if (typeof fillCalendarUserFilter === 'function') fillCalendarUserFilter();
  if (d.notifications && typeof d.notifications.unread === 'number') {
    renderNotifBadge(d.notifications.unread);
  }
  harvestRelease.on = !!(d.harvestRelease && d.harvestRelease.on);
}
function defaultInventory() {
  return {
    stock: { hardwood: 0, wheatbran: 0, gypsum: 0, grain: 0 },
    thresholds: { hardwood: { minKg: 50 }, wheatbran: { minKg: 20 }, gypsum: { minKg: 5 }, grain: { minKg: 10 } },
    // Average substrate composition used for "~X bags" estimates
    // These are editable in the Inventory → Stock tab
    avgComposition: { hwPct: 75, wbPct: 25, rhPct: 63, bagKg: 3, grainBagKg: 1, grainRhPct: 52 },
    labThresholds: { MC: 0, PD: 0, LC: 0, G2G: 0, GS: 0, SY: 0 },
    bagCountFrom: null,
    log: []
  };
}
// saveData() removed — all mutations now use atomic REST endpoints (apiPost/apiPatch/apiDelete)
let _mutating = 0; // tracks in-flight mutations to block pollSync from overwriting
// Die Fotos, deren Original noch unterwegs ist (pjOriginaleHochladen). Sie
// zaehlen nicht zu _mutating: ein Original braucht auf dem Handy Minuten, und
// so lange darf der Sync nicht stehen. Ein Neuladen fuer ein Update wartet
// aber auf sie, wer die Seite schliesst, wird gefragt, und ihre Kachel bietet
// das Nachreichen erst an, wenn sie fertig sind.
const pjOriginaleUnterwegs = new Set();
let lastSyncTime = null;
function formatRelativeTime(ts) {
  const sec = Math.round((Date.now() - ts) / 1000);
  if (sec < 5) return t('time.justNow');
  if (sec < 60) return t('time.secsAgo', { n: sec });
  const min = Math.floor(sec / 60);
  if (min < 60) return t('time.minsAgo', { n: min });
  const hr = Math.floor(min / 60);
  if (hr < 24) return t('time.hoursAgo', { n: hr });
  return t('time.daysAgo', { n: Math.floor(hr / 24) });
}
// ─── NOTIFICATIONS ──────────────────────────────────────────
let _notifOpen = false;
let _notifItems = [];
let _notifOutsideHandler = null;

function renderNotifBadge(count) {
  const n = typeof count === 'number' && count > 0 ? count : 0;
  const text = n > 99 ? '99+' : String(n);
  for (const id of ['bell-badge', 'bell-badge-m']) {
    const el = document.getElementById(id);
    if (!el) continue;
    if (n > 0) {
      el.textContent = text;
      el.hidden = false;
    } else {
      el.hidden = true;
    }
  }
}

async function openNotifDropdown(anchorEl) {
  if (_notifOpen) return closeNotifDropdown();
  const dd = document.getElementById('notif-dropdown');
  if (!dd) return;
  // Load items
  try {
    const r = await authFetch('/api/notifications');
    const data = await r.json();
    _notifItems = Array.isArray(data.items) ? data.items : [];
    if (typeof data.unread === 'number') renderNotifBadge(data.unread);
  } catch (e) {
    _notifItems = [];
  }
  renderNotifList(_notifItems);
  positionNotifDropdown(dd, anchorEl);
  dd.hidden = false;
  _notifOpen = true;
  // Outside-click to close (captured on next tick so the opening click doesn't fire it)
  setTimeout(() => {
    _notifOutsideHandler = (e) => {
      if (!dd.contains(e.target) && !e.target.closest('#n-notif') && !e.target.closest('#n-notif-m')) {
        closeNotifDropdown();
      }
    };
    document.addEventListener('click', _notifOutsideHandler);
  }, 0);
}

function positionNotifDropdown(dd, anchorEl) {
  // On mobile the CSS already pins it to the top-right of the viewport.
  // On desktop, anchor it to the sidebar bell: the CSS default covers this.
  // If the user clicks the mobile bell we let CSS handle positioning.
  if (!anchorEl) return;
  // No JS positioning needed; CSS handles both the sidebar and mobile cases.
}

function closeNotifDropdown() {
  const dd = document.getElementById('notif-dropdown');
  if (dd) dd.hidden = true;
  _notifOpen = false;
  if (_notifOutsideHandler) {
    document.removeEventListener('click', _notifOutsideHandler);
    _notifOutsideHandler = null;
  }
}

function renderNotifList(items) {
  const list = document.getElementById('notif-list');
  const empty = document.getElementById('notif-empty');
  if (!list || !empty) return;
  if (!items.length) {
    list.innerHTML = '';
    empty.hidden = false;
    return;
  }
  empty.hidden = true;
  list.innerHTML = items
    .map((it) => {
      const ts = it.created ? new Date(it.created).getTime() : Date.now();
      const rel = formatRelativeTime(ts);
      const unreadCls = it.read ? '' : 'unread';
      // esc() is the existing HTML-escape helper used throughout app.js
      return (
        '<button type="button" class="notif-item ' +
        unreadCls +
        '" data-nid="' +
        it.id +
        '" data-link-type="' +
        esc(it.linkType || '') +
        '" data-link-id="' +
        esc(it.linkId || '') +
        '">' +
        '<div class="notif-item-title">' +
        esc(it.title || '') +
        '</div>' +
        (it.body ? '<div class="notif-item-body">' + esc(it.body) + '</div>' : '') +
        '<div class="notif-item-time">' +
        esc(rel) +
        '</div>' +
        '</button>'
      );
    })
    .join('');
  // Wire click handlers on each item
  for (const el of list.querySelectorAll('.notif-item')) {
    el.addEventListener('click', onNotifItemClick);
  }
}

function onNotifItemClick(e) {
  const el = e.currentTarget;
  const id = parseInt(el.getAttribute('data-nid'), 10);
  const linkType = el.getAttribute('data-link-type');
  const linkId = el.getAttribute('data-link-id');
  // Optimistic: mark this single item as read so the badge drops instantly
  if (!Number.isNaN(id)) {
    apiPost('/api/notifications/read', { ids: [id] }).catch(() => {});
    const badge = document.getElementById('bell-badge');
    const n = badge && !badge.hidden ? Math.max(0, parseInt(badge.textContent, 10) - 1) : 0;
    renderNotifBadge(n);
  }
  closeNotifDropdown();
  if (linkType === 'calendar_event' && linkId) {
    // Navigate to the calendar tab, then open the event detail modal
    go('cal', 'n-cal');
    const ce = calendarEvents.find((x) => x.id === linkId);
    if (ce) {
      // openEventDetail accepts a simplified occurrence object; match its shape
      openEventDetail({ type: 'custom', id: ce.id, date: ce.startDate });
    }
  } else if (linkType === 'task' && linkId) {
    // Tasks in the calendar are keyed by their `created` timestamp, so look
    // the task up by its numeric DB id first, then dispatch to the detail
    // modal using the timestamp — same shape clicking the task tile produces.
    go('cal', 'n-cal');
    const tid = parseInt(linkId, 10);
    const tk = manualTasks.find((x) => x.id === tid);
    if (tk) openEventDetail({ type: 'task-due', id: tk.created, date: tk.dueDate });
  } else if (linkType === 'sale' && linkId) {
    // "Bestellt, aber nicht da" (checkSaleAlerts): the species on the Labor
    // page, with its sheet open. linkId is "<species>/<slot>".
    const sid = parseInt(linkId.split('/')[0], 10);
    go('lab', 'n-lab');
    loadLabStock().then(() => lbSheetOpen(sid));
  } else if (linkType === 'label_unsaved' && linkId) {
    // "Gedruckt, aber nicht gespeichert" (checkUnsavedLabels): a culture goes
    // straight into the intake, dated from its label, as if it had been scanned.
    // A bag has no intake; the notice text says what to do.
    const k = kulturKennungLesen(linkId);
    if (k && ['MC', 'PD', 'LC', 'SY'].includes(k.type) && !cultures.some((c) => c.id === k.id)) msQuickOpenIntake(k);
  } else if (linkType === 'customer' && linkId) {
    // An eBay account-closure request. The server records it and raises this
    // notification but deliberately never erases on its own, so this alert is the
    // whole trail — and clicking it used to fall through every branch after
    // marking it read, so the only pointer to the request destroyed itself and
    // the admin landed nowhere. Take them to the Kunden table with the row
    // highlighted, which is where the erase actually happens.
    go('orders', 'n-orders');
    openStab('orders', 'customers');
    _ohHighlightCustomer = parseInt(linkId, 10) || null;
    renderOrdersCustomers();
  }
}

async function markAllNotifRead() {
  try {
    await apiPost('/api/notifications/read', { all: true });
  } catch (e) {
    /* ignore */
  }
  renderNotifBadge(0);
  // Update local list to reflect read state
  _notifItems = _notifItems.map((it) => ({ ...it, read: 1 }));
  renderNotifList(_notifItems);
}

// The label is one line and clips rather than wraps, because a second line
// moved the Admin button above it. The title carries whatever did not fit.
function setSyncLabel(text) {
  const el = document.getElementById('sync-label');
  el.textContent = text;
  el.title = text;
}
function setSyncStatus(cls, msg) {
  document.getElementById('sync-dot').className = 'sync-dot ' + cls;
  setSyncLabel(msg);
  const m = document.getElementById('sync-dot-m');
  if (m) m.className = 'sync-dot ' + cls;
  if (cls === 'ok') lastSyncTime = Date.now();
}
// Update relative time display every 5 seconds
setInterval(() => {
  if (!lastSyncTime) return;
  const dot = document.getElementById('sync-dot');
  if (!dot || !dot.classList.contains('ok')) return;
  setSyncLabel(t('sync.syncedAt', { time: formatRelativeTime(lastSyncTime) }));
}, 5000);
let _polling = false;
let _lastEtag = null;
// Wer auf eine schon laufende Abfrage wartet (syncNow), statt eine zweite
// danebenzustellen. Bekommt deren Ergebnis.
const _pollWaiters = [];
// Wie lange eine Abfrage höchstens laufen darf. Eine, die hängt — das Telefon
// schlief mitten in der Anfrage, das WLAN wechselte —, hielt _polling für immer
// auf true, und damit ließ jede weitere Abfrage sich selbst aus: das Telefon
// blieb auf seinem alten Stand, bis die App neu startete. Eine Minute reicht
// auch für langsames Netz.
const POLL_TIMEOUT_MS = 60000;
// Ergebnis: true, wenn der Server geantwortet hat (neuer Stand oder "nichts
// Neues"); false, wenn nicht; null, wenn die Abfrage ausgelassen wurde, weil
// gerade gespeichert wird. opts.force lässt sie trotzdem laufen.
async function pollSync(opts) {
  if (_polling) return new Promise((res) => _pollWaiters.push(res));
  if (_mutating > 0 && !(opts && opts.force)) return null;
  _polling = true;
  let ok = false;
  try {
    // P-04: send If-None-Match so the server can short-circuit with 304
    // when nothing has changed. Server sets ETag from the monotonic
    // data_version counter, so a cached version is authoritative.
    const init = {};
    if (_lastEtag) init.headers = { 'If-None-Match': _lastEtag };
    if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
      init.signal = AbortSignal.timeout(POLL_TIMEOUT_MS);
    }
    const r = await authFetch('/api/data', init);
    if (r.status === 304) {
      // No change — keep the existing data, just refresh the "synced at" time.
      lastSyncTime = lastSyncTime || Date.now();
      setSyncStatus('ok', t('sync.syncedAt', { time: formatRelativeTime(lastSyncTime) }));
      ok = true;
      return ok;
    }
    const newEtag = r.headers && r.headers.get ? r.headers.get('ETag') : null;
    const d = await dataResponse(r);
    const h = JSON.stringify(d);
    if (h !== lastHash) {
      lastHash = h;
      _lastEtag = newEtag;
      applyData(d);
      lastSyncTime = Date.now();
      setSyncStatus('ok', t('sync.syncedAt', { time: formatRelativeTime(lastSyncTime) }));
      refresh();
    } else {
      // Body unchanged but ETag may have advanced (e.g. mutation that
      // reverted to the same content). Track the new ETag so the next poll
      // can still 304 cleanly.
      if (newEtag) _lastEtag = newEtag;
      lastSyncTime = lastSyncTime || Date.now();
    }
    ok = true;
    // Ein 200 nach einem Deploy: der Stand im ETag hat sich bewegt.
    noteServerBuild(d.build);
  } catch (e) {
    if (e.message !== 'unauthorized') setSyncStatus('err', 'Sync error');
  } finally {
    _polling = false;
    for (const res of _pollWaiters.splice(0)) res(ok);
  }
  return ok;
}

// Den Stand jetzt holen, statt auf den nächsten Takt zu warten. Läuft gerade
// eine Speicherung, kurz warten — pollSync() lässt sonst aus, damit der
// Server-Stand die eigene, noch unterwegs befindliche Änderung nicht
// überschreibt. Hängt sie, geht die Abfrage nach drei Sekunden trotzdem raus:
// ein Scanner, der auf eine tote Speicherung wartet, hilft niemandem.
// true, wenn der Server geantwortet hat.
async function syncNow() {
  for (let i = 0; i < 30 && _mutating > 0; i++) await new Promise((r) => setTimeout(r, 100));
  return (await pollSync({ force: true })) === true;
}

// ─── NEUE VERSION ────────────────────────────────────────────
// Nach einem Deploy lief auf jedem Telefon, das die App offen hatte, der alte
// Code weiter — tagelang, wenn niemand neu lud, und ohne dass es jemand sah.
// Der Server nennt den Stand seiner App-Dateien (appBuild() in server.js) bei
// jeder Abfrage und beim Verbinden des Ereignisstroms. Welcher Stand hier läuft,
// meldet der Service Worker beim Ausliefern von app.js; ohne diese Meldung gilt
// der erste Stand, den der Server nach dem Laden nennt.
//
// Weichen die beiden ab, lädt die App sich selbst neu — aber nur, wo es
// niemanden stört: beim Start, wenn sie aus dem Hintergrund zurückkommt oder
// dorthin verschwindet, und nie, solange etwas offen, unterwegs oder eingetippt
// ist. Sonst sagt ein Balken oben, dass eine neue Version da ist, mit einem
// Knopf dafür; beim nächsten ruhigen Moment lädt sie trotzdem von selbst.
let _loadedBuild = null;
let _serverBuild = null;
const _pageLoadedAt = Date.now();
let _resumedAt = 0;
// So lange nach dem Start oder dem Zurückkommen stört ein Neuladen niemanden:
// man hat noch nichts angefangen.
const UPDATE_QUIET_MS = 5000;

function noteServerBuild(build) {
  if (!build) return;
  _serverBuild = build;
  if (!_loadedBuild) _loadedBuild = build;
  checkForUpdate();
}
// Der Stand des app.js, das diese Seite wirklich bekommen hat. Er geht dem
// ersten Stand vom Server vor: kam app.js aus dem Zwischenspeicher, wäre der
// Stand des Servers der neue, obwohl hier der alte läuft.
function noteLoadedBuild(build) {
  if (!build) return;
  _loadedBuild = build;
  checkForUpdate();
}
function appOutdated() {
  return !!(_loadedBuild && _serverBuild && _loadedBuild !== _serverBuild);
}
function checkForUpdate() {
  const alt = appOutdated();
  const bar = document.getElementById('update-bar');
  if (bar) bar.hidden = !alt;
  if (!alt) return;
  const ruhig = document.hidden || Date.now() - Math.max(_pageLoadedAt, _resumedAt) < UPDATE_QUIET_MS;
  if (ruhig && reloadIsSafe() && updateReloadAllowed()) reloadForUpdate();
}

// Ist gerade etwas offen, unterwegs oder eingetippt, das ein Neuladen verlöre?
function reloadIsSafe() {
  if (_mutating > 0 || pjOriginaleUnterwegs.size > 0) return false;
  if (leaveGuards.some((g) => askGuard(g))) return false;
  // Jedes Fenster, die Kamera eingeschlossen — sie ist auch eines.
  if (document.querySelector('.modal-bg.open')) return false;
  const huelle = document.getElementById('scan-overlay');
  if (huelle && huelle.classList.contains('open')) return false;
  const ernte = document.getElementById('harvest-panel');
  if (ernte && ernte.style.display === 'block') return false;
  const fokus = document.activeElement;
  if (fokus && (/^(INPUT|TEXTAREA|SELECT)$/.test(fokus.tagName) || fokus.isContentEditable)) return false;
  return !pageHasTypedText();
}
// Ein sichtbares Textfeld auf der offenen Seite, in dem etwas steht, das beim
// Laden nicht drin stand. Die meisten Eingaben laufen über Fenster (oben schon
// abgefangen); das hier sind die Formulare, die auf einer Seite selbst stehen.
// Suchfelder zählen nicht, Auswahllisten auch nicht — die füllt das Programm
// selbst vor, und eine Auswahl ist schnell wieder getroffen.
function pageHasTypedText() {
  const seite = document.querySelector('.page.active');
  if (!seite) return false;
  for (const el of seite.querySelectorAll('input, textarea')) {
    if (el.disabled || el.readOnly || el.offsetParent === null) continue;
    const art = el.tagName === 'TEXTAREA' ? 'text' : el.type || 'text';
    if (!/^(text|number|email|tel|url|password)$/.test(art)) continue;
    if (el.value && el.value !== el.defaultValue) return true;
  }
  return false;
}

// Die Marke gegen eine Schleife: kommt nach dem Neuladen wieder der alte Code
// (das Netz war zu langsam, app.js kam aus dem Zwischenspeicher), lädt die App
// nicht gleich noch einmal, sondern zeigt den Balken.
function updateReloadAllowed() {
  try {
    const m = JSON.parse(sessionStorage.getItem('mp-update-reload') || 'null');
    return !(m && m.build === _serverBuild && Date.now() - m.at < 2 * 60 * 1000);
  } catch {
    return true;
  }
}
// Neu laden, und danach dort weitermachen, wo man war. Ohne das landete jedes
// Neuladen auf der Startseite, und wer auf "Chargen" gestanden hatte, suchte.
function reloadForUpdate() {
  try {
    sessionStorage.setItem('mp-update-reload', JSON.stringify({ build: _serverBuild, at: Date.now() }));
    sessionStorage.setItem('mp-update-where', JSON.stringify({ ...whereAmI(), at: Date.now() }));
  } catch {
    /* storage blocked — reload anyway, just without the way back */
  }
  location.reload();
}
function whereAmI() {
  const seite = document.querySelector('.page.active');
  const nav = document.querySelector('.sb-nav .sb-btn.active, .sb-footer .sb-btn.active');
  const reiter = seite ? seite.querySelector('.stab.active') : null;
  return {
    page: seite ? seite.id.replace(/^p-/, '') : null,
    btnId: nav ? nav.id : null,
    stab: reiter ? reiter.id : null,
    drilled: !!(seite && seite.classList.contains('stab-drilled'))
  };
}
function restoreAfterUpdate() {
  let w = null;
  try {
    w = JSON.parse(sessionStorage.getItem('mp-update-where') || 'null');
    sessionStorage.removeItem('mp-update-where');
  } catch {
    return;
  }
  if (!w || Date.now() - w.at > 60 * 1000) return;
  if (!w.page || !w.btnId || !document.getElementById('p-' + w.page) || !document.getElementById(w.btnId)) return;
  go(w.page, w.btnId);
  // Den Reiter wie mit einem Tipp öffnen. Am Telefon nur, wenn man in ihm
  // stand — sonst stand man auf der Liste der Reiter, und dort landet go().
  if (w.stab && (w.drilled || !sbIsPhone())) {
    const r = document.getElementById(w.stab);
    if (r) r.click();
  }
}

// Coalesce SSE-triggered reconciliation polls. Every scan (by any client)
// broadcasts a data-changed event; without coalescing, a burst of scans fires
// one full /api/data refetch per scan — the dominant cause of scan sluggishness
// as history grows. The scanning client already applies its own scan locally
// (optimistic update), so the authoritative reconcile can be deferred and
// folded into a single poll per window.
let _ssePollTimer = null;
function scheduleSsePoll() {
  if (_ssePollTimer) return; // a poll is already queued — fold this event into it
  _ssePollTimer = setTimeout(function () {
    _ssePollTimer = null;
    // If a local mutation is mid-flight, its own flow keeps the UI correct;
    // retry shortly so we still pick up other clients' interleaved changes.
    if (_mutating > 0 || _polling) {
      scheduleSsePoll();
      return;
    }
    pollSync();
  }, 500);
}

// ── SSE real-time sync (replaces 5s polling for connected clients) ──
let _sse = null;
let _sseReconnectTimer = null;
let _sseRetryDelay = 1000;
let _sseOpenedOnce = false;
function connectSSE() {
  if (_sse) return;
  try {
    _sse = new EventSource('/api/events');
    _sse.onopen = function () {
      _sseRetryDelay = 1000; // Reset backoff on successful connection
      setSyncStatus('ok', 'Connected');
      // Nach dem Wiederverbinden: was in der Lücke geändert wurde, kam als
      // Ereignis nie an. Einmal nachfragen, statt auf den nächsten Takt zu warten.
      if (_sseOpenedOnce) scheduleSsePoll();
      _sseOpenedOnce = true;
    };
    _sse.onmessage = function (ev) {
      try {
        const msg = JSON.parse(ev.data);
        if (msg.type === 'data-changed') scheduleSsePoll();
        // The stick backup announces itself; loadUsbBackup asks only for admins.
        if (msg.type === 'usb-backup') loadUsbBackup();
        if (msg.type === 'connected') {
          setSyncStatus('ok', 'Connected');
          // Nach einem Deploy startet der Server neu, und hier kommt als Erstes
          // sein Stand an — noch bevor die nächste Abfrage fällig wäre.
          noteServerBuild(msg.build);
        }
      } catch (e) {
        /* ignore parse errors */
      }
    };
    _sse.onerror = function () {
      _sse.close();
      _sse = null;
      setSyncStatus('err', 'Connection lost');
      // Exponential backoff reconnect, capped at 30s
      if (!_sseReconnectTimer) {
        _sseReconnectTimer = setTimeout(
          () => {
            _sseReconnectTimer = null;
            connectSSE();
          },
          Math.min(_sseRetryDelay, 30000)
        );
        _sseRetryDelay = Math.min(_sseRetryDelay * 2, 30000);
      }
    };
  } catch (e) {
    /* SSE not supported — polling fallback active */
  }
}

// ─── SIDEBAR ────────────────────────────────────────────────
//
// Two arrangements behind one button, and the button asks the window which one
// it is in. It asks at the moment of the click, and the answer can have changed
// since the drawer opened. Open the drawer at 760px, widen the window past 769,
// and `.sb-overlay.sb-show` stays: it is display:block at every width, and the
// only code that takes it off refuses to run above the breakpoint. What is left
// is a 40 percent black sheet over the whole application, and the tap that
// should lift it now falls into the desktop branch below and collapses the
// docked sidebar instead. Escape did not know the drawer at all. The way out
// was a reload, and the gesture that gets you there is an ordinary one: a split
// window being resized.
//
// So the phone arrangement gets a query of its own, asked once and listened to,
// instead of a width read at click time.
const sbPhone = typeof window.matchMedia === 'function' ? window.matchMedia('(max-width: 768px)') : null;
const sbIsPhone = () => (sbPhone ? sbPhone.matches : window.innerWidth <= 768);

function toggleSidebar() {
  const sb = document.getElementById('sidebar');
  const ov = document.getElementById('sb-overlay');
  if (sbIsPhone()) {
    sb.classList.toggle('sb-open');
    ov.classList.toggle('sb-show');
    document.body.classList.toggle('sb-mobile-open');
  } else {
    sb.classList.toggle('sb-collapsed');
    document.body.classList.toggle('sb-is-collapsed');
  }
  sbSyncInert();
}

// The drawer's three classes, off. Every route out of the drawer goes through
// here: navigating, Escape, tapping the veil, and crossing the breakpoint. Four
// call sites that each removed their own subset is how one of them came to
// remove none.
function sbClose() {
  document.getElementById('sidebar').classList.remove('sb-open');
  document.getElementById('sb-overlay').classList.remove('sb-show');
  document.body.classList.remove('sb-mobile-open');
  sbSyncInert();
}

// The other arrangement's state, off. Collapsing is a desktop idea, and the
// rules that carry it out (styles.css:454-461) sit outside any min-width block,
// so the phone block never takes them back. A sidebar collapsed on a desk and
// then met on a phone opens full width with every label, every group heading
// and the logo text set to display:none. Same disease as the veil this fix is
// about, in the direction the crossing handler claimed to cover.
function sbUncollapse() {
  document.getElementById('sidebar').classList.remove('sb-collapsed');
  document.body.classList.remove('sb-is-collapsed');
}

// Close the drawer when navigating. No phone gate: sbClose() removes three
// classes that the desktop arrangement never sets and derives inert from the
// arrangement itself, so on a desk it already does nothing. The gate could only
// ever suppress a cleanup, never enable one, and it was the last place left
// where a state change depended on re-reading the arrangement after the fact,
// which is the shape of the bug this branch was opened about.
function sbCloseMobile() {
  sbClose();
}

// A closed drawer is off-screen, not gone. `transform: translateX(-100%)` keeps
// all fourteen of its rows in the tab order, and there is no tabindex anywhere
// in this markup, so focus order is document order: the sidebar opens at
// index.html:1255 and closes at 1764, and the top bar with its hamburger only
// begins at 1767. The invisible rows are therefore what the FIRST Tab into the
// page walks, and what Shift+Tab from the hamburger walks back into. (An
// earlier version of this comment said Tab forward from the top bar, which
// cannot happen: forward from the hamburger leads away from the drawer.)
// `inert` is what takes an element out of tabbing and hit-testing without
// hiding it from the transition that slides it back in. Docked, the sidebar is
// on screen and must never be inert, so the flag is derived from both facts
// rather than toggled alongside the class.
function sbSyncInert() {
  const sb = document.getElementById('sidebar');
  if (!sb) return;
  sb.inert = sbIsPhone() && !sb.classList.contains('sb-open');
}

// Crossing the line in either direction puts BOTH arrangements' state back to
// nothing, because either one is meaningless in the other. Below the line the
// drawer's three classes are what must go; above it, the collapse. Doing only
// half of that is the bug this fix was opened about.
if (sbPhone) {
  sbPhone.addEventListener('change', () => {
    sbClose();
    sbUncollapse();
  });
}

// ─── NAV ─────────────────────────────────────────────────────
const PAGES = {
  dash: 'n-dash',
  batch: 'n-batch',
  lab: 'n-lab',
  print: 'n-print',
  cal: 'n-cal',
  settings: 'n-settings',
  strains: 'n-strains',
  orders: 'n-orders',
  pickups: 'n-pickups'
};
// ─── UNSAVED WORK ────────────────────────────────────────────────────────────
//
// Most of this application writes as it goes: a scan is a scan, a move is a
// move, and there is nothing to forget. A few screens cannot work that way,
// because what they store is read by somebody outside this building the moment
// it lands — a release amount typed as 50 on the way to 5 reaches a shop window
// before the second digit does. Those screens keep a Save button, and register
// here so that the button's one weakness is covered: the edit nobody pressed it
// for.
//
// Two doors, both guarded. `go()` is the way to another page; `beforeunload` is
// the tab, the reload and the back button.
const leaveGuards = [];

/**
 * @param {() => string|null} ask the question to put to the user, or null when
 *   nothing is pending.
 * @param {() => void} [discard] run once leaving is confirmed. Without it the
 *   screen would go on treating the dropped edits as pending and keep refusing
 *   the refresh that would put it right.
 */
function guardUnsaved(ask, discard) {
  leaveGuards.push({ ask, discard });
}

// A guard that throws must not lock the application on one page.
function askGuard(g) {
  try {
    return g.ask();
  } catch (e) {
    return null;
  }
}

/** True when walking away is fine — nothing pending, or it was confirmed. */
function mayLeavePage() {
  // Ask every pending guard before discarding any of them: a "cancel" on the
  // second question must not leave the first one's edits already thrown away.
  const offen = leaveGuards.map((g) => [g, askGuard(g)]).filter(([, frage]) => frage);
  for (const [, frage] of offen) if (!window.confirm(frage)) return false;
  for (const [g] of offen) if (g.discard) g.discard();
  return true;
}

// The browser writes its own wording here and ignores ours — the string only has
// to be non-empty. The sentence people actually read is the one in mayLeavePage().
window.addEventListener('beforeunload', (e) => {
  if (!leaveGuards.some((g) => askGuard(g))) return;
  e.preventDefault();
  e.returnValue = '';
});
// Originale unterwegs halten nur das Schliessen auf, nicht go(): ein Wechsel
// der Seite in der App laesst sie weiterlaufen.
window.addEventListener('beforeunload', (e) => {
  if (!pjOriginaleUnterwegs.size) return;
  e.preventDefault();
  e.returnValue = '';
});

// Where "Zurück" out of the admin section leads. Recorded on the way in, so it
// lands where the user actually was instead of always on the dashboard.
let lastMainPage = { page: 'dash', btnId: 'n-dash' };

function go(page, btnId) {
  if (!mayLeavePage()) return;
  if (page !== 'settings') lastMainPage = { page, btnId };
  // Admin borrows the sidebar on a desktop; styles.css reads this flag.
  document.body.classList.toggle('admin-mode', page === 'settings');
  // The footer button changes job with it. Sighted users read that off the
  // arrow and the green; a screen reader reads only the label, so say it.
  const adminBtn = document.getElementById('n-settings');
  if (adminBtn) adminBtn.setAttribute('aria-label', t(page === 'settings' ? 'nav.adminBack' : 'nav.admin'));
  document.querySelectorAll('.page').forEach((p) => p.classList.remove('active'));
  document.querySelectorAll('.sb-nav .sb-btn, .sb-footer .sb-btn').forEach((b) => b.classList.remove('active'));
  document.querySelectorAll('.bottom-nav-btn').forEach((b) => b.classList.remove('active'));
  document.getElementById('p-' + page).classList.add('active');
  const navBtn = document.getElementById(btnId);
  navBtn.classList.add('active');
  // The mobile topbar says which page this is. Read off the entry that opened it
  // rather than a second table of names: a page added to the sidebar is named
  // here without anyone remembering to, and a renamed one is renamed once.
  const title = document.getElementById('topbar-title');
  const label = navBtn.querySelector('[data-i18n]');
  if (title && label) {
    title.dataset.i18n = label.dataset.i18n;
    title.textContent = label.textContent;
  }
  // Mirror active state onto the mobile bottom nav for the four pages it covers.
  const bnBtn = document.querySelector('.bottom-nav-btn[data-page="' + page + '"]');
  if (bnBtn) bnBtn.classList.add('active');
  // The pipeline strip and the "where is everything" list live on the Chargen
  // page now, and lab stock on Labor — each section carries its own overview
  // instead of the dashboard carrying all of them. renderStatus fills both
  // #metrics and #dash-locations, so it belongs with the page that shows them.
  // The day's work is the top of Arbeitsgänge now, above the seven jobs that do
  // it — it was the Dashboard's Farm mode, which was two buttons and this row.
  if (page === 'work') {
    renderDashBatchTasks();
    loadLabStock();
  }
  if (page === 'batch') {
    renderStatus();
    renderBatches();
  }
  if (page === 'lab') {
    loadLabStock();
    renderCultures();
    renderGrainBatches();
  }
  if (page === 'inv') {
    renderInvStock();
  }
  if (page === 'zones') renderZones();
  if (page === 'print') {
    fillBatchSelect();
    renderLabList();
    refreshPrinterStatus();
  }
  if (page === 'cal') {
    renderCalendar();
    loadCalDAVImports().then(() => renderCalendar());
  }
  // Admin opens on whichever sub-tab is selected — Server on the first visit of
  // a session, and after that wherever the user last was. Clicking that button
  // rather than calling a loader here keeps one wiring per sub-tab: the strip
  // handler already knows what its panel needs, and it is the same path the
  // sidebar list takes.
  if (page === 'settings') {
    const stab = document.querySelector('#p-settings .stab.active');
    if (stab) stab.click();
  }
  // The history charts load on first arrival. applyDashMode() used to do this on
  // the way into Overview mode; the mode is gone and the page is the Overview.
  // Die Betriebsseite zeichnete sich nie selbst. renderOverviewKPIs() haengt
  // an renderStatus(), das fuer die Chargen-Seite laeuft — wer Betrieb oeffnete,
  // sah also, was beim letzten Besuch der Chargen uebrig geblieben war, und beim
  // Neuladen gar nichts. Dieselbe Sache wie bei Arbeitsgaenge (#566), eine Seite
  // weiter.
  if (page === 'dash') renderOverviewKPIs();
  if (page === 'dash' && !kpiHistoryData) loadKpiHistory();
  if (page === 'strains') renderStrains();
  // Dieselbe Regel wie bei Admin und Bestellungen: welcher Reiter aufgeht,
  // entscheidet die Leiste und nicht diese Liste. Der Reiter weiß selbst, was
  // sein Panel braucht, und es ist derselbe Weg, den ein Tipp auf die Leiste
  // nimmt — eine Verdrahtung je Unterseite statt zwei.
  if (page === 'proj') {
    const stab = document.querySelector('#p-proj .stab.active');
    if (stab) stab.click();
  }
  // Same rule as Admin above, and for the same reason: the strip handler already
  // knows what its panel needs. Rendering the inbox here instead drew a list the
  // user may not have asked for, every time — the four other views then drew
  // over it from openStab().
  if (page === 'orders') {
    const stab = document.querySelector('#p-orders .stab.active');
    if (stab) stab.click();
  }
  if (page === 'pickups') renderPickups();
  updateTodoBadge();
  // Every page, Admin included, is somewhere the user has now arrived, so the
  // drawer gets out of the way. Admin used to be the exception — the drawer
  // swapped its list for the sub-tabs and stayed open, because on a phone
  // closing it left nothing to navigate the section with. The section now has
  // its own list on the page, so keeping the drawer open would only put the
  // same thirteen entries on top of the thirteen behind it.
  sbCloseMobile();
}
function openStab(page, sub) {
  document.querySelectorAll(`#p-${page} .stab`).forEach((b) => b.classList.remove('active'));
  document.querySelectorAll(`#p-${page} .sp`).forEach((p) => p.classList.remove('active'));
  const stEl = document.getElementById(`st-${page}-${sub}`);
  if (stEl) stEl.classList.add('active');
  const spEl = document.getElementById(`sp-${page}-${sub}`);
  if (spEl) spEl.classList.add('active');
  // The sidebar list and the sub-tab strip show the same selection — the
  // sidebar on a desktop, the strip on a phone. Marking both here keeps them
  // in step whichever one was clicked. A no-op for pages without a sidebar list.
  document.querySelectorAll('.sb-admin-nav .sb-btn').forEach((b) => b.classList.remove('active'));
  const snEl = document.getElementById(`sn-${page}-${sub}`);
  if (snEl) snEl.classList.add('active');
  if (page === 'batch' && sub === 'list') {
    renderBatches();
  }
  // Painted on arrival rather than on every sync: renderStatus() walks the whole
  // scan log once per rack and once per zone, and this panel is display:none
  // until somebody asks for it.
  if (page === 'batch' && sub === 'locations') renderStatus();
  if (page === 'batch' && sub === 'new') nbEnsureReady();
  if (page === 'batch' && sub === 'substrate') renderSubstrateTab();
  if (page === 'batch' && sub === 'harvest') renderHarvests();
  if (page === 'batch' && sub === 'blocks') {
    renderBlockBook();
    loadBlockBook();
  }
  if (page === 'lab' && sub === 'cultures') renderCultures();
  if (page === 'lab' && sub === 'work') {
    lwUpdate();
    renderLabLog();
  }
  if (page === 'lab' && sub === 'lineage') fillLineageSelect();
  if (page === 'lab' && sub === 'contam') renderContamReports();
  if (page === 'inv' && sub === 'stock') renderInvStock();
  if (page === 'inv' && sub === 'delivery') {
    delMatChange();
    adjMatChange();
  }
  if (page === 'inv' && sub === 'log') renderInvLog();
  if (page === 'print' && sub === 'bags') fillBatchSelect();
  if (page === 'print' && sub === 'lab') {
    renderLabList();
    renderLabPreview();
  }
  if (page === 'print' && sub === 'ref') renderRefBarcodes();
  if (page === 'cal' && sub === 'cal') {
    loadCalDAVImports().then(() => renderCalendar());
    // Best effort, and deliberately not awaited: the calendar must draw whether
    // or not this answers. With no answer the warning simply does not fire.
    loadPickupBookings();
  }
  if (page === 'settings' && sub === 'caldav') loadCaldavSettings();
  if (page === 'settings' && sub === 'backup') loadUsbBackup();
  if (page === 'settings' && sub === 'duckdns') loadDuckdnsSettings();
  if (page === 'settings' && sub === 'harvestfeed') loadHarvestFeedSettings();
  if (page === 'settings' && sub === 'pickuplocations') renderPickupLocations();
  if (page === 'settings' && sub === 'versand') loadShipSettings();
  if (page === 'settings' && sub === 'channels') loadChannelsSettings();
  if (page === 'settings' && sub === 'mcp') loadMcpSettings();
  if (page === 'settings' && sub === 'log') renderLog();
  if (page === 'orders' && sub === 'pack') renderOrderBoard();
  if (page === 'orders' && sub === 'inbox') renderOrders();
  if (page === 'orders' && sub === 'tomake') renderOrdersDemand();
  if (page === 'orders' && sub === 'mapping') renderOrdersMapping();
  if (page === 'orders' && sub === 'customers') renderOrdersCustomers();
  if (page === 'orders' && sub === 'versand') renderOrdersVersand();
  if (page === 'proj') {
    if (sub === 'versuch') renderVersuch();
    else renderProjekte(sub);
  }
  // A phone shows one sub-page at a time, with a back row where the strip was.
  // Every route into a sub-page runs through here — the strip, the admin
  // drawer, and the dozen `openStab(...)` calls that land somewhere after a
  // save — so this is the one place that has to say "we are inside one now".
  //
  // Both guards are load-bearing. `stab-drill` is only on the pages
  // stabDrillInit() gave a back row to, so a page with no way out never gets
  // shut into a sub-page. And a tab that is not in the list is not a place to
  // land — see stabLandable().
  const pgEl = document.getElementById(`p-${page}`);
  if (pgEl && pgEl.classList.contains('stab-drill') && stabLandable(stEl, spEl))
    pgEl.classList.add('stab-drilled');
}

// A sub-tab hidden from the strip is not somewhere to send a phone: the list it
// would have to go back through does not offer it. Admin's "Server" is the one
// that exists — it opens on first visit of a session and is display:none in
// both the strip and the sidebar list — so on a phone Admin opens on its index
// instead of on a panel the index cannot reach.
// Zweiter Weg hinein: ein Panel, das ein Leitknopf über der Leiste öffnet.
//
// "Neue Charge" und "Laborarbeit" waren einmal Reiter. Sie sind jetzt Knöpfe
// über der Leiste, weil sie etwas anlegen statt eine Ansicht zu wechseln —
// und mit dem Reiter verschwand das Element, an dem diese Prüfung hängt. Am
// Telefon hieß das: Knopf antippen, .sp bekommt .active, und die Regel
// `.page.stab-drill:not(.stab-drilled) .sp { display: none }` lässt es
// trotzdem verborgen. Der Knopf tat nichts, auf genau dem Gerät, auf dem die
// Anwendung meistens läuft.
//
// Die ursprüngliche Begründung bleibt gültig: ein Ziel braucht einen Weg
// zurück, den die Liste anbietet. Ein Leitknopf ist so ein Weg, er steht nur
// über der Liste statt darin. Panels, die so erreicht werden, sagen es selbst.
function stabLandable(stab, panel) {
  if (panel && panel.dataset && panel.dataset.stabLead !== undefined) return true;
  return !!stab && stab.style.display !== 'none';
}

// Gives each sub-tab strip its back row and decides which of the two states the
// page starts in. See the drill-down block in styles.css for what the states
// look like; this only sets the class, so above 769px it is inert.
function stabDrillInit() {
  document.querySelectorAll('.page > .stabs').forEach((strip) => {
    // A strip that is hidden is not an index anyone can go back to, so a page
    // carrying one gets no back row. Nothing hides one today — Bestellungen was
    // the last, and its five views were sidebar entries at the time.
    if (strip.style.display === 'none') return;
    const page = strip.parentElement;
    const home = strip.dataset.stabHome;
    const back = document.createElement('button');
    back.type = 'button';
    back.className = 'stab-back';
    // The label is the page's own name — where the row leads, not where you
    // are; the sub-page below it already says that. data-i18n on the span (and
    // data-i18n-aria-label on the button) hands both to translatePage(), so a
    // language switch reaches them like any other string in the markup.
    back.setAttribute('data-i18n-aria-label', 'nav.backToList');
    back.setAttribute('aria-label', t('nav.backToList'));
    const ico = document.createElement('span');
    ico.className = 'stab-back-ico';
    ico.setAttribute('aria-hidden', 'true');
    ico.textContent = '\u2190';
    const label = document.createElement('span');
    label.setAttribute('data-i18n', home);
    label.textContent = t(home);
    back.append(ico, label);
    back.addEventListener('click', () => page.classList.remove('stab-drilled'));
    page.insertBefore(back, strip);
    // The marker is what scopes the CSS. Only a page that got a back row here
    // may have its sub-pages hidden, because only that page can get them back:
    // Bestellungen keeps its strip display:none on every device, and hiding its
    // panels on the way to an index it does not have renders the page blank.
    page.classList.add('stab-drill');
    // Land on the sub-page the page defaults to, unless that tab is not one you
    // can land on — same rule as openStab(), so both entrances agree.
    if (stabLandable(strip.querySelector('.stab.active'))) page.classList.add('stab-drilled');
  });
}
function refresh() {
  // P-05: invalidate the per-batch status cache before each render. This is
  // belt-and-braces — applyData() also clears it on incoming data — but
  // covers local mutation paths (push to scanLog, removeBag, etc.) that
  // don't go through applyData. The cache is rebuilt lazily on first
  // getStatus() call within this render pass, then reused for the rest.
  _statusByBatch = null;
  _rhythmIndex = null;
  _placementByBag = null;
  const active = document.querySelector('.page.active');
  if (!active) return;
  const id = active.id.replace('p-', '');
  // Arbeitsgänge ist die Seite, mit der die App aufmacht, und der einzige Zweig,
  // der ihre Liste zeichnete, hieß 'dash' — dort stand die Karte, bevor sie
  // hierher zog. Also zeigte ein Neuladen die sieben Kacheln und darüber nichts,
  // bis man die Seite verließ und über go() zurückkam; und solange man blieb,
  // frischte weder ein SSE-Ereignis noch der 30-Sekunden-Takt sie je auf.
  // #dash-batch-tasks steht nicht auf p-dash, der alte Zweig zeichnete also eine
  // Karte, die auf der offenen Seite gar nicht vorkommt.
  if (id === 'work') {
    renderDashBatchTasks();
    loadLabStock();
  }
  if (id === 'dash') renderOverviewKPIs();
  if (id === 'batch') {
    renderStatus();
    renderBatches();
  }
  if (id === 'lab') {
    loadLabStock();
    renderCultures();
    renderGrainBatches();
  }
  if (id === 'inv') renderInvStock();
  if (id === 'zones') renderZones();
  if (id === 'cal') renderCalendar();
  if (id === 'strains') renderStrains();
  if (id === 'proj') pjRefresh();
  if (id === 'orders') _refreshOrdersActive();
  // Pickups arrive on the feed's own schedule, not on any action taken here, so
  // this page has no other way to notice that something changed.
  if (id === 'pickups') renderPickups();
  updateTodoBadge();
  // Cheap, and how a language switch reaches the stick reminder and card.
  renderUsbReminder();
  if (id === 'settings') renderUsbCard();
}

// ═══ ORDER HUB (Phase 0) ═══════════════════════════════════════════════════
// Sales orders → products → production demand. Fetched on demand from the
// dedicated /api/orders, /api/products, /api/customers endpoints (not the
// /api/data full-state blob). See ORDERS_HUB_DESIGN.md.
//
// Module-level DOM helper: the app's main `$` is declared local to
// initEventListeners, so these top-level render functions need their own.
const $ = (id) => document.getElementById(id);
let _ordersCache = [];
let _ordersFilter = '';

function _ohEmpty(cols, msg) {
  return `<tr><td class="empty" colspan="${cols}" style="text-align:center;padding:16px;color:var(--c-text-muted)">${esc(msg)}</td></tr>`;
}
// The channel's name on its own. _ohChannel() wraps it in the pill; the search
// index wants the words without the markup around them.
function _ohChannelLabel(ch) {
  return ch === 'manual' ? t('orders.manual') : ch === 'ebay' ? 'eBay' : ch ? ch.charAt(0).toUpperCase() + ch.slice(1) : '—';
}
function _ohChannel(ch) {
  return `<span class="oh-ch oh-ch-${esc(ch || 'manual')}">${esc(_ohChannelLabel(ch))}</span>`;
}
function _ohStatus(st) {
  return `<span class="oh-st oh-st-${esc(st)}">${esc(t('orders.status.' + st))}</span>`;
}
function _refreshOrdersActive() {
  const active = document.querySelector('#p-orders .sp.active');
  const id = active ? active.id : 'sp-orders-pack';
  if (id === 'sp-orders-pack') renderOrderBoard();
  else if (id === 'sp-orders-tomake') renderOrdersDemand();
  else if (id === 'sp-orders-mapping') renderOrdersMapping();
  else if (id === 'sp-orders-customers') renderOrdersCustomers();
  else if (id === 'sp-orders-versand') renderOrdersVersand();
  else renderOrders();
}

// One reader for the orders snapshot, and one for the customer list. Both used
// to be fetched from two places — the page that shows them and the search that
// indexes them — each writing the module cache from its own `.then()`, so two
// answers to the same question could land in either order and the table could
// end up showing rows the cache no longer held. The endpoint and its limit are
// written once here, so they cannot drift apart either.
function loadOrders() {
  return apiGet('/api/orders?limit=500').then((d) => {
    _ordersCache = d.items || [];
    gsIndexInvalidate();
    return _ordersCache;
  });
}
// The server caps this at 1000 and defaults to 200 (db.listCustomers), ordered
// by spend. 200 is a fine page of a table and a poor thing to search: a customer
// past the first 200 would not exist as far as the palette was concerned, with
// nothing on screen to say so. Ask for the maximum the server will give.
function loadCustomers() {
  return apiGet('/api/customers?limit=1000').then((d) => {
    _ohCustomerCache = d.items || [];
    gsIndexInvalidate();
    return _ohCustomerCache;
  });
}

function renderOrders() {
  const body = $('orders-body');
  if (!body) return;
  loadOrders()
    .then(() => {
      _renderOrdersInbox();
    })
    .catch(() => {
      body.innerHTML = _ohEmpty(6, t('common.error'));
    });
  apiGet('/api/products/unmapped')
    .then((d) => {
      const items = d.items || [];
      const banner = $('orders-unmapped-banner');
      if (banner) banner.style.display = items.length ? 'flex' : 'none';
      const cnt = $('orders-unmapped-count');
      if (cnt) cnt.textContent = items.length;
    })
    .catch(() => {});
}

function _renderOrdersInbox() {
  const body = $('orders-body');
  if (!body) return;
  const rows = _ordersCache.filter((o) => !_ordersFilter || o.status === _ordersFilter);
  if (!rows.length) {
    body.innerHTML = _ohEmpty(6, t('orders.none'));
    return;
  }
  body.innerHTML = rows
    .map(
      (o) =>
        `<tr data-find="order:${esc(o.channelOrderId)}"><td data-mlabel="${esc(t('orders.th.channel'))}">${_ohChannel(o.channel)}</td>` +
        `<td class="fs-xs" data-mlabel="${esc(t('orders.th.order'))}" style="font-family:monospace">${esc(o.channelOrderId)}</td>` +
        `<td data-mlabel="${esc(t('orders.th.customer'))}">${esc(o.customerName || '—')}</td>` +
        `<td data-mlabel="${esc(t('orders.th.items'))}">${o.itemCount || 0}${o.unmappedCount ? ` <span class="oh-warn" title="${esc(t('orders.unmappedLines'))}">⚠︎</span>` : ''}</td>` +
        `<td class="fs-xs" data-mlabel="${esc(t('orders.th.shipBy'))}">${o.shipBy ? esc(fmtDt(o.shipBy)) : '—'}</td>` +
        `<td data-mlabel="${esc(t('orders.th.status'))}">${_ohStatus(o.status)}</td></tr>`
    )
    .join('');
}

function setOrdersFilter(f) {
  _ordersFilter = f;
  document
    .querySelectorAll('#sp-orders-inbox .oh-fchip')
    .forEach((c) => c.classList.toggle('active', (c.dataset.filter || '') === f));
  _renderOrdersInbox();
}

function renderOrdersDemand() {
  const body = $('demand-body');
  if (!body) return;
  body.innerHTML = _ohEmpty(6, t('common.loading'));
  apiGet('/api/orders/demand')
    .then((d) => {
      const rows = d.items || [];
      if (!rows.length) {
        body.innerHTML = _ohEmpty(5, t('orders.demandNone'));
        return;
      }
      body.innerHTML = rows
        .map((r) => {
          const comps = (r.components || []).length
            ? r.components
                .map(
                  (c) =>
                    `<span style="display:inline-block;margin-right:10px;${c.short > 0 ? 'color:var(--c-red-dark);font-weight:600' : 'color:var(--c-text-muted)'}">` +
                    `${esc(_ohMatName(c.mat))} ${_ohN(c.need)}${esc(c.unit || '')}${c.short > 0 ? ' (−' + _ohN(c.short) + ')' : ' ✓'}</span>`
                )
                .join('')
            : '<span class="muted">—</span>';
          const produce =
            r.toProduce > 0
              ? `<strong>${r.toProduce}</strong>${r.componentsShort ? ` <span class="oh-warn" title="${esc(t('orders.componentsShort'))}">⚠︎</span>` : ''}`
              : r.backorder > 0
                ? `<span class="oh-st oh-st-cancelled">${r.backorder} ${esc(t('orders.backorder'))}</span>`
                : '<span class="muted">0</span>';
          return (
            `<tr><td data-mlabel="${esc(t('orders.th.product'))}"><strong>${esc(r.product)}</strong> <span class="muted fs-xs">${esc(r.category || '')}</span></td>` +
            `<td data-mlabel="${esc(t('orders.th.demand'))}">${r.demand}</td>` +
            `<td data-mlabel="${esc(t('orders.th.fromStock'))}">${r.fromStock}</td>` +
            `<td data-mlabel="${esc(t('orders.th.toProduce'))}">${produce}</td>` +
            `<td class="fs-xs" data-mlabel="${esc(t('orders.th.startBy'))}">${r.startBy ? esc(r.startBy) : '—'}</td>` +
            `<td class="fs-xs" data-mlabel="${esc(t('orders.th.components'))}">${comps}</td></tr>`
          );
        })
        .join('');
    })
    .catch(() => {
      body.innerHTML = _ohEmpty(5, t('common.error'));
    });
}

// ── Packen: what to pack today, what to make first ─────────────────────────
// The shop orders Billbee holds, as the floor reads them: one card per parcel
// to pack, and one total per made-to-order product. Built on the server
// (order-board.js) from what the 15-minute Billbee sync stored; this only draws.
// Cards rather than a table, because this is read on a phone at the packing
// table, and a table of five columns is a sideways scroll there.
//
// "Gepackt" and "Hergestellt" are this app's own marks, one tap each with an
// undo. Billbee's state is shown on the card and decides nothing: its rules
// buy the label and set "Versendet" before anybody has packed the parcel.
let _obBoard = null;

// A day as the floor says it: "heute", "morgen", or a short weekday and date.
// Parsed at noon so no time zone can push the day over midnight.
function _obDay(day) {
  if (!day || !_obBoard) return '';
  if (day === _obBoard.today) return t('orders.board.today');
  const d = new Date(day + 'T12:00:00');
  const tomorrow = new Date(_obBoard.today + 'T12:00:00');
  tomorrow.setDate(tomorrow.getDate() + 1);
  if (d.toDateString() === tomorrow.toDateString()) return t('orders.board.tomorrow');
  return d.toLocaleDateString(loc(), { weekday: 'short', day: '2-digit', month: '2-digit' });
}
function _obTime(iso) {
  const d = new Date(iso);
  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
// Who and when, for a mark: "10:42 · erika", or the date when it was not today.
function _obWhoWhen(m) {
  if (!m) return '';
  const day = fmtDtShort(m.at);
  const when = day === fmtDtShort(new Date()) ? _obTime(m.at) : day + ' ' + _obTime(m.at);
  return m.by ? when + ' · ' + m.by : when;
}
// The shop an order came from. Billbee orders name it; the direct connections
// are their own shop. The pill colour follows the shop, the text is its name.
function _obShop(o) {
  const name = o.shop || _ohChannelLabel(o.channel);
  const n = String(name).toLowerCase();
  const slug = ['ebay', 'etsy', 'wix', 'shopify'].find((s) => n.includes(s)) || 'manual';
  return `<span class="oh-ch oh-ch-${slug}">${esc(name)}</span>`;
}
function _obBillbee(o) {
  if (o.channel !== 'billbee') return '';
  return `<span class="ob-bb ob-bb-${esc(o.billbee)}">${esc(t('orders.board.bb.' + o.billbee))}</span>`;
}
function _obItems(items) {
  if (!items || !items.length) return `<div class="muted fs-meta">${esc(t('orders.board.noItems'))}</div>`;
  return (
    '<ul class="ob-items">' +
    items
      .map(
        (it) =>
          // Quantity and SKU, nothing else: the SKU is what is on the shelf.
          // A line without one shows its title instead, so it is not lost.
          `<li${it.title ? ` title="${esc(it.title)}"` : ''}><span class="ob-qty">${esc(it.qty)}×</span> ` +
          (it.sku
            ? `<span class="ob-line-sku">${esc(it.sku)}</span>`
            : `<span class="ob-title">${esc(it.title || '—')}</span>`) +
          (it.madeToOrder ? ` <span class="ob-tag ob-tag-make">${esc(t('orders.board.madeTag'))}</span>` : '') +
          '</li>'
      )
      .join('') +
    '</ul>'
  );
}
function _obMarkBtn(o, kind, on, primary) {
  const label = on
    ? t(kind === 'packed' ? 'orders.board.markPacked' : 'orders.board.markMade')
    : t(kind === 'packed' ? 'orders.board.unpack' : 'orders.board.unmade');
  return (
    `<button class="btn btn-sm${primary ? ' btn-p' : ''}" data-action="ob-mark" data-id="${o.id}"` +
    ` data-kind="${kind}" data-on="${on ? 1 : 0}">${esc(label)}</button>`
  );
}
function _obPlan(o) {
  if (!o.plannedLabelDay) return '';
  return `<span class="ob-plan${o.labelDue ? ' is-due' : ''}">${esc(t('orders.board.labelOn', { day: _obDay(o.plannedLabelDay) }))}</span>`;
}
function _obOrderCard(o, mode) {
  // The person first, so the parcel can be matched to a label at a glance.
  const who = o.customer ? `<div class="ob-customer">${esc(o.customer)}</div>` : '';
  const top =
    who +
    `<div class="ob-order-top">${_obShop(o)}` +
    `<span class="ob-num">${esc(o.number)}</span>` +
    `<span class="ob-when">${esc(_obDay(o.orderDay))}</span>` +
    _obBillbee(o) +
    (o.webUrl
      ? `<a class="ob-link" href="${esc(o.webUrl)}" target="_blank" rel="noopener">${esc(t('orders.board.openBillbee'))}</a>`
      : '') +
    '</div>';
  const notes = [];
  if (o.combine && o.combine.size === 1) notes.push(t('orders.board.combineTagged'));
  if (o.waitsForGroup) notes.push(t('orders.board.waitsForGroup'));
  const noteHtml = notes.map((n) => `<div class="ob-combine-solo">${esc(n)}</div>`).join('');
  let foot = '';
  if (mode === 'pack') {
    const made = o.made
      ? `<span class="ob-done">✓ ${esc(t('orders.board.madeBy', { who: _obWhoWhen(o.made) }))}</span>` +
        _obMarkBtn(o, 'made', false, false)
      : '';
    foot = `<div class="ob-order-foot">${_obPlan(o)}${made}${_obMarkBtn(o, 'packed', true, true)}</div>`;
  } else if (mode === 'later') {
    const btn = o.needsMaking ? _obMarkBtn(o, 'made', true, true) : '';
    foot = `<div class="ob-order-foot">${_obPlan(o)}${btn}</div>`;
  } else if (mode === 'done') {
    foot =
      `<div class="ob-order-foot"><span class="ob-done">✓ ${esc(t('orders.board.packedBy', { who: _obWhoWhen(o.packed) }))}</span>` +
      _obMarkBtn(o, 'packed', false, false) +
      '</div>';
  }
  return `<div class="ob-order${mode === 'done' ? ' is-packed' : ''}">${top}${noteHtml}${_obItems(o.items)}${foot}</div>`;
}
// Orders that go into one parcel sit in one frame, with the reason on top, so
// nobody tapes up the first box before the second order is in it.
function _obOrderList(rows, mode) {
  let html = '';
  for (let i = 0; i < rows.length; i++) {
    const o = rows[i];
    const key = o.combine && o.combine.key;
    if (key && o.combine.size > 1) {
      const group = [];
      while (i < rows.length && rows[i].combine && rows[i].combine.key === key) group.push(rows[i++]);
      i--;
      // The other order of the pair is in another list (packed already, say).
      // Say so on the card instead of framing a group of one.
      if (group.length === 1) {
        html += _obOrderCard(o, mode).replace(
          '<ul class="ob-items">',
          `<div class="ob-combine-solo">${esc(t('orders.board.combineElsewhere'))}</div><ul class="ob-items">`
        );
        continue;
      }
      html +=
        `<div class="ob-group"><div class="ob-group-head">${esc(t('orders.board.combine', { n: group.length }))}</div>` +
        group.map((g) => _obOrderCard(g, mode)).join('') +
        '</div>';
    } else {
      html += _obOrderCard(o, mode);
    }
  }
  return html;
}
function _obEmpty(msg) {
  return `<div class="ob-empty">${esc(msg)}</div>`;
}
function _obSyncLine(b) {
  const el = $('ob-sync');
  if (!el) return;
  const s = b.sync || {};
  const when = s.lastSync ? fmtDtTime(s.lastSync) : null;
  const parts = [];
  if (s.problem) parts.push(`<span>${esc(t('orders.board.sync.' + s.problem, { time: when || '—' }))}</span>`);
  else if (when) parts.push(`<span class="muted">${esc(t('orders.board.syncedAt', { time: when }))}</span>`);
  if (b.unpaid)
    parts.push(
      `<span class="muted">${esc(t(b.unpaid === 1 ? 'orders.board.unpaid.one' : 'orders.board.unpaid.other', { n: b.unpaid }))}</span>`
    );
  if (s.canSync) parts.push(`<button class="btn btn-sm" data-action="ob-sync-now">${esc(t('orders.board.syncNow'))}</button>`);
  el.innerHTML = parts.join('');
  el.classList.toggle('is-warn', !!s.problem && s.problem !== 'never');
  el.style.display = parts.length ? 'flex' : 'none';
}
// A size as the shelf says it: 1750 → "1,75 kg", 700 → "700 g". Sizes that are
// not grams (a sort, an old SKU number) are shown as they are.
function _obSize(sz) {
  if (sz.grams == null) return sz.size || '';
  if (sz.grams >= 1000) return (sz.grams / 1000).toLocaleString(loc(), { maximumFractionDigits: 2 }) + ' kg';
  return sz.grams + ' g';
}
// Alles zusammen: every open order counted once, per product — the list to
// pull from the shelf before packing order by order.
function _obTotals(totals) {
  if (!totals || !totals.length) return _obEmpty(t('orders.board.totalsNone'));
  return totals
    .map((tt) => {
      const sizes = tt.sizes
        .filter((sz) => sz.size != null)
        .map((sz) => `<span class="ob-size">${esc(sz.pieces)} × ${esc(_obSize(sz))}</span>`)
        .join('');
      const skus = tt.skus.map((x) => `${esc(x.qty)}× ${esc(x.sku)}`).join(' · ');
      return (
        `<div class="ob-total">` +
        `<div class="ob-total-top"><span class="ob-total-n">${esc(tt.pieces)}</span>` +
        `<span class="ob-total-name">${esc(tt.product || tt.title || '—')}</span>` +
        (tt.madeToOrder ? ` <span class="ob-tag ob-tag-make">${esc(t('orders.board.madeTag'))}</span>` : '') +
        `<span class="ob-total-orders">${esc(t(tt.orders === 1 ? 'orders.board.ordersN.one' : 'orders.board.ordersN.other', { n: tt.orders }))}</span></div>` +
        (sizes ? `<div class="ob-sizes">${sizes}</div>` : '') +
        (skus ? `<div class="ob-total-skus">${skus}</div>` : '') +
        '</div>'
      );
    })
    .join('');
}
function _obMakeList(make) {
  if (!make.length) return _obEmpty(t('orders.board.makeNone'));
  return make
    .map(
      (m) =>
        `<div class="ob-make${m.overdue ? ' is-late' : ''}">` +
        `<div class="ob-make-top"><span class="ob-make-qty">${esc(m.qty)}×</span>` +
        `<span class="ob-line-sku"${m.title ? ` title="${esc(m.title)}"` : ''}>${esc(m.sku)}</span></div>` +
        `<div class="ob-make-meta">${esc(t(m.orders === 1 ? 'orders.board.ordersN.one' : 'orders.board.ordersN.other', { n: m.orders }))}</div>` +
        '<div class="ob-make-days">' +
        m.byDay
          .map(
            (d) =>
              `<span class="ob-day${d.day <= _obBoard.today ? ' is-late' : ''}">${esc(t('orders.board.labelOnQty', { qty: d.qty, day: _obDay(d.day) }))}</span>`
          )
          .join('') +
        '</div></div>'
    )
    .join('');
}
function _renderOrderBoard() {
  const b = _obBoard;
  if (!b) return;
  _obSyncLine(b);
  const tot = $('ob-totals');
  if (tot) tot.innerHTML = _obTotals(b.totals);
  const cnt = $('ob-pack-count');
  if (cnt) cnt.textContent = b.pack.length ? String(b.pack.length) : '';
  const pack = $('ob-pack');
  if (pack) pack.innerHTML = b.pack.length ? _obOrderList(b.pack, 'pack') : _obEmpty(t('orders.board.packNone'));
  const make = $('ob-make');
  if (make) make.innerHTML = _obMakeList(b.make);
  const laterCard = $('ob-later-card');
  if (laterCard) laterCard.style.display = b.later.length ? '' : 'none';
  const later = $('ob-later');
  if (later) later.innerHTML = _obOrderList(b.later, 'later');
  const lc = $('ob-later-count');
  if (lc) lc.textContent = b.later.length ? String(b.later.length) : '';
  const done = $('ob-done');
  if (done) done.innerHTML = b.done.length ? _obOrderList(b.done, 'done') : _obEmpty(t('orders.board.doneNone'));
  const dc = $('ob-done-count');
  if (dc) dc.textContent = b.done.length ? String(b.done.length) : '';
}
function renderOrderBoard() {
  const pack = $('ob-pack');
  if (!pack) return;
  if (!_obBoard) pack.innerHTML = _obEmpty(t('common.loading'));
  apiGet('/api/orders/board')
    .then((b) => {
      _obBoard = b;
      _renderOrderBoard();
    })
    .catch(() => {
      // Keep the last list on screen if there is one: a dropped connection on
      // the floor should not blank what somebody is packing from.
      const el = $('ob-sync');
      if (el) {
        el.innerHTML = `<span>${esc(t('orders.board.loadFailed'))}</span>`;
        el.classList.add('is-warn');
        el.style.display = 'flex';
      }
      if (!_obBoard) pack.innerHTML = '';
    });
}
// One tap sets the mark, the bar offers the way back. The mark lives in this
// app (order_marks) with who and when; Billbee is not told.
function _obSetMark(id, kind, on) {
  return apiPost('/api/orders/board/mark', { id: Number(id), kind, on }).then((r) => {
    if (r && r.error) {
      toast(t('orders.board.saveFailed'), 'err');
      return false;
    }
    return true;
  });
}
function obMark(id, kind, on) {
  _obSetMark(id, kind, on).then((ok) => {
    if (!ok) return;
    renderOrderBoard();
    const msg = t(
      kind === 'packed'
        ? on
          ? 'orders.board.packedMsg'
          : 'orders.board.unpackedMsg'
        : on
          ? 'orders.board.madeMsg'
          : 'orders.board.unmadeMsg'
    );
    showUndoBar(msg, () => _obSetMark(id, kind, !on).then(() => renderOrderBoard()));
  });
}
function obSyncNow(btn) {
  if (btn) btn.disabled = true;
  apiPost('/api/channels/billbee/sync').then((r) => {
    if (btn) btn.disabled = false;
    if (r && r.error) toast(t('orders.board.syncFailed'), 'err');
    renderOrderBoard();
  });
}

function renderOrdersMapping() {
  renderInventoryCard();
  const left = $('orders-unmapped-list');
  if (!left) return;
  Promise.all([apiGet('/api/products/unmapped'), apiGet('/api/products')])
    .then(([u, p]) => {
      const products = p.items || [];
      const opts = products.map((pr) => `<option value="${pr.id}">${esc(pr.name)}</option>`).join('');
      const items = u.items || [];
      left.innerHTML = items.length
        ? items
            .map(
              (it) =>
                `<div class="oh-maprow">${_ohChannel(it.channel)}` +
                `<div style="flex:1;min-width:0"><div class="fs-sm" style="font-weight:600">${esc(it.title || it.channelSku || it.listingId || '—')}</div>` +
                `<div class="muted fs-xs" style="font-family:monospace">${esc(it.channelSku || it.listingId || '')} · ${it.qty || 0}×</div></div>` +
                `<select class="oh-mapsel fs-meta" data-channel="${esc(it.channel)}" data-sku="${esc(it.channelSku || '')}" data-listing="${esc(it.listingId || '')}" data-title="${esc(it.title || '')}" style="width:auto">` +
                `<option value="">— ${esc(t('orders.choose'))} —</option>${opts}</select>` +
                `<button class="btn btn-sm btn-p" data-action="oh-map">${esc(t('orders.assign'))}</button></div>`
            )
            .join('')
        : `<div style="padding:14px;color:var(--c-text-muted)">${esc(t('orders.allMapped'))}</div>`;
      // The standing mappings, and the one form that can create one without an
      // order having carried the listing in first.
      const fixSel = $('oh-fixmap-product');
      if (fixSel) {
        const keep = fixSel.value;
        fixSel.innerHTML = `<option value="">— ${esc(t('orders.choose'))} —</option>${opts}`;
        if (keep) fixSel.value = keep;
      }
      renderFixedMappings();
      const cat = $('orders-catalog-body');
      if (cat) {
        cat.innerHTML = products.length
          ? products
              .map(
                (pr) =>
                  `<tr><td data-mlabel="${esc(t('orders.th.product'))}"><strong>${esc(pr.name)}</strong></td>` +
                  `<td data-mlabel="${esc(t('orders.th.category'))}">${esc(pr.category || '—')}</td>` +
                  `<td data-mlabel="${esc(t('orders.th.sku'))}" class="muted fs-xs">${esc(pr.sku || '')}</td>` +
                  `<td class="oh-actions"><button class="btn btn-sm" data-action="oh-prod-edit" data-id="${pr.id}">${esc(t('orders.edit'))}</button></td></tr>`
              )
              .join('')
          : _ohEmpty(4, t('orders.noProducts'));
      }
    })
    .catch(() => {
      left.innerHTML = `<div style="padding:14px;color:var(--c-text-muted)">${esc(t('common.error'))}</div>`;
    });
}

// The mappings that already stand for the channel chosen in the form, so a typo
// is visible and can be corrected by mapping the same SKU again (the insert is an
// upsert). Without this list the mapping is a black box: these rows are what
// every incoming order line is resolved against, and nothing else.
function renderFixedMappings() {
  const box = $('orders-fixmap-list');
  const sel = $('oh-fixmap-channel');
  if (!box || !sel) return;
  apiGet('/api/products/mappings?channel=' + encodeURIComponent(sel.value))
    .then((d) => {
      const rows = (d && d.items) || [];
      box.innerHTML = rows.length
        ? rows
            .map(
              (m) =>
                `<div class="oh-maprow"><div style="flex:1;min-width:0">` +
                `<span style="font-family:monospace">${esc(m.channelSku || m.listingId || '—')}</span>` +
                ` → <strong>${esc(m.productName || '—')}</strong>` +
                (m.productActive === 0 ? ` <span class="muted fs-xs">(${esc(t('orders.retired'))})</span>` : '') +
                `</div></div>`
            )
            .join('')
        : `<div class="muted fs-meta" style="padding:8px 2px">${esc(t('orders.fixmapNone'))}</div>`;
    })
    .catch(() => {
      box.innerHTML = `<div class="muted fs-meta" style="padding:8px 2px">${esc(t('common.error'))}</div>`;
    });
}

// Set when an eBay account-closure notification is clicked, so the row that the
// request is about is findable in a 200-row table. Cleared on the next render.
let _ohHighlightCustomer = null;
// Kept for the search index. This table re-fetches on every visit and renders
// straight from the response, so nothing here remembered a customer between
// renders — and the palette cannot search a list that only ever existed inside
// a .then().
let _ohCustomerCache = [];
function renderOrdersCustomers() {
  const body = $('orders-customers-body');
  if (!body) return;
  // /api/customers is behind requireShipping, not requireAdmin, so packers load
  // this table too. Gate the header with the cells — otherwise they get a titled,
  // permanently empty column stealing width on a table already wide enough to
  // need horizontal scrolling on a phone. Same pattern as showAdminNav().
  const isAdmin = !!(currentUser && currentUser.role === 'admin');
  const privacyTh = document.getElementById('orders-customers-privacy-th');
  if (privacyTh) privacyTh.hidden = !isAdmin;
  const cols = isAdmin ? 7 : 6;
  body.innerHTML = _ohEmpty(cols, t('common.loading'));
  loadCustomers()
    .then((rows) => {
      if (!rows.length) {
        body.innerHTML = _ohEmpty(cols, t('orders.noCustomers'));
        return;
      }
      body.innerHTML = rows
        .map((c) => {
          const chans = (c.channels || '').split(',').filter(Boolean).map(_ohChannel).join(' ');
          const ltv = c.totalSpent != null ? Number(c.totalSpent).toFixed(2) : '0.00';
          // Ask the server, do not infer. `!c.name && !c.email` looked equivalent
          // but was wrong for exactly the population this feature serves: eBay
          // masks the buyer email and carries identity in customer_identities, so
          // those customers have neither column and were never erased — yet they
          // rendered as "already erased" while their shipping address, phone and
          // raw payload sat in orders, with no way to run the erasure. Manual
          // entry and CSV import without those columns produce the same shape.
          // erased_at is written only by eraseCustomer.
          const erased = !!c.erasedAt;
          // btn-r is the repo's destructive style (delete batch, remove member,
          // delete camera). No inline padding: it would override the coarse-pointer
          // rule that gives every .btn-sm a 44px target, which is not something the
          // most destructive control in the app should opt out of.
          const privacy = !isAdmin
            ? ''
            : erased
              ? `<span class="fs-xs" style="color:var(--c-text-muted)">${esc(t('orders.erased'))}</span>`
              : `<button type="button" class="btn btn-sm btn-r" data-action="oh-erase-customer" data-id="${esc(String(c.id))}" data-name="${esc(c.name || c.email || String(c.id))}">${esc(t('orders.erase'))}</button>`;
          // Arrived here from an account-closure notification: mark the row the
          // request is about, otherwise it is one of up to 200 and the admin has
          // only an id to go on.
          const hit = _ohHighlightCustomer && c.id === _ohHighlightCustomer;
          return (
            `<tr data-find="customer:${esc(String(c.id))}"${hit ? ' style="outline:2px solid var(--c-red-dark);outline-offset:-2px"' : ''}>` +
            `<td data-mlabel="${esc(t('orders.th.customer'))}"><strong>${esc(c.name || c.email || '—')}</strong></td>` +
            `<td data-mlabel="${esc(t('orders.th.channels'))}">${chans}</td>` +
            `<td data-mlabel="${esc(t('orders.th.orders'))}">${c.orderCount || 0}</td>` +
            `<td data-mlabel="${esc(t('orders.th.ltv'))}"><strong>${ltv} ${esc(c.currency || '€')}</strong></td>` +
            `<td class="fs-xs" data-mlabel="${esc(t('orders.th.last'))}">${c.lastOrder ? esc(fmtDt(c.lastOrder)) : '—'}</td>` +
            `<td data-mlabel="${esc(t('orders.th.type'))}">${(c.orderCount || 0) > 1 ? `<span class="oh-st oh-st-ready">${esc(t('orders.repeat'))}</span>` : ''}</td>` +
            (isAdmin ? `<td data-mlabel="${esc(t('orders.th.privacy'))}">${privacy}</td>` : '') +
            '</tr>'
          );
        })
        .join('');
      // Consumed by this render; a later visit must not re-highlight whatever
      // row now holds that id.
      _ohHighlightCustomer = null;
    })
    .catch(() => {
      body.innerHTML = _ohEmpty(cols, t('common.error'));
    });
}
// ═══ PICKUPS ═══════════════════════════════════════════════════════════════
// Collection slots the harvest-feed receiver reported back in its reply. Every
// value on this page came from outside this machine, so every value goes
// through esc() — including the ones that "obviously" cannot contain markup,
// because the reason they cannot is a validation rule in another file that
// somebody may relax later.
//
// Read-only by design. The receiver took the booking and repeats each open
// pickup until this end confirms it; an edit here would be overwritten by the
// next reply that carried the same id.

/**
 * The window, as the customer was told it.
 *
 * slotText is the receiver's own wording and wins whenever it is there — it
 * already reads the way the confirmation email did. The fallback assembles
 * from/to, which are LOCAL wall-clock at the pickup place: printed as they
 * arrived, never parsed into a Date. Feeding them to the browser's clock would
 * reinterpret them in whoever-is-looking's timezone and silently move a 9 a.m.
 * pickup by an hour or two.
 */
function _pickupWhen(p) {
  if (p.slotText) return esc(p.slotText);
  if (!p.from) return '—';
  const day = p.from.slice(0, 10);
  const fromTime = p.from.slice(11, 16);
  const toTime = p.to ? p.to.slice(11, 16) : '';
  return esc(day + ' ' + fromTime + (toTime ? '–' + toTime : ''));
}

// Which half of the list is on screen. Not remembered across a reload: the half
// that is still work is what this page is opened for, and coming back to a
// screen full of finished collections is the state this switch exists to end.
let pickupsPast = false;

function renderPickups() {
  // The upper half of the page. Not awaited: the two halves come from different
  // routes and neither needs the other, so making the bookings wait on the
  // release table would only add a stall to the half people open this page for.
  loadHarvestReleases();

  const body = $('pickups-body');
  if (!body) return;
  const count = $('pickups-count');
  const schalter = $('pickups-past-btn');
  if (schalter) schalter.textContent = t(pickupsPast ? 'pickups.showUpcoming' : 'pickups.showPast');
  body.innerHTML = _ohEmpty(5, t('common.loading'));
  apiGet('/api/pickups' + (pickupsPast ? '?past=1' : ''))
    .then((d) => {
      const rows = d.items || [];
      if (count) count.textContent = rows.length ? t('pickups.count', { n: rows.length }) : '';
      if (!rows.length) {
        // Two different empties. "Nothing booked" is news; "nothing has
        // happened yet" is not, and telling them apart stops an empty past
        // list from reading like a lost one.
        body.innerHTML = _ohEmpty(5, t(pickupsPast ? 'pickups.nonePast' : 'pickups.none'));
        return;
      }
      body.innerHTML = rows
        .map((p) => {
          const items = (p.items || [])
            .map((it) => esc(it.kind) + ' ' + Math.round(it.grams) + ' g')
            .join('<br>');
          // The zone belongs next to the time or the time means nothing. A
          // pickup in another country is exactly when the label earns its width.
          const zone = p.zone ? `<div class="fs-xs" style="color:var(--c-text-muted)">${esc(p.zone)}</div>` : '';
          // Two different states, and only one of them is a problem. Overbooked
          // is the receiver saying it sold more of that slot than it should
          // have; unconfirmed just means the next push has not gone out yet.
          const state = p.overbooked
            ? `<span class="oh-st oh-st-over">${esc(t('pickups.overbooked'))}</span>`
            : p.acked
              ? `<span class="oh-st oh-st-ready">${esc(t('pickups.confirmed'))}</span>`
              : `<span class="fs-xs" style="color:var(--c-text-muted)">${esc(t('pickups.pending'))}</span>`;
          return (
            '<tr>' +
            `<td data-mlabel="${esc(t('pickups.th.when'))}"><strong>${_pickupWhen(p)}</strong>${zone}</td>` +
            `<td data-mlabel="${esc(t('pickups.th.place'))}">${esc(p.place || '—')}</td>` +
            `<td data-mlabel="${esc(t('pickups.th.order'))}">${esc(p.order || '—')}</td>` +
            `<td class="fs-meta" data-mlabel="${esc(t('pickups.th.items'))}">${items || '—'}</td>` +
            `<td data-mlabel="${esc(t('pickups.th.state'))}">${state}</td>` +
            '</tr>'
          );
        })
        .join('');
    })
    .catch(() => {
      body.innerHTML = _ohEmpty(5, t('common.error'));
    });
}

// Carry out a marketplace account-closure request. This is the authenticated
// counterpart to the eBay deletion endpoint, which records such requests and
// notifies admins but never erases on its own — that endpoint is unauthenticated
// by necessity, so anyone able to reach the host would otherwise be able to
// destroy a customer's order history with one request.
//
// Irreversible: the server NULLs the customer's name and email, every ship_*
// field on their orders, and the raw_json that could have restored them. The
// order rows themselves survive, so revenue history stays intact.
function eraseCustomer(id, name) {
  confirm2(t('orders.eraseTitle'), t('orders.eraseMsg', { name }), t('orders.eraseYes'), () => {
    apiPost('/api/customers/erase', { customerId: Number(id) }).then((r) => {
      if (r && r.error) {
        setFb('err', r.error);
        return;
      }
      setFb('ok', t('orders.eraseDone', { name, n: (r && r.orders) || 0 }));
      renderOrdersCustomers();
    });
  });
}

// ── Versand: order list + buy-label modal ──
function renderOrdersVersand() {
  const body = $('versand-orders-body');
  if (!body) return;
  body.innerHTML = _ohEmpty(5, t('common.loading'));
  authFetch('/api/ship/config')
    .then((r) => r.json())
    .then((cfg) => {
      const warn = $('versand-config-warn');
      if (warn) warn.style.display = cfg && cfg.enabled && cfg.publicKey ? 'none' : 'block';
    })
    .catch(() => {});
  apiGet('/api/orders?limit=500')
    .then((d) => {
      const rows = (d.items || []).filter((o) => o.status !== 'cancelled');
      if (!rows.length) {
        body.innerHTML = _ohEmpty(5, t('orders.none'));
        return;
      }
      body.innerHTML = rows
        .map((o) => {
          const btn =
            o.status === 'shipped'
              ? `<button class="btn btn-sm" data-action="oh-ship-open" data-order-id="${o.id}">${t('versand.relabel')}</button>`
              : `<button class="btn btn-sm btn-p" data-action="oh-ship-open" data-order-id="${o.id}">${t('versand.shipBtn')}</button>`;
          return (
            `<tr><td>${_ohChannel(o.channel)}</td>` +
            `<td class="fs-xs" style="font-family:monospace">${esc(o.channelOrderId)}</td>` +
            `<td>${esc(o.customerName || '—')}</td>` +
            `<td>${_ohStatus(o.status)}</td>` +
            `<td style="white-space:nowrap">${btn}</td></tr>`
          );
        })
        .join('');
    })
    .catch(() => {
      body.innerHTML = _ohEmpty(5, t('common.error'));
    });
}

function shipModalClose() {
  const m = $('ship-modal');
  if (m) m.style.display = 'none';
}
function shipModalOpen(orderId) {
  if (!orderId) return;
  const m = $('ship-modal');
  if (!m) return;
  $('ship-m-orderid').value = orderId;
  $('ship-m-result').textContent = '';
  const set = (id, v) => {
    const el = document.getElementById(id);
    if (el) el.value = v == null ? '' : v;
  };
  // Prefill from the stored order (ship_* filled by a prior label or channel sync).
  apiGet('/api/ship/order/' + orderId)
    .then((o) => {
      if (!o || o.error) o = {};
      $('ship-m-sub').textContent = (o.channel ? o.channel + ' · ' : '') + (o.channelOrderId || '#' + orderId);
      set('ship-m-name', o.shipName || o.customerName || '');
      set('ship-m-company', o.shipCompany);
      set('ship-m-street', o.shipStreet);
      set('ship-m-house', o.shipHouse);
      set('ship-m-address2', o.shipAddress2);
      set('ship-m-postal', o.shipPostal);
      set('ship-m-city', o.shipCity);
      set('ship-m-country', (o.shipCountry || 'DE').toUpperCase());
      set('ship-m-phone', o.shipPhone);
      set('ship-m-weight', o.shipWeightG || 1000);
      shipLoadMethods();
    })
    .catch(() => shipLoadMethods());
  m.style.display = 'flex';
}
function shipLoadMethods() {
  const sel = $('ship-m-method');
  if (!sel) return;
  const country = ($('ship-m-country').value || 'DE').toUpperCase();
  const weight = parseInt($('ship-m-weight').value, 10) || 1000;
  sel.innerHTML = `<option value="">${esc(t('versand.loadingMethods'))}</option>`;
  apiGet('/api/ship/methods?country=' + encodeURIComponent(country) + '&weight=' + weight)
    .then((d) => {
      const methods = (d && d.methods) || [];
      sel.innerHTML = methods.length
        ? methods.map((m) => `<option value="${m.id}">${esc(m.name)}</option>`).join('')
        : `<option value="">${esc(t('versand.noMethods'))}</option>`;
    })
    .catch(() => {
      sel.innerHTML = `<option value="">${esc(t('common.error'))}</option>`;
    });
}
function shipBuyLabel() {
  const orderId = parseInt($('ship-m-orderid').value, 10);
  const methodId = $('ship-m-method').value;
  if (!orderId) return;
  if (!methodId) {
    setFb('err', t('versand.pickMethod'));
    return;
  }
  const v = (id) => ($(id) ? $(id).value.trim() : '');
  const weightG = parseInt($('ship-m-weight').value, 10) || 1000;
  const address = {
    shipName: v('ship-m-name'),
    shipCompany: v('ship-m-company'),
    shipStreet: v('ship-m-street'),
    shipHouse: v('ship-m-house'),
    shipAddress2: v('ship-m-address2'),
    shipPostal: v('ship-m-postal'),
    shipCity: v('ship-m-city'),
    shipCountry: (v('ship-m-country') || 'DE').toUpperCase(),
    shipPhone: v('ship-m-phone'),
    shipWeightG: weightG
  };
  const res = $('ship-m-result');
  if (res) res.textContent = t('common.loading');
  apiPost('/api/ship/label', { orderId, methodId, weightG, address })
    .then((r) => {
      if (!r || r.error) {
        if (res) res.textContent = '⚠ ' + ((r && r.error) || t('common.error'));
        return;
      }
      if (r.test) {
        // Test mode announced the parcel without buying a billable label.
        if (res) res.textContent = '✓ ' + t('versand.testAnnounced');
        setFb('ok', t('versand.testAnnounced'));
        renderOrdersVersand();
        return;
      }
      const pdf = '/api/ship/label/' + r.id + '/pdf';
      const pushMsg = r.channelPushed
        ? '<br>↪ ' + esc(t('versand.pushedToChannel'))
        : r.pushError
          ? '<br>⚠ ' + esc(t('versand.pushFailed')) + ': ' + esc(r.pushError)
          : '';
      if (res)
        res.innerHTML =
          '✓ ' +
          esc(t('versand.bought')) +
          (r.trackingNumber ? ' · ' + esc(r.trackingNumber) : '') +
          ` · <a href="${pdf}" target="_blank" rel="noopener">${esc(t('versand.openLabel'))}</a>` +
          pushMsg;
      setFb('ok', t('versand.bought'));
      renderOrdersVersand();
    })
    .catch(() => {
      if (res) res.textContent = '⚠ ' + t('common.error');
    });
}

function ordersActionHandler(e) {
  const btn = e.target.closest('[data-action]');
  if (!btn) return;
  const action = btn.dataset.action;
  if (action === 'oh-erase-customer') {
    eraseCustomer(btn.dataset.id, btn.dataset.name);
  } else if (action === 'ob-mark') {
    obMark(btn.dataset.id, btn.dataset.kind, btn.dataset.on === '1');
  } else if (action === 'ob-sync-now') {
    obSyncNow(btn);
  } else if (action === 'oh-filter') {
    setOrdersFilter(btn.dataset.filter || '');
  } else if (action === 'oh-goto-mapping') {
    openStab('orders', 'mapping');
  } else if (action === 'oh-manual-toggle') {
    const p = $('orders-manual-panel');
    if (p) p.style.display = p.style.display === 'none' ? 'block' : 'none';
  } else if (action === 'oh-import-csv') {
    const f = $('orders-csv-file');
    if (f) f.click();
  } else if (action === 'oh-manual-submit') {
    _ordersManualSubmit();
  } else if (action === 'oh-map-fixed') {
    const channel = ($('oh-fixmap-channel') || {}).value;
    const sku = (($('oh-fixmap-sku') || {}).value || '').trim();
    const productId = parseInt(($('oh-fixmap-product') || {}).value || '', 10);
    if (!sku || !productId) {
      setFb('err', t('orders.fixmapNeeds'));
      return;
    }
    apiPost('/api/products/map', { channel, channelSku: sku, productId }).then((r) => {
      if (r && r.error) {
        setFb('err', r.error);
        return;
      }
      $('oh-fixmap-sku').value = '';
      setFb('ok', t('orders.mapped'));
      renderOrdersMapping();
    });
  } else if (action === 'oh-map') {
    const row = btn.closest('.oh-maprow');
    const sel = row && row.querySelector('.oh-mapsel');
    if (!sel || !sel.value) {
      setFb('err', t('orders.choose'));
      return;
    }
    apiPost('/api/products/map', {
      channel: sel.dataset.channel,
      channelSku: sel.dataset.sku || null,
      listingId: sel.dataset.listing || null,
      title: sel.dataset.title || null,
      productId: parseInt(sel.value, 10)
    }).then((r) => {
      if (r && r.error) {
        setFb('err', r.error);
        return;
      }
      setFb('ok', t('orders.mapped'));
      renderOrdersMapping();
    });
  } else if (action === 'oh-prod-new') {
    _ohProductForm(null);
  } else if (action === 'oh-prod-cancel') {
    const f = $('orders-prod-form');
    if (f) f.style.display = 'none';
  } else if (action === 'oh-prod-edit') {
    apiGet('/api/products/' + btn.dataset.id)
      .then((p) => _ohProductForm(p))
      .catch(() => setFb('err', t('common.error')));
  } else if (action === 'oh-prod-save') {
    _ohProductSave();
  } else if (action === 'oh-prod-delete') {
    _ohProductDelete();
  } else if (action === 'oh-inv-set') {
    const row = btn.closest('tr');
    const inp = row && row.querySelector('.oh-inv-input');
    if (!inp) return;
    const val = parseFloat(inp.value);
    if (!(val >= 0)) {
      setFb('err', t('orders.invBadValue'));
      return;
    }
    invSetAbsolute(btn.dataset.mat, val, 'manual', 'order-hub').then((r) => {
      if (r && r.error) {
        setFb('err', r.error);
        return;
      }
      setFb('ok', t('orders.invSaved'));
      if (inventory && inventory.stock) inventory.stock[btn.dataset.mat] = val;
      renderInventoryCard();
    });
  } else if (action === 'oh-ship-open') {
    shipModalOpen(parseInt(btn.dataset.orderId, 10));
  } else if (action === 'oh-ship-buy') {
    shipBuyLabel();
  } else if (action === 'oh-ship-close') {
    shipModalClose();
  }
}

function _ordersManualSubmit() {
  const oid = ($('oh-m-oid').value || '').trim();
  if (!oid) {
    setFb('err', t('orders.needOrderId'));
    return;
  }
  const order = {
    channel: $('oh-m-channel').value || 'manual',
    channelOrderId: oid,
    customerName: ($('oh-m-cust').value || '').trim() || null,
    customerEmail: ($('oh-m-email').value || '').trim() || null,
    shipBy: $('oh-m-shipby').value || null,
    items: [
      {
        channelSku: ($('oh-m-sku').value || '').trim() || null,
        title: ($('oh-m-title').value || '').trim() || null,
        qty: parseInt($('oh-m-qty').value, 10) || 1
      }
    ]
  };
  apiPost('/api/orders/import', order).then((r) => {
    if (r && r.error) {
      setFb('err', r.error);
      return;
    }
    setFb('ok', t('orders.added'));
    ['oh-m-oid', 'oh-m-cust', 'oh-m-email', 'oh-m-sku', 'oh-m-title', 'oh-m-shipby'].forEach((id) => {
      const el = $(id);
      if (el) el.value = '';
    });
    const q = $('oh-m-qty');
    if (q) q.value = '1';
    renderOrders();
  });
}

function _ordersImportCsv(file) {
  const reader = new FileReader();
  reader.onload = () => {
    try {
      const lines = String(reader.result || '')
        .split(/\r?\n/)
        .filter((l) => l.trim());
      if (lines.length < 2) {
        setFb('err', t('orders.csvEmpty'));
        return;
      }
      const splitRow = (l) => l.split(/[,;]/).map((c) => c.trim().replace(/^"|"$/g, ''));
      const head = splitRow(lines[0]).map((h) => h.toLowerCase());
      const col = (names) => {
        for (const n of names) {
          const i = head.indexOf(n);
          if (i >= 0) return i;
        }
        return -1;
      };
      const ci = {
        channel: col(['channel', 'kanal']),
        oid: col(['order_id', 'order id', 'channel_order_id', 'bestellnummer', 'order']),
        name: col(['customer_name', 'customer', 'name', 'kunde']),
        email: col(['customer_email', 'email', 'e-mail']),
        shipby: col(['ship_by', 'ship by', 'liefern bis', 'deliver_by']),
        sku: col(['sku', 'channel_sku', 'artikelnummer']),
        title: col(['title', 'item', 'artikel', 'produkt']),
        qty: col(['qty', 'quantity', 'menge', 'anzahl'])
      };
      if (ci.oid < 0) {
        setFb('err', t('orders.csvNoOrderId'));
        return;
      }
      const byId = new Map();
      for (let i = 1; i < lines.length; i++) {
        const cells = splitRow(lines[i]);
        const oid = cells[ci.oid];
        if (!oid) continue;
        const channel = (ci.channel >= 0 && cells[ci.channel]) || 'manual';
        const key = channel + '|' + oid;
        if (!byId.has(key)) {
          byId.set(key, {
            channel,
            channelOrderId: oid,
            customerName: ci.name >= 0 ? cells[ci.name] || null : null,
            customerEmail: ci.email >= 0 ? cells[ci.email] || null : null,
            shipBy: ci.shipby >= 0 ? cells[ci.shipby] || null : null,
            items: []
          });
        }
        byId.get(key).items.push({
          channelSku: ci.sku >= 0 ? cells[ci.sku] || null : null,
          title: ci.title >= 0 ? cells[ci.title] || null : null,
          qty: ci.qty >= 0 ? parseInt(cells[ci.qty], 10) || 1 : 1
        });
      }
      const orders = [...byId.values()];
      if (!orders.length) {
        setFb('err', t('orders.csvEmpty'));
        return;
      }
      apiPost('/api/orders/import', { orders }).then((r) => {
        if (r && r.error) {
          setFb('err', r.error);
          return;
        }
        setFb('ok', t('orders.imported', { n: r.imported != null ? r.imported : orders.length }));
        renderOrders();
      });
    } catch (err) {
      setFb('err', t('common.error'));
    }
  };
  reader.readAsText(file);
}

// ── Product catalog editor ──
const _OH_MAT_KEY = {
  grain: 'inv.grain',
  hardwood: 'inv.hardwood',
  wheatbran: 'inv.wheatBran',
  gypsum: 'inv.gypsum',
  coir: 'inv.coir',
  corn: 'inv.corn',
  calcium: 'inv.calcium'
};
function _ohMatName(mat) {
  return t(_OH_MAT_KEY[mat] || mat);
}
function _ohN(x) {
  return Math.round((x || 0) * 100) / 100;
}

// ── Hydration math: the ONE client-side source of truth ──────────────────────
// "Wet" substrate/grain weights include soak water; raw-material need is the dry
// matter left after removing rh% water. The SERVER (db.js computeBatchMaterialDeltas
// / computeProductMaterialNeed) is authoritative for inventory — these mirror it,
// so if the formula ever changes, KEEP db.js AND this in sync.
function mtGrainFactor(rhPct) {
  const rh = Number(rhPct) || 0;
  return rh > 0 ? 1 - rh / 100 : 1;
}
function mtDryKg(wetKg, rhPct) {
  return (Number(wetKg) || 0) * mtGrainFactor(rhPct);
}

// Per-unit raw-material need (dry kg) — mirrors db.computeProductMaterialNeed
// so the editor preview matches the demand board exactly.
function _ohProdNeedCompute(spec) {
  const need = { grain: 0, hardwood: 0, wheatbran: 0, gypsum: 0, coir: 0 };
  const num = (v) => (isFinite(+v) ? +v : 0);
  const type = spec.prodType || 'buy';
  if (type === 'block' || type === 'allinone') {
    const bagKg = num(spec.prodBagKg);
    const rh = num(spec.prodRhPct);
    const dry = mtDryKg(bagKg, rh);
    if ((spec.prodSubstrate || 'holzkleie') === 'cvg') {
      need.coir += dry * ((num(spec.prodCoirPct) || 100) / 100);
    } else {
      need.hardwood += dry * (num(spec.prodHardwoodPct) / 100);
      need.wheatbran += dry * (num(spec.prodWheatbranPct) / 100);
    }
    if (spec.prodGypsum) need.gypsum += dry * 0.01;
  }
  if (type === 'grain' || type === 'allinone') {
    const gKg = num(spec.prodGrainKg);
    const gRh = spec.prodGrainRhPct != null && spec.prodGrainRhPct !== '' ? num(spec.prodGrainRhPct) : 52;
    need.grain += mtDryKg(gKg, gRh);
  }
  return need;
}

function _ohReadProdForm() {
  return {
    prodType: $('oh-p-prodtype').value || 'buy',
    prodSubstrate: $('oh-p-substrate').value || 'holzkleie',
    prodBagKg: $('oh-p-bagkg').value,
    prodRhPct: $('oh-p-rh').value,
    prodHardwoodPct: $('oh-p-hw').value,
    prodWheatbranPct: $('oh-p-wb').value,
    prodCoirPct: $('oh-p-coir').value,
    prodGypsum: $('oh-p-gyp').checked ? 1 : 0,
    prodGrainKg: $('oh-p-grainkg').value,
    prodGrainRhPct: $('oh-p-grainrh').value
  };
}

function _ohProdTypeChange() {
  const type = $('oh-p-prodtype').value || 'buy';
  const sub = $('oh-p-substrate').value || 'holzkleie';
  const set = (id, disp) => {
    const el = $(id);
    if (el) el.style.display = disp;
  };
  set('oh-p-subgroup', type === 'block' || type === 'allinone' ? 'block' : 'none');
  set('oh-p-graingroup', type === 'grain' || type === 'allinone' ? 'block' : 'none');
  set('oh-p-holzgroup', sub === 'holzkleie' ? 'grid' : 'none');
  set('oh-p-coirgroup', sub === 'cvg' ? 'grid' : 'none');
  _ohProdNeed();
}

function _ohProdNeed() {
  const el = $('oh-p-need');
  if (!el) return;
  const need = _ohProdNeedCompute(_ohReadProdForm());
  const parts = Object.keys(need)
    .filter((m) => need[m] > 0)
    .map((m) => `${esc(_ohMatName(m))} ${_ohN(need[m])} kg`);
  el.textContent = parts.length ? t('orders.p.needPrefix') + ' ' + parts.join(' · ') : '';
}

function _ohProductForm(p) {
  const f = $('orders-prod-form');
  if (!f) return;
  f.style.display = 'block';
  $('oh-p-id').value = p && p.id ? p.id : '';
  $('oh-p-name').value = p ? p.name || '' : '';
  $('oh-p-sku').value = p ? p.sku || '' : '';
  $('oh-p-category').value = p ? p.category || 'all-in-one' : 'all-in-one';
  $('oh-p-stock').value = p && p.stock != null ? p.stock : 0;
  $('oh-p-lead').value = p && p.leadDays != null ? p.leadDays : 0;
  $('oh-p-prodtype').value = (p && p.prodType) || 'buy';
  $('oh-p-substrate').value = (p && p.prodSubstrate) || 'holzkleie';
  $('oh-p-bagkg').value = p && p.prodBagKg != null ? p.prodBagKg : 0;
  $('oh-p-rh').value = p && p.prodRhPct != null ? p.prodRhPct : 0;
  $('oh-p-hw').value = p && p.prodHardwoodPct != null ? p.prodHardwoodPct : 0;
  $('oh-p-wb').value = p && p.prodWheatbranPct != null ? p.prodWheatbranPct : 0;
  $('oh-p-coir').value = p && p.prodCoirPct != null ? p.prodCoirPct : 100;
  $('oh-p-gyp').checked = !!(p && p.prodGypsum);
  $('oh-p-grainkg').value = p && p.prodGrainKg != null ? p.prodGrainKg : 0;
  $('oh-p-grainrh').value = p && p.prodGrainRhPct != null ? p.prodGrainRhPct : 52;
  $('oh-p-prodtype').onchange = _ohProdTypeChange;
  $('oh-p-substrate').onchange = _ohProdTypeChange;
  f.oninput = _ohProdNeed;
  _ohProdTypeChange();
  const del = $('oh-p-delete');
  if (del) del.style.display = p && p.id ? 'inline-flex' : 'none';
}

function _ohProductSave() {
  const name = ($('oh-p-name').value || '').trim();
  if (!name) {
    setFb('err', t('orders.needName'));
    return;
  }
  const spec = _ohReadProdForm();
  const id = $('oh-p-id').value;
  const payload = {
    name,
    sku: ($('oh-p-sku').value || '').trim() || null,
    category: $('oh-p-category').value || null,
    stock: parseFloat($('oh-p-stock').value) || 0,
    leadDays: parseInt($('oh-p-lead').value, 10) || 0,
    prodType: spec.prodType,
    prodSubstrate: spec.prodSubstrate,
    prodBagKg: parseFloat(spec.prodBagKg) || 0,
    prodRhPct: parseFloat(spec.prodRhPct) || 0,
    prodHardwoodPct: parseFloat(spec.prodHardwoodPct) || 0,
    prodWheatbranPct: parseFloat(spec.prodWheatbranPct) || 0,
    prodCoirPct: parseFloat(spec.prodCoirPct) || 0,
    prodGypsum: spec.prodGypsum,
    prodGrainKg: parseFloat(spec.prodGrainKg) || 0,
    prodGrainRhPct: parseFloat(spec.prodGrainRhPct) || 52
  };
  const req = id ? apiPatch('/api/products/' + id, payload) : apiPost('/api/products', payload);
  req.then((r) => {
    if (r && r.error) {
      setFb('err', r.error);
      return;
    }
    setFb('ok', t('orders.productSaved'));
    const f = $('orders-prod-form');
    if (f) f.style.display = 'none';
    renderOrdersMapping();
  });
}

// ── Raw-material inventory (shared ledger) ──
function renderInventoryCard() {
  const body = $('orders-rawstock-body');
  if (!body) return;
  const stock = (inventory && inventory.stock) || {};
  const thr = (inventory && inventory.thresholds) || {};
  const mats = ['grain', 'hardwood', 'wheatbran', 'gypsum', 'coir', 'corn', 'calcium'];
  body.innerHTML = mats
    .map((m) => {
      const have = stock[m] || 0;
      const min = (thr[m] && thr[m].minKg) || 0;
      const low = min > 0 && have <= min;
      return (
        `<tr><td data-mlabel="${esc(t('orders.mat.name'))}"><strong>${esc(_ohMatName(m))}</strong></td>` +
        `<td data-mlabel="${esc(t('orders.rawStockKg'))}">${_ohN(have)}${low ? ' <span class="oh-warn">⚠︎</span>' : ''}</td>` +
        `<td class="oh-actions" style="white-space:nowrap"><input class="oh-inv-input" data-mat="${m}" type="number" min="0" step="0.1" value="${_ohN(have)}" style="width:80px" /> ` +
        `<button class="btn btn-sm" data-action="oh-inv-set" data-mat="${m}">${esc(t('orders.set'))}</button></td></tr>`
      );
    })
    .join('');
}
function _ohProductDelete() {
  const id = $('oh-p-id').value;
  if (!id) return;
  confirm2(t('common.delete'), t('orders.confirmDelete'), t('common.delete'), () => {
    apiDelete('/api/products/' + id).then((r) => {
      if (r && r.error) {
        setFb('err', r.error);
        return;
      }
      setFb('ok', t('orders.productDeleted'));
      const f = $('orders-prod-form');
      if (f) f.style.display = 'none';
      renderOrdersMapping();
    });
  });
}

// ─── MODALS ──────────────────────────────────────────────────
function confirm2(title, body, label, cb, cancelCb) {
  document.getElementById('m-title').textContent = title;
  document.getElementById('m-body').textContent = body;
  document.getElementById('m-ok').textContent = label || t('common.confirm');
  confirmCb = cb;
  confirmCancelCb = cancelCb || null;
  document.getElementById('m-confirm').classList.add('open');
}
function closeConfirm() {
  document.getElementById('m-confirm').classList.remove('open');
  // Read and clear before calling: a cancel handler that opens another dialog
  // would otherwise find this one's callbacks still in place.
  const cancel = confirmCancelCb;
  confirmCb = null;
  confirmCancelCb = null;
  if (cancel) cancel();
}
document.getElementById('m-ok').onclick = () => {
  const cb = confirmCb;
  confirmCb = null;
  // Cleared first, or closing after a Yes would report a No as well.
  confirmCancelCb = null;
  document.getElementById('m-confirm').classList.remove('open');
  if (cb) cb();
};

/**
 * Say which field is wrong by marking it, not only by describing it.
 *
 * The bar fixed three of the four things wrong with alert() and left the fourth:
 * a message that names a problem without pointing at it. "Menge eingeben" over a
 * form with nine inputs is a puzzle, and the answer is obvious only to whoever
 * wrote the check.
 *
 * So the field gets the red ring, the focus and the scroll, and the bar still
 * carries the words. The words are deliberately *not* moved under the input: the
 * twenty-eight fields this is called for sit in grid cells, plain blocks and
 * non-wrapping flex rows, and a block inserted after the input lands differently
 * in each. Marking and focusing is what answers "which one", and it does so the
 * same way everywhere.
 *
 * The ring clears on the next keystroke in that field, because a ring that
 * outlives the mistake is a field that looks broken while it is being fixed.
 *
 * @param {string|Element} el the input, or its id
 * @param {string} msg what is wrong with it
 */
function fieldError(el, msg) {
  const input = typeof el === 'string' ? document.getElementById(el) : el;
  // No element is not a reason to say nothing: a renamed id falls back to what
  // every one of these call sites did before, rather than failing silently.
  if (!input) return toast(msg, 'err');
  toast(msg, 'err');
  input.classList.add('is-invalid');
  input.setAttribute('aria-invalid', 'true');
  const clear = () => {
    input.classList.remove('is-invalid');
    input.removeAttribute('aria-invalid');
  };
  input.addEventListener('input', clear, { once: true });
  input.addEventListener('change', clear, { once: true });
  // A field two screens down is not pointed at by being focused alone; and
  // focus() on a hidden or disabled input is a no-op, which is why the ring is
  // set first and does not depend on it.
  try {
    input.scrollIntoView({ block: 'center', behavior: 'smooth' });
    input.focus({ preventScroll: true });
  } catch (e) {
    /* an input the browser will not focus is still marked */
  }
}

/**
 * The dialog confirm2() opens, as a promise, so a caller can keep the shape it
 * had with window.confirm(): `if (!(await askConfirm(…))) return;`.
 *
 * Eight irreversible actions in this app asked through window.confirm(), and the
 * button that agrees to one is labelled **OK**. "Kamera löschen?" answered with
 * OK is a worse button than one that says Löschen: the label is the last thing
 * read before the thing happens, and OK does not say what it does. confirm2()
 * has taken a label since it was written — these eight had simply never been
 * moved onto it.
 *
 * It settles both ways. Cancel, the backdrop and closeConfirm() all resolve
 * false, because an await that never settles leaves the rest of the caller
 * suspended for the life of the page.
 *
 * The ninth confirm() stays native: mayLeavePage() runs from `beforeunload`,
 * which is synchronous by specification, and a promise there resolves after the
 * tab is already gone.
 *
 * @param {string} title  the action, short — it is also the button's label
 * @param {string} body   what happens if they say yes
 * @param {string} label  the button. Never "OK".
 * @returns {Promise<boolean>}
 */
function askConfirm(title, body, label) {
  return new Promise((resolve) => {
    confirm2(
      title,
      body,
      label,
      () => resolve(true),
      () => resolve(false)
    );
  });
}
document.getElementById('si-close').onclick = closeSubstrateInfo;
document.getElementById('m-subinfo').addEventListener('click', (e) => {
  if (e.target.id === 'm-subinfo') closeSubstrateInfo();
});
document.getElementById('m-confirm').addEventListener('click', (e) => {
  if (e.target.id === 'm-confirm') closeConfirm();
});

let confirmCancelCb = null;
let confirm3CbA = null;
let confirm3CbB = null;
function confirm3(title, body, labelA, labelB, cbA, cbB) {
  document.getElementById('m-title3').textContent = title;
  document.getElementById('m-body3').textContent = body;
  document.getElementById('m-ok3a').textContent = labelA;
  document.getElementById('m-ok3b').textContent = labelB;
  confirm3CbA = cbA;
  confirm3CbB = cbB;
  document.getElementById('m-confirm3').classList.add('open');
}
function closeConfirm3() {
  document.getElementById('m-confirm3').classList.remove('open');
  confirm3CbA = null;
  confirm3CbB = null;
}
document.getElementById('m-ok3a').onclick = () => {
  const cb = confirm3CbA;
  closeConfirm3();
  if (cb) cb();
};
document.getElementById('m-ok3b').onclick = () => {
  const cb = confirm3CbB;
  closeConfirm3();
  if (cb) cb();
};
document.getElementById('m-cancel3').onclick = closeConfirm3;
document.getElementById('m-confirm3').addEventListener('click', (e) => {
  if (e.target.id === 'm-confirm3') closeConfirm3();
});
let promptCb = null;
// opts.inputmode gets set on every open, never only when asked for: a phone that
// was handed a decimal keypad once would keep it at the next prompt that wants
// letters, and the leak would be invisible on a desktop. opts.value pre-fills,
// which is what correcting a number wants — retyping it to change one digit is
// how the wrong digit survives.
function prompt2(title, placeholder, cb, opts) {
  const o = opts || {};
  document.getElementById('m-pr-title').textContent = title;
  const inp = document.getElementById('m-pr-input');
  inp.value = o.value == null ? '' : String(o.value);
  inp.setAttribute('inputmode', o.inputmode || 'text');
  inp.placeholder = placeholder || '';
  promptCb = cb;
  document.getElementById('m-prompt').classList.add('open');
  setTimeout(() => inp.focus(), 80);
}
function closePrompt() {
  document.getElementById('m-prompt').classList.remove('open');
  promptCb = null;
}
document.getElementById('m-pr-ok').onclick = () => {
  if (promptCb) promptCb(document.getElementById('m-pr-input').value.trim());
  closePrompt();
};
document.getElementById('m-pr-cancel').onclick = closePrompt;
document.getElementById('m-prompt').addEventListener('click', (e) => {
  if (e.target.id === 'm-prompt') closePrompt();
});
// Derselbe Weg hinaus wie beim Vorgaenger: ein Klick neben das Fenster.
document.getElementById('m-dayqty').addEventListener('click', (e) => {
  if (e.target.id === 'm-dayqty') closeDayQty();
});
document.getElementById('m-pr-input').addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    e.preventDefault();
    document.getElementById('m-pr-ok').click();
  }
});
function openNote(id) {
  const b = batches.find((x) => x.batchId === id);
  if (!b) return;
  noteId = id;
  document.getElementById('m-note-title').textContent = t('note.prefix') + id;
  document.getElementById('m-note-text').value = b.notes || '';
  document.getElementById('m-note').classList.add('open');
  setTimeout(() => document.getElementById('m-note-text').focus(), 80);
}
function closeNote() {
  document.getElementById('m-note').classList.remove('open');
  noteId = null;
}
function saveNote() {
  const b = batches.find((x) => x.batchId === noteId);
  if (b) {
    b.notes = document.getElementById('m-note-text').value.trim();
    apiPatch('/api/batches/' + encodeURIComponent(noteId), { notes: b.notes });
    renderBatches();
  }
  closeNote();
}
document.getElementById('m-note').addEventListener('click', (e) => {
  if (e.target.id === 'm-note') closeNote();
});

// Batch-add modal
function openBatchAdd() {
  const bs = document.getElementById('ba-batch');
  bs.innerHTML =
    '<option value="">— choose batch —</option>' +
    batches
      .map((b) => {
        const kz = b.strainKuerzel || b.strain || '';
        const name = b.strainName || b.species || '';
        const st = (b.strainText || '').trim();
        const label = (kz ? '[' + esc(kz) + '] ' : '') + esc(b.batchId) + ' — ' + esc(name) + (st ? ' ' + esc(st) : '');
        return `<option value="${esc(b.batchId)}">${label}</option>`;
      })
      .join('');
  const ls = document.getElementById('ba-loc');
  ls.innerHTML = [...ZONES, ...ALL_RACKS].map((l) => `<option value="${esc(l)}">${esc(l)}</option>`).join('');
  bs.onchange = baPreview;
  ls.onchange = baPreview;
  document.getElementById('m-batchadd').classList.add('open');
}
function closeBatchAdd() {
  document.getElementById('m-batchadd').classList.remove('open');
}
function baPreview() {
  const id = document.getElementById('ba-batch').value,
    loc = document.getElementById('ba-loc').value,
    b = batches.find((x) => x.batchId === id);
  document.getElementById('ba-prev').textContent = b ? `Will log ${b.bags.length} bags → ${loc}` : '';
}
document.getElementById('m-batchadd').addEventListener('click', (e) => {
  if (e.target.id === 'm-batchadd') closeBatchAdd();
});
function confirmBatchAdd() {
  const id = document.getElementById('ba-batch').value,
    loc = document.getElementById('ba-loc').value,
    batch = batches.find((x) => x.batchId === id);
  if (!id || !batch) {
    fieldError('ba-batch', t('batchadd.selectBatch'));
    return;
  }
  if (!loc) {
    fieldError('ba-loc', t('batchadd.selectLoc'));
    return;
  }
  const now = new Date().toISOString();
  const entries = [];
  batch.bags.forEach((bagId) => {
    const tempId = 's' + ++_scanTempIdCounter;
    const entry = {
      time: now,
      action: 'ADD',
      batch: id,
      bag: bagId,
      from: null,
      to: loc,
      species: batch.species,
      strain: batch.strain,
      user: currentUser?.username || null,
      client_uuid: newScanUuid(),
      _tempId: tempId
    };
    scanLog.push(entry);
    movements.push(entry);
    if (!sessionStartTime) sessionStartTime = Date.now();
    sessionEntries.push(entry);
    scan.count++;
    entries.push(entry);
  });
  apiPost('/api/scan-log', { entries }).then(function (r) {
    if (r && r.ids)
      entries.forEach((e, i) => {
        setEntryServerId(e, r.ids[i]);
      });
  });
  updateSD();
  setFb('ok', `Batch ADD: ${batch.bags.length} bags → ${loc}`);
  closeBatchAdd();
}

// ─── HELPERS ─────────────────────────────────────────────────
const abbrev = (s) => {
  if (!s) return 'BAGX';
  const ms = mushroomStrains.find((x) => x.name.toLowerCase() === s.toLowerCase());
  if (ms && ms.kuerzel) return ms.kuerzel;
  return s.replace(/\s+/g, '').slice(0, 4).toUpperCase().padEnd(4, 'X');
};
// TTMMJJ. Ohne Angabe heute; mit einem Tag der, an dem angesetzt wurde — bei der
// Inventur trägt die Kennung das Datum, das auch auf dem Glas steht.
const todayStr = (d = new Date()) => {
  return (
    String(d.getDate()).padStart(2, '0') + String(d.getMonth() + 1).padStart(2, '0') + String(d.getFullYear()).slice(2)
  );
};
// Die nächste freie Nummer, nicht die Anzahl plus eins: an einem Tag, von dem
// eine Charge gelöscht wurde, gäbe die Anzahl eine Kennung, die es schon gibt,
// und der Server wiese die neue Charge ab. Mit angesetzten Tagen aus der
// Vergangenheit (Inventur) trifft man solche Tage öfter.
const genBatchId = (sp, d) => {
  const ab = abbrev(sp),
    dt = todayStr(d),
    prefix = ab + '-' + dt + '-',
    n = batches.reduce((mx, b) => {
      const k = b.batchId.startsWith(prefix) ? parseInt(b.batchId.slice(prefix.length), 10) : 0;
      return Number.isFinite(k) && k > mx ? k : mx;
    }, 0);
  return prefix + String(n + 1).padStart(2, '0');
};
// ─── STATUS CALC ─────────────────────────────────────────────
// I-10/I-14/I-21: derive zone counts from the *last* event per bag instead
// of incrementally counting deltas. This naturally handles out-of-order
// scan replays (e.g. an offline REMOVE that arrives after a fresh ADD): the
// latest scan-log entry by id always wins, so a stale event can't push a
// bag's tracked count below zero or above one.
//
// P-05: getStatus(id) used to walk the entire scanLog per call, and was
// invoked from inside the renderBatches fingerprint loop AND the body
// `.map`. With N batches × M scans that was N × M comparisons per
// render. We now build a per-batch lookup map once per applyData() and
// invalidate it whenever scanLog mutates (set _statusByBatch = null).
let _statusByBatch = null;
function _emptyStatus() {
  const c = {};
  if (Array.isArray(ZONES)) ZONES.forEach((z) => (c[z] = 0));
  return { c, total: 0, status: 'EMPTY', action: '' };
}
// Which growth stage a bag of `batchType` is in while it sits in a zone of
// `role`. The room alone cannot answer this: grain spawn can sit in an
// incubation room while it waits, and finished blocks can be parked in a
// spawn room — so keying off the room called grain "incubating"
// and called blocks a "spawn run". A fruiting or contamination room still wins
// outright (that genuinely IS a stage change); anywhere else the batch decides.
function stageOf(role, batchType) {
  if (role === 'fruiting' || role === 'contaminated') return role;
  return batchType === 'grain' ? 'spawn' : 'incubation';
}
// A bag in a contamination zone is nobody's stock: no count of a batch's or a
// Sorte's bags may include it. Only the contamination zone itself shows it.
function isContamLoc(loc) {
  const z = loc ? ZONE_BY_ID[toZone(loc)] : null;
  return !!(z && z.role === 'contaminated');
}
function _statusFromCounts(c, hasScans, batchType) {
  let total = 0;
  for (const k in c) total += c[k];
  const byRole = {};
  for (const z of zones) {
    const st = stageOf(z.role, batchType);
    if (!byRole[st]) byRole[st] = 0;
    byRole[st] += c[z.id] || 0;
  }
  let status = 'EMPTY',
    action = '';
  if (byRole.fruiting > 0) {
    status = 'FRUITING';
    action = t('status.action.harvest');
  } else if (byRole.incubation > 0) {
    status = 'INCUBATING';
    action = t('status.action.moveTent');
  } else if (byRole.spawn > 0) {
    status = 'SPAWN RUN';
    action = t('status.action.monitorSpawn');
  } else if (byRole.contaminated > 0) {
    status = 'CONTAM';
    action = t('status.action.discard');
  } else if (total === 0 && hasScans) {
    // Scan events exist for this batch but no remaining bags — DONE
    status = 'DONE';
  }
  return { c, total, status, action };
}
function _buildStatusByBatch() {
  // For each batch, find the last scan-log event per bag. Pre-bucket by
  // batch so we never iterate scanLog more than once. scanLog is ordered
  // by server id (see db.js getScanLog ORDER BY s.id), so a simple
  // forward iteration makes the highest-id entry win for each bag.
  const lastByBatchBag = new Map(); // batchId -> Map<bagKey, event>
  for (const e of scanLog) {
    if (!e.batch) continue;
    let m = lastByBatchBag.get(e.batch);
    if (!m) {
      m = new Map();
      lastByBatchBag.set(e.batch, m);
    }
    const key = e.bag || `__batch__:${e.batch}`;
    m.set(key, e);
  }
  // batch -> type, so stageOf() can tell grain from a block without a lookup
  // inside the per-zone loop.
  const typeById = new Map();
  for (const b of batches) typeById.set(b.batchId, b.batchType);
  const out = new Map();
  for (const [batchId, m] of lastByBatchBag) {
    const c = {};
    if (Array.isArray(ZONES)) ZONES.forEach((z) => (c[z] = 0));
    for (const e of m.values()) {
      if (e.action === 'REMOVE') continue;
      const tz = toZone(e.to);
      if (tz && c[tz] !== undefined) c[tz]++;
    }
    out.set(batchId, _statusFromCounts(c, true, typeById.get(batchId)));
  }
  return out;
}
function getStatus(id) {
  if (!_statusByBatch) _statusByBatch = _buildStatusByBatch();
  const cached = _statusByBatch.get(id);
  if (cached) return cached;
  // No scan events for this batch yet — return EMPTY.
  return _emptyStatus();
}
const getHarvested = (id) => harvests.filter((h) => h.batch === id).reduce((s, h) => s + (h.grams || 0), 0);

// ─── DASHBOARD ───────────────────────────────────────────────
let batchYieldInst = null,
  timelineInst = null;

// True iff the batch's due date is strictly before today (local-midnight comparison).
// Both sides are normalized to local midnight so the answer doesn't flip partway
// through the day in non-UTC timezones — b.due is stored as toISOString() (UTC),
// so comparing it against "now" would otherwise drift by the local offset.
function isBatchOverdue(b, now) {
  const today = now ? new Date(now) : new Date();
  today.setHours(0, 0, 0, 0);
  const due = new Date(b.due);
  due.setHours(0, 0, 0, 0);
  return due < today;
}

// ─── SORTEN OVERVIEW ─────────────────────────────────────────
// Replaces the pipeline KPI strip. That strip answered "how many bags are in
// each stage" and mixed its units doing it — "Chargen gesamt" and "Fertig"
// counted batches while the three stages beside them counted bags, all in one
// row. It also could not answer the question this page is actually opened
// with: which Sorte is running out, so which one needs a batch started.
//
// Both live here now. The first tile is the old total; the rest are one tile
// per Sorte, and every number on all of them is bags.

// ─── SORTE IDENTITY ──────────────────────────────────────────
// One key for a Sorte, used by the rollup, the tile filter, the grouping and
// every programme lookup.
//
// `b.species` is NOT it, and keying on it was one bug wearing seven faces:
// createBatch (and db.js insertBatch) write "Name (KÜRZEL)" while
// createGrainBatch writes "Name", so the SAME Sorte reaches this code under two
// spellings. Grain never joined its blocks, the programme flag never matched
// anything, and buildSupplyTasks() returned an empty list on real data.
//
// Batches carry strain_id and db.js resolves strainName from it, so the id is
// the key wherever it exists. _spKey is the fallback for rows that predate
// strain_id: it is the normalisation spColor has always used, and stripping the
// " (KZ)" suffix is exactly what it was written for.
function sorteKey(rec) {
  if (rec && rec.strainId) return 'id:' + rec.strainId;
  return 'n:' + _spKey((rec && (rec.strainName || rec.species || rec.name)) || '');
}
// A Sorte row may be reached by either key — id for batches that carry one,
// name for the older ones — so a lookup table has to answer to both.
function _strainKeys(ms) {
  return ['id:' + ms.id, 'n:' + _spKey(ms.name || '')];
}
// The Pilzsorten row behind a sorteKey(), for the buttons that have to open a
// form for it. Answers to both key forms, exactly as the programme map does.
function _strainByKey(key) {
  if (!key) return null;
  for (const ms of mushroomStrains || []) if (_strainKeys(ms).includes(key)) return ms;
  return null;
}
// Choose a type in the Laborarbeit quick dialog.
//
// The picker spells grain spawn KB where MIN_TYPES spells it GS; without the
// translation the membership check below silently fails and the dialog opens on
// whatever was selected last — a "Körnerbrut anlegen" button that registers a
// slant. Setting .value in script does not fire the select's onchange either,
// and that handler is what refills the parent list, so it is called directly.
function _msqPickType(type) {
  const lt = document.getElementById('ms-q-labtype');
  const want = type === 'GS' ? 'KB' : type;
  if (!lt || !want || ![...lt.options].some((o) => o.value === want)) return;
  lt.value = want;
  msQuickLabTypeChanged();
}

// What to print. The resolved strain name when there is one; otherwise the
// species with its " (KZ)" suffix stripped, so the card never shows one Sorte
// under two labels.
function sorteName(b) {
  const n = (b.strainName || '').trim();
  if (n) return n;
  const s = String(b.species || '')
    .replace(/\s*\([^)]*\)\s*$/, '')
    .trim();
  return s || t('batch.noSorte');
}

// Bags of one batch, bucketed by production stage. Same source as the status
// badge and the old strip — getStatus().c is the per-zone count derived from
// the last scan per bag — so the tiles cannot disagree with the row beneath
// them. stageOf() is what folds grain jars into 'spawn' regardless of the zone
// they sit in.
function _stageBagsOf(b) {
  const { c } = getStatus(b.batchId);
  const out = { spawn: 0, incubation: 0, fruiting: 0, contaminated: 0 };
  for (const z of zones) {
    const n = c[z.id] || 0;
    if (!n) continue;
    const st = stageOf(z.role, b.batchType);
    if (out[st] !== undefined) out[st] += n;
  }
  return out;
}

// How much of each Sorte is where, and whether anything is behind it.
//
// The chain on this farm runs grain -> block -> fruiting, and the verdict reads
// the first two steps SEPARATELY, because they are different lead times.
// Incubation is the next harvest; grain is what the harvest after that has to
// be made from. A Sorte with a full incubation and no grain has no problem
// today and a certain one in two cycles — a different thing from "running low",
// wanting a different answer on a different day.
//
//   grain 0, incubating 0  -> 'now'      behind the fruiting blocks: nothing
//   grain 0, incubating >0 -> 'nospawn'  next harvest yes, the one after no
//   incubating < fruiting  -> 'low'      the chain runs, but it is thinning
//   otherwise              -> 'ok'
//
// ── Why grain is counted apart rather than added in ──
//
// stageOf() puts a BLOCK into 'incubation' from any zone that is not fruiting
// or contaminated — blocks have no spawn-run stage in this app, only grain
// does. So "bags in spawn" is not a thing a block can be, and reading it that
// way would have made the spawn count permanently zero and the distinction
// above meaningless. Grain lives in its own batches (batchType 'grain', listed
// under Labor), and that is where the number has to come from.
//
// It is therefore in a DIFFERENT UNIT: kilograms, not blocks, and a kilo of
// grain makes several blocks. Adding it to a bag count would repeat exactly the
// mistake this card was built to fix — the old strip put batch counts and bag
// counts in one row. So grain stays out of `bags` and out of the bar's
// arithmetic; it is named in its own right, in kilograms, beside them.
//
// Everything here comes out of data the app already has. A real days-of-cover
// number would need a target per Sorte or consumption from the order side;
// neither exists, so it is not guessed at.
const SUPPLY_RANK = { now: 0, nospawn: 1, low: 2, ok: 3, off: 4 };
// Wieviel Körnerbrut dieser Charge noch dasteht, in KILOGRAMM.
//
// Eine Regel, ein Glas: es zählt, solange sein letzter Scan kein REMOVE ist und
// es nicht in einer Kontaminationszone steht. Sonst nichts.
//
// Das ersetzt zwei Sonderfälle, die beide falsch lagen. Der eine las
// getStatus().c, also Gläser mit Scan-Position — und createGrainBatch schreibt
// keine Scans, also war heute angesetzte Brut null. Der andere warf die ganze
// Charge weg, sobald sie archiviert war, und zählte sie bis dahin voll: neun von
// zehn Gläsern verbraucht, und die Kachel meldete weiter zehn. "Körner fehlen"
// konnte damit erst umschlagen, wenn das letzte Glas der letzten Charge ging.
//
// Die eine Regel deckt beides ab, ohne einen Zweig für keins von beidem:
//   kein Eintrag  — heute angesetzt, noch nirgends hingestellt → zählt
//   ADD / MOVE    — steht irgendwo → zählt
//   REMOVE        — in Blöcke gegangen oder verworfen → zählt nicht
//   in CONTAM     — da, aber nicht brauchbar → zählt nicht
//
// Ist eine Charge ganz verbraucht, ergibt die Summe von selbst 0; es braucht
// keine Archiv-Abfrage mehr — und damit auch keinen Merker dafür, ob eine
// Charge überhaupt schon gescannt wurde.
//
// Das Gewicht kommt je Glas aus bagWeights, das db.js schon auflöst (bag_kg der
// Zeile, sonst der Charge, sonst 3). `w || 1` stand hier und machte aus einem
// mit 0 gewogenen Glas eines von einem Kilo — und widersprach nebenbei dem
// Standardwert der Serverseite.
function _grainKgOf(b) {
  const platz = placementByBag();
  // Wenn niemand sagt, wie schwer ein Glas ist, gilt dasselbe Ersatzmass wie
  // beim Auslesen: db.js setzt fuer bagWeights `r.bag_kg || 3`. Hier stand 0,
  // und damit meldete eine Charge ohne Gewichtsangabe null Kilogramm ueber
  // einem vollen Regal -- genau die "Koerner fehlen"-Meldung, gegen die diese
  // Funktion zweimal umgeschrieben wurde. Zwei Wege durch dieselbe Zahl
  // muessen dasselbe Ersatzmass nehmen, sonst widersprechen sie sich.
  const ERSATZ_KG = 3;
  const glasKg = (bag) => {
    const w = b.bagWeights && b.bagWeights[bag];
    if (Number.isFinite(w)) return w;
    return Number.isFinite(b.bagKg) ? b.bagKg : ERSATZ_KG;
  };
  const glaeser = b.bags && b.bags.length ? b.bags : null;
  // Ohne Glasliste bleibt nur die Angabe der Charge selbst.
  if (!glaeser) return (b.qty || 0) * (Number.isFinite(b.bagKg) ? b.bagKg : ERSATZ_KG);
  let kg = 0;
  for (const bag of glaeser) {
    const letzter = platz.get(String(bag).toUpperCase());
    if (letzter && letzter.action === 'REMOVE') continue;
    if (letzter && letzter.to) {
      const z = ZONE_BY_ID[toZone(letzter.to)];
      if (z && z.role === 'contaminated') continue;
    }
    kg += glasKg(bag);
  }
  return kg;
}
function sorteRollup() {
  const bySorte = new Map();
  for (const b of batches) {
    const key = sorteKey(b);
    let r = bySorte.get(key);
    if (!r) {
      r = {
        key,
        name: sorteName(b),
        kuerzel: '',
        grain: 0, // kilograms of grain spawn — a different unit, kept apart
        incubation: 0,
        fruiting: 0,
        nBatches: 0, // block batches with bags still on the floor
        nAll: 0, // block batches, live and finished — what the table below lists
        lastDays: null,
        nextDue: null
      };
      bySorte.set(key, r);
    }
    if (!r.kuerzel) r.kuerzel = (b.strainKuerzel || '').trim();
    // Grain contributes one thing and one thing only: how much is ready to make
    // the next blocks from. It is not a batch on this page and not a bag in
    // these totals.
    if (b.batchType === 'grain') {
      r.grain += _grainKgOf(b);
      continue;
    }
    const st = _stageBagsOf(b);
    r.nAll++;
    // Age, not recipe length. b.days is the planned incubation duration — the
    // number the table prints as "14d" — so reading it here printed a constant
    // that never moved, under a label saying "last one N days ago".
    const age = Math.max(0, Math.round((Date.now() - new Date(b.created)) / 864e5));
    if (Number.isFinite(age) && (r.lastDays == null || age < r.lastDays)) r.lastDays = age;
    r.incubation += st.incubation;
    r.fruiting += st.fruiting;
    if (st.incubation + st.fruiting > 0) {
      r.nBatches++;
      // The next batch due to reach fruiting — only the ones not already there.
      const due = new Date(b.due);
      if (st.fruiting === 0 && (r.nextDue == null || due < r.nextDue)) r.nextDue = due;
    }
  }
  // Is the farm growing this one at the moment? Keyed by BOTH of a strain's
  // keys, because a batch reaches the rollup under its id and an older one
  // under its name. A Sorte with no row in Pilzsorten at all is treated as in
  // the programme — it is being grown, somebody just never wrote it down, and
  // going quiet about it would hide real work — but `known` records which case
  // it was, so a caller that needs the stricter reading can have it.
  const programme = new Map();
  for (const m of mushroomStrains) for (const k of _strainKeys(m)) programme.set(k, m.imProgramm !== false);
  const out = [...bySorte.values()];
  for (const r of out) {
    if (!r.kuerzel) r.kuerzel = abbrev(r.name);
    const p = programme.get(r.key);
    r.known = p !== undefined;
    r.imProgramm = p === undefined ? true : p;
    // Blocks only — the same unit as every other number on the tile.
    r.bags = r.incubation + r.fruiting;
    r.supply =
      r.grain === 0 && r.incubation === 0
        ? 'now'
        : r.grain === 0
          ? 'nospawn'
          : r.incubation < r.fruiting
            ? 'low'
            : 'ok';
    // Out of the programme, the light goes off entirely rather than turning
    // green. Green would claim the supply is fine; the truth is that nobody is
    // asking. A Sorte paused for the season has no grain and nothing incubating BY
    // DEFINITION, and a red card every day for a Sorte nobody is growing
    // teaches people to stop reading the card.
    if (!r.imProgramm) r.supply = 'off';
  }
  // A Sorte that exists only as grain has no block batches and nothing for the
  // list below; it still belongs on the card, because "there is spawn ready and
  // no blocks made from it" is precisely a thing worth seeing.
  return out;
}

function renderOverviewKPIs() {
  const weekEl = document.getElementById('ov-kpi-week');
  const substratesEl = document.getElementById('ov-kpi-substrates');
  const qualEl = document.getElementById('ov-kpi-quality');
  if (!weekEl || !qualEl) return;

  applyOvPeriod();

  const now = Date.now();
  const nowDate = new Date();

  // Period start based on selected period
  let periodStart;
  if (ovPeriod === 'week') {
    // Monday of the current week (Mon–Sun)
    periodStart = new Date(nowDate);
    periodStart.setDate(periodStart.getDate() - ((periodStart.getDay() + 6) % 7));
    periodStart.setHours(0, 0, 0, 0);
  } else if (ovPeriod === 'month') periodStart = new Date(nowDate.getFullYear(), nowDate.getMonth(), 1);
  else periodStart = new Date(nowDate.getFullYear(), 0, 1);

  // ── PRODUCTION ─────────────────────────────────────────────
  // 1. Bags created
  const bagsCreated = batches.filter((b) => new Date(b.created) >= periodStart).reduce((s, b) => s + (b.qty || 0), 0);

  // 2. Grain used (kg) — from inventory log
  const grainUsed = (inventory.log || [])
    .filter((e) => e.mat === 'grain' && e.type === 'batch' && new Date(e.time) >= periodStart)
    .reduce((s, e) => s + Math.abs(e.deltaKg || 0), 0);

  // 3. Harvest (kg)
  const periodHarvests = harvests.filter((h) => new Date(h.time) >= periodStart);
  const periodHarvestKg = periodHarvests.reduce((s, h) => s + (h.grams || 0), 0) / 1000;

  // 4. Substrate used (kg) — hardwood and wheat bran tracked separately
  let hardwoodUsed = 0,
    wheatbranUsed = 0;
  (inventory.log || []).forEach((e) => {
    if (e.type !== 'batch' || new Date(e.time) < periodStart) return;
    if (e.mat === 'hardwood') hardwoodUsed += Math.abs(e.deltaKg || 0);
    else if (e.mat === 'wheatbran') wheatbranUsed += Math.abs(e.deltaKg || 0);
  });

  // ── QUALITY & EFFICIENCY ──────────────────────────────────
  // 5. Avg yield per bag (g) — all-time + period comparison
  const uniqueHarvestedBags = new Set(harvests.map((h) => h.bag)).size;
  const avgYield =
    uniqueHarvestedBags > 0 ? Math.round(harvests.reduce((s, h) => s + (h.grams || 0), 0) / uniqueHarvestedBags) : 0;
  // Period yield: avg grams per unique bag harvested in selected period
  const periodBags = new Set(periodHarvests.map((h) => h.bag));
  const periodYield =
    periodBags.size > 0 ? Math.round(periodHarvests.reduce((s, h) => s + (h.grams || 0), 0) / periodBags.size) : 0;
  const yieldDelta = avgYield > 0 && periodYield > 0 ? periodYield - avgYield : null;
  // periodSub = label for the selected period ("This week"/"This month"/…).
  // Declared here (not further down) because yieldSub below references it —
  // a later `const` would throw a temporal-dead-zone ReferenceError and abort
  // the whole render whenever there is a harvest in the period.
  const periodSub = t('dash.ov.period' + ovPeriod.charAt(0).toUpperCase() + ovPeriod.slice(1));
  let yieldSub = t('dash.ov.perBag');
  if (periodYield > 0) {
    const arrow = yieldDelta > 0 ? '↑' : yieldDelta < 0 ? '↓' : '=';
    const color = yieldDelta > 0 ? 'var(--c-green-dark)' : yieldDelta < 0 ? 'var(--c-red-dark)' : '';
    yieldSub = periodSub + ': ' + periodYield + 'g <span style="color:' + color + '">' + arrow + '</span>';
  }

  // 6. Contamination rate (%)
  // I-13: numerator must be a subset of the denominator. addedBags is the
  // set of bags that ever had an ADD; contamBagSet only counts bags that
  // both had an ADD AND were moved to a contaminated zone. Without this
  // intersection a MOVE-only contaminated bag (e.g. an auto-MOVE from a
  // contamination report on a bag that pre-dated explicit ADD tracking)
  // would push the percentage above 100.
  const addedBags = new Set(scanLog.filter((e) => e.action === 'ADD' && e.bag).map((e) => e.bag));
  const allBagsPlaced = addedBags.size;
  const contamBagSet = new Set();
  scanLog.forEach((e) => {
    if (!e.to || !e.bag) return;
    if (!addedBags.has(e.bag)) return;
    const z = ZONE_BY_ID[toZone(e.to)];
    if (z && z.role === 'contaminated') contamBagSet.add(e.bag);
  });
  const contamRate = allBagsPlaced > 0 ? +((contamBagSet.size / allBagsPlaced) * 100).toFixed(1) : 0;
  // The -dark variants, not the mid tones. `card()` puts this colour on two
  // things: the 24px value, which sits on the white card, and the icon glyph,
  // which sits on the tinted chip beside it. The mid tones fail both — green
  // reads 2.3:1 on white against the 3.0 large text needs, amber 2.1, and as
  // glyphs on their own -light backgrounds 2.1 and 1.9 against the same 3.0 for
  // non-text. The six cards either side of these two are hand-picked darks
  // between 3.7 and 6.4, so the two quality KPIs were the washed-out pair in a
  // row of eight. Red is moved with them: at 3.8 it passes, but leaving it mid
  // while its two siblings go dark makes the worst state the palest of the
  // three. The -dark family lands 7.1-8.3 on white and 6.4-6.8 on the chips.
  const contamColor =
    contamRate === 0 ? 'var(--c-green-dark)' : contamRate <= 5 ? 'var(--c-amber-dark)' : 'var(--c-red-dark)';
  const contamBg =
    contamRate === 0 ? 'var(--c-green-light)' : contamRate <= 5 ? 'var(--c-amber-light)' : 'var(--c-red-light)';

  // 7. Days without contamination (streak)
  const contamEvents = scanLog.filter((e) => {
    if (!e.to || !e.bag) return false;
    const z = ZONE_BY_ID[toZone(e.to)];
    return z && z.role === 'contaminated';
  });
  let daysSinceContam = null,
    daysSinceLabel = '';
  if (contamEvents.length === 0) {
    daysSinceContam = batches.length > 0 ? Math.floor((now - new Date(batches[0].created)) / 864e5) : null;
    daysSinceLabel = t('dash.ov.neverContam');
  } else {
    daysSinceContam = Math.floor((now - new Date(contamEvents[contamEvents.length - 1].time)) / 864e5);
    daysSinceLabel = daysSinceContam === 1 ? t('dash.ov.dayAgo') : t('dash.ov.daysAgo', { n: daysSinceContam });
  }
  // Same three states, same card, same reason — see contamColor above.
  const streakColor =
    daysSinceContam === null
      ? 'var(--c-text-muted)'
      : daysSinceContam >= 14
        ? 'var(--c-green-dark)'
        : daysSinceContam >= 7
          ? 'var(--c-amber-dark)'
          : 'var(--c-red-dark)';
  const streakBg =
    daysSinceContam === null
      ? 'var(--c-bg)'
      : daysSinceContam >= 14
        ? 'var(--c-green-light)'
        : daysSinceContam >= 7
          ? 'var(--c-amber-light)'
          : 'var(--c-red-light)';

  // 8. Flush 2+ bags (bags that have had ≥2 harvests logged)
  const bagFlushMax = {};
  harvests.forEach((h) => {
    if (!bagFlushMax[h.bag] || h.flush > bagFlushMax[h.bag]) bagFlushMax[h.bag] = h.flush || 1;
  });
  const flush2Plus = Object.values(bagFlushMax).filter((f) => f >= 2).length;

  // ── HELPER ────────────────────────────────────────────────
  function card(icon, value, label, sub, accentColor, accentBg) {
    return `<div class="ov-kpi-card">
      <div class="ov-kpi-icon" style="color:${accentColor};background:${accentBg}">${icon}</div>
      <div class="ov-kpi-body">
        <div class="ov-kpi-value" style="color:${accentColor}">${value}</div>
        <div class="ov-kpi-label">${label}</div>
        ${sub ? `<div class="ov-kpi-sub">${sub}</div>` : ''}
      </div>
    </div>`;
  }

  const fmtKg = (v) => (v >= 10 ? Math.round(v) + 'kg' : v.toFixed(1) + 'kg');

  // Icons
  const iconBag = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>`;
  const iconGrain = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 110 20A10 10 0 0112 2z"/><path d="M12 6v12M8 8l4 4 4-4M8 16l4-4 4 4"/></svg>`;
  const iconHarvest = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>`;
  const iconSubstrate = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/></svg>`;
  const iconYield = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>`;
  const iconContam = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
  const iconStreak = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`;
  const iconFlush = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>`;

  const periodLabel = document.getElementById('ov-period-label');
  if (periodLabel) periodLabel.textContent = periodSub;

  weekEl.innerHTML = [
    card(iconBag, bagsCreated || '0', t('dash.ov.bagsCreated'), periodSub, '#2d6a4f', '#e6f2ec'),
    card(
      iconHarvest,
      periodHarvestKg > 0 ? fmtKg(periodHarvestKg) : '—',
      t('dash.ov.harvestThisWeek'),
      periodHarvests.length + ' ' + t('dash.ov.harvests'),
      '#c2703e',
      '#faf0e6'
    ),
    card(iconFlush, flush2Plus || '0', t('dash.ov.flush2Plus'), t('dash.ov.bagsOnSecondFlush'), '#3a7d7b', '#e6f2f1'),
    card(iconYield, avgYield > 0 ? avgYield + 'g' : '—', t('dash.ov.avgYield'), yieldSub, '#5a8a32', '#eef4e5')
  ].join('');

  if (substratesEl)
    substratesEl.innerHTML = [
      card(
        iconGrain,
        grainUsed > 0 ? fmtKg(grainUsed) : '—',
        t('dash.ov.grainUsed'),
        t('dash.ov.fromBatches'),
        '#6b7c3f',
        '#f0f2e6'
      ),
      card(
        iconSubstrate,
        hardwoodUsed > 0 ? fmtKg(hardwoodUsed) : '—',
        t('dash.ov.hardwoodUsed'),
        t('dash.ov.fromBatches'),
        '#8b5e3c',
        '#f5ede6'
      ),
      card(
        iconSubstrate,
        wheatbranUsed > 0 ? fmtKg(wheatbranUsed) : '—',
        t('dash.ov.wheatbranUsed'),
        t('dash.ov.fromBatches'),
        // Was #c9a227, the one card in this row that had been picked for its
        // hue rather than its weight: 2.4:1 on the card and 2.2:1 as a glyph on
        // its own chip, where its six neighbours run 3.7 to 6.4. Same gold, taken
        // down to 4.7 — which lands it between the olive at 4.6 and the teal at
        // 4.8, so the row reads as one set instead of six numbers and a faint one.
        '#8f6f16',
        '#faf5e0'
      )
    ].join('');

  qualEl.innerHTML = [
    card(
      iconContam,
      contamRate + '%',
      t('dash.ov.contamRate'),
      contamBagSet.size + ' / ' + allBagsPlaced + ' ' + t('dash.ov.bags'),
      contamColor,
      contamBg
    ),
    card(
      iconStreak,
      daysSinceContam !== null ? daysSinceContam : t('dash.ov.na'),
      t('dash.ov.daysSinceContam'),
      daysSinceLabel,
      streakColor,
      streakBg
    )
  ].join('');

  renderOverviewCharts(periodStart);
}

let ovChartHarvestInst = null,
  ovChartSpeciesInst = null,
  ovChartSubstrateInst = null,
  ovChartBagsInst = null;

function renderOverviewCharts(periodStart) {
  // P-02: Chart.js is lazy-loaded. If the idle preload hasn't fired yet,
  // skip this render and re-fire once the lib is ready. The page is fully
  // usable in the meantime — just no charts. (renderOverviewCharts is
  // already a no-op when the canvases aren't in the DOM, so callers don't
  // need their own guard.)
  if (typeof Chart === 'undefined') {
    loadVendorLibs().then(() => renderOverviewCharts(periodStart));
    return;
  }
  const nowDate = new Date();
  const periodHarvests = harvests.filter((h) => new Date(h.time) >= periodStart);
  const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

  // ── Helpers: build time buckets depending on period ──────
  function buildTimeBuckets() {
    if (ovPeriod === 'week') {
      // Full week Mon–Sun (7 days)
      const days = [];
      const cur = new Date(periodStart);
      for (let i = 0; i < 7; i++) {
        days.push(localDateStr(cur));
        cur.setDate(cur.getDate() + 1);
      }
      const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
      return { keys: days, label: (k, i) => dayNames[i] + ' ' + fmtDtShort(k), groupKey: (d) => localDateStr(d) };
    }
    if (ovPeriod === 'month') {
      // Full month 1st to last day
      const days = [];
      const cur = new Date(periodStart);
      const lastDay = new Date(nowDate.getFullYear(), nowDate.getMonth() + 1, 0);
      while (cur <= lastDay) {
        days.push(localDateStr(cur));
        cur.setDate(cur.getDate() + 1);
      }
      return { keys: days, label: (k) => fmtDtShort(k), groupKey: (d) => localDateStr(d) };
    }
    // year — monthly buckets Jan–Dec
    const mKeys = Array.from({ length: 12 }, (_, i) => `${nowDate.getFullYear()}-${String(i + 1).padStart(2, '0')}`);
    return {
      keys: mKeys,
      label: (k) => monthNames[parseInt(k.split('-')[1]) - 1],
      groupKey: (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
    };
  }
  const { keys, label, groupKey } = buildTimeBuckets();

  // ── Chart titles ─────────────────────────────────────────
  const harvestLabel = document.getElementById('ov-chart-harvest-label');
  const speciesLabel = document.getElementById('ov-chart-species-label');
  const substrateLabel = document.getElementById('ov-chart-substrate-label');
  const bagsLabel = document.getElementById('ov-chart-bags-label');
  if (ovPeriod === 'week') {
    if (harvestLabel) harvestLabel.textContent = t('dash.ov.dailyHarvest') || 'Daily harvest';
    if (substrateLabel) substrateLabel.textContent = t('dash.ov.weeklySubstrate') || 'Substrate usage';
    if (bagsLabel) bagsLabel.textContent = t('dash.ov.bagsCreated') || 'Bags created';
  } else if (ovPeriod === 'month') {
    if (harvestLabel) harvestLabel.textContent = t('dash.ov.dailyHarvest') || 'Daily harvest';
    if (substrateLabel) substrateLabel.textContent = t('dash.ov.weeklySubstrate') || 'Substrate by week';
    if (bagsLabel) bagsLabel.textContent = t('dash.ov.bagsCreated') || 'Bags created';
  } else {
    if (harvestLabel) harvestLabel.textContent = t('dash.ov.monthlyHarvest') || 'Monthly harvest';
    if (substrateLabel) substrateLabel.textContent = t('dash.ov.weeklySubstrate') || 'Substrate by month';
    if (bagsLabel) bagsLabel.textContent = t('dash.ov.monthlyBags') || 'Bags created by month';
  }
  if (speciesLabel) speciesLabel.textContent = t('dash.harvestBySpecies') || 'Harvest by species (kg)';

  // ── 1. Harvest over time ─────────────────────────────────
  const c1 = document.getElementById('ov-chart-harvest');
  if (c1) {
    const harvestMap = {};
    periodHarvests.forEach((h) => {
      const k = groupKey(new Date(h.time));
      harvestMap[k] = (harvestMap[k] || 0) + (h.grams || 0);
    });
    if (ovChartHarvestInst) {
      ovChartHarvestInst.destroy();
      ovChartHarvestInst = null;
    }
    const ctx = c1.getContext('2d');
    const grad = ctx.createLinearGradient(0, 0, 0, 180);
    grad.addColorStop(0, 'rgba(194,112,62,0.25)');
    grad.addColorStop(1, 'rgba(194,112,62,0.02)');
    const useBar = ovPeriod === 'year';
    ovChartHarvestInst = new Chart(c1, {
      type: useBar ? 'bar' : 'line',
      data: {
        labels: keys.map((k, i) => label(k, i)),
        datasets: [
          {
            data: keys.map((k) => +((harvestMap[k] || 0) / 1000).toFixed(2)),
            fill: !useBar,
            backgroundColor: useBar
              ? keys.map((k, i) =>
                  ovPeriod === 'year' && i === nowDate.getMonth() ? '#c2703e' : 'rgba(194,112,62,0.55)'
                )
              : grad,
            borderColor: '#c2703e',
            borderWidth: 2,
            pointRadius: keys.length > 20 ? 0 : 3,
            pointBackgroundColor: '#c2703e',
            borderRadius: useBar ? 6 : 0,
            tension: 0.35
          }
        ]
      },
      options: {
        responsive: true,
        plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => c.parsed.y.toFixed(2) + 'kg' } } },
        scales: {
          y: { ticks: { callback: (v) => v + 'kg', color: '#94a3b8' }, grid: { color: '#e2e8f0' }, beginAtZero: true },
          x: { ticks: { font: { size: 9 }, color: '#94a3b8', maxTicksLimit: 14 }, grid: { display: false } }
        }
      }
    });
  }

  // ── 2. Harvest by species ────────────────────────────────
  const c2 = document.getElementById('ov-chart-species');
  if (c2) {
    const bySpecies = {};
    periodHarvests.forEach((h) => {
      bySpecies[h.species] = (bySpecies[h.species] || 0) + (h.grams || 0);
    });
    const spLabels = Object.keys(bySpecies);
    const spData = spLabels.map((s) => bySpecies[s] / 1000);
    if (ovChartSpeciesInst) {
      ovChartSpeciesInst.destroy();
      ovChartSpeciesInst = null;
    }
    if (!spLabels.length) {
      const ctx = c2.getContext('2d');
      ctx.clearRect(0, 0, c2.width, c2.height);
      ctx.fillStyle = '#94a3b8';
      ctx.font = '12px system-ui';
      ctx.textAlign = 'center';
      ctx.fillText(t('harvest.noData'), c2.width / 2, 80);
    } else {
      const fmtKg = (v) => Math.round(v * 100) / 100 + 'kg';
      const ctx = c2.getContext('2d');
      const bgColors = spLabels.map((s) => {
        const g = ctx.createLinearGradient(0, 0, 0, c2.clientHeight || 180);
        g.addColorStop(0, spColor(s) + 'ee');
        g.addColorStop(1, spColor(s) + '55');
        return g;
      });
      const dataLabelPlugin = {
        id: 'harvestDataLabels',
        afterDatasetsDraw(chart) {
          const { ctx: cc, data } = chart;
          chart.getDatasetMeta(0).data.forEach((bar, i) => {
            const val = data.datasets[0].data[i];
            if (!val) return;
            cc.save();
            cc.fillStyle = '#475569';
            cc.font = 'bold 11px system-ui';
            cc.textAlign = 'center';
            cc.textBaseline = 'bottom';
            cc.fillText(fmtKg(val), bar.x, bar.y - 4);
            cc.restore();
          });
        }
      };
      ovChartSpeciesInst = new Chart(c2, {
        type: 'bar',
        plugins: [dataLabelPlugin],
        data: {
          labels: spLabels,
          datasets: [
            {
              data: spData,
              backgroundColor: bgColors,
              borderColor: spLabels.map((s) => spColor(s)),
              borderWidth: 1.5,
              borderRadius: 8,
              borderSkipped: false
            }
          ]
        },
        options: {
          responsive: true,
          layout: { padding: { top: 22 } },
          plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => fmtKg(c.parsed.y) } } },
          scales: {
            y: {
              ticks: { callback: (v) => fmtKg(v), color: '#94a3b8' },
              grid: { color: '#e2e8f0' },
              beginAtZero: true
            },
            x: { grid: { display: false }, ticks: { color: '#94a3b8' } }
          }
        }
      });
    }
  }

  // ── 3. Substrate usage ───────────────────────────────────
  const c3 = document.getElementById('ov-chart-substrate');
  if (c3) {
    const hwMap = {},
      wbMap = {},
      grMap = {};
    (inventory.log || []).forEach((e) => {
      if (e.type !== 'batch') return;
      if (e.mat !== 'hardwood' && e.mat !== 'wheatbran' && e.mat !== 'grain') return;
      const d = new Date(e.time);
      if (d < periodStart) return;
      let k;
      if (ovPeriod === 'year') {
        k = groupKey(d);
      } else {
        // group by week (Monday)
        const mon = new Date(d);
        mon.setDate(d.getDate() - ((d.getDay() + 6) % 7));
        k = localDateStr(mon);
      }
      if (e.mat === 'hardwood') hwMap[k] = (hwMap[k] || 0) + Math.abs(e.deltaKg || 0);
      else if (e.mat === 'wheatbran') wbMap[k] = (wbMap[k] || 0) + Math.abs(e.deltaKg || 0);
      else if (e.mat === 'grain') grMap[k] = (grMap[k] || 0) + Math.abs(e.deltaKg || 0);
    });
    let subKeys, subLabels;
    if (ovPeriod === 'year') {
      subKeys = keys;
      subLabels = keys.map((k, i) => label(k, i));
    } else {
      subKeys = [...new Set([...Object.keys(hwMap), ...Object.keys(wbMap), ...Object.keys(grMap)])].sort();
      subLabels = subKeys.map((k) => 'KW ' + isoWeekNumber(k));
    }
    if (ovChartSubstrateInst) {
      ovChartSubstrateInst.destroy();
      ovChartSubstrateInst = null;
    }
    if (!subKeys.length) {
      const ctx = c3.getContext('2d');
      ctx.clearRect(0, 0, c3.width, c3.height);
      ctx.fillStyle = '#94a3b8';
      ctx.font = '12px system-ui';
      ctx.textAlign = 'center';
      ctx.fillText(t('dash.noHarvestData'), c3.width / 2, 60);
    } else {
      ovChartSubstrateInst = new Chart(c3, {
        type: 'bar',
        data: {
          labels: subLabels,
          datasets: [
            {
              label: t('dash.ov.grain'),
              data: subKeys.map((k) => +(grMap[k] || 0).toFixed(1)),
              backgroundColor: '#6b7c3f',
              borderRadius: 5
            },
            {
              label: t('dash.ov.hardwood'),
              data: subKeys.map((k) => +(hwMap[k] || 0).toFixed(1)),
              backgroundColor: '#8b5e3c',
              borderRadius: 5
            },
            {
              label: t('dash.ov.wheatbran'),
              data: subKeys.map((k) => +(wbMap[k] || 0).toFixed(1)),
              backgroundColor: '#c9a227',
              borderRadius: 5
            }
          ]
        },
        options: {
          responsive: true,
          plugins: {
            legend: { position: 'bottom', labels: { boxWidth: 10, font: { size: 10 } } },
            tooltip: { callbacks: { label: (c) => c.dataset.label + ': ' + c.parsed.y.toFixed(1) + 'kg' } }
          },
          scales: {
            y: {
              ticks: { callback: (v) => v + 'kg', color: '#94a3b8' },
              grid: { color: '#e2e8f0' },
              beginAtZero: true
            },
            x: { ticks: { color: '#94a3b8' }, grid: { display: false } }
          }
        }
      });
    }
  }

  // ── 4. Bags created ──────────────────────────────────────
  const c4 = document.getElementById('ov-chart-bags');
  if (c4) {
    const bagMap = {};
    batches
      .filter((b) => new Date(b.created) >= periodStart)
      .forEach((b) => {
        const k = groupKey(new Date(b.created));
        bagMap[k] = (bagMap[k] || 0) + (b.qty || 0);
      });
    if (ovChartBagsInst) {
      ovChartBagsInst.destroy();
      ovChartBagsInst = null;
    }
    ovChartBagsInst = new Chart(c4, {
      type: 'bar',
      data: {
        labels: keys.map((k, i) => label(k, i)),
        datasets: [
          {
            data: keys.map((k) => bagMap[k] || 0),
            backgroundColor: keys.map((k, i) =>
              ovPeriod === 'year' && i === nowDate.getMonth() ? '#2d6a4f' : 'rgba(45,106,79,0.55)'
            ),
            borderRadius: 6
          }
        ]
      },
      options: {
        responsive: true,
        plugins: {
          legend: { display: false },
          tooltip: { callbacks: { label: (c) => c.parsed.y + ' ' + t('dash.ov.bags') } }
        },
        scales: {
          y: { ticks: { color: '#94a3b8' }, grid: { color: '#e2e8f0' }, beginAtZero: true },
          x: { ticks: { font: { size: 9 }, color: '#94a3b8', maxTicksLimit: 14 }, grid: { display: false } }
        }
      }
    });
  }
}

// ── KPI History (daily snapshot trend charts) ───────────────
let kpiHistoryData = null;
let ovHistHarvestInst = null,
  ovHistPipelineInst = null,
  ovHistContamInst = null,
  ovHistStockInst = null;

async function loadKpiHistory() {
  try {
    const r = await fetch('/api/kpi-snapshots?limit=90');
    if (!r.ok) return;
    const j = await r.json();
    kpiHistoryData = j.items || [];
    renderKpiHistory();
  } catch (e) {
    console.warn('KPI history load failed', e);
  }
}

function renderKpiHistory() {
  const wrap = document.getElementById('ov-kpi-history');
  const emptyEl = document.getElementById('ov-history-empty');
  const chartsEl = document.getElementById('ov-history-charts');
  if (!wrap) return;
  // P-02: Chart.js may not be loaded yet (idle preload). Defer.
  if (typeof Chart === 'undefined') {
    loadVendorLibs().then(() => renderKpiHistory());
    return;
  }

  if (!kpiHistoryData || kpiHistoryData.length < 2) {
    if (emptyEl) emptyEl.style.display = '';
    if (chartsEl) chartsEl.style.display = 'none';
    return;
  }
  if (emptyEl) emptyEl.style.display = 'none';
  if (chartsEl) chartsEl.style.display = '';

  // Filter snapshots by the selected overview period + build fixed date range
  const nowDate = new Date();
  const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  let periodStart;
  if (ovPeriod === 'week') {
    periodStart = new Date(nowDate);
    periodStart.setDate(periodStart.getDate() - ((periodStart.getDay() + 6) % 7));
    periodStart.setHours(0, 0, 0, 0);
  } else if (ovPeriod === 'month') {
    periodStart = new Date(nowDate.getFullYear(), nowDate.getMonth(), 1);
  } else {
    periodStart = new Date(nowDate.getFullYear(), 0, 1);
  }
  const periodKey = localDateStr(periodStart);
  const filtered = kpiHistoryData.filter((s) => s.date >= periodKey);
  if (!filtered.length) {
    if (emptyEl) emptyEl.style.display = '';
    if (chartsEl) chartsEl.style.display = 'none';
    return;
  }

  // Build lookup + fixed date keys so x-axis always covers the full period
  const snapshotMap = {};
  filtered.forEach((s) => {
    snapshotMap[s.date] = s;
  });

  const histKeys = []; // date keys for x-axis
  const histLabels = []; // display labels
  if (ovPeriod === 'week') {
    // Mon–Sun (7 days)
    const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
    const cur = new Date(periodStart);
    for (let i = 0; i < 7; i++) {
      histKeys.push(localDateStr(cur));
      histLabels.push(dayNames[i] + ' ' + fmtDtShort(cur));
      cur.setDate(cur.getDate() + 1);
    }
  } else if (ovPeriod === 'month') {
    // 1st to last day of month
    const cur = new Date(periodStart);
    const lastDay = new Date(nowDate.getFullYear(), nowDate.getMonth() + 1, 0);
    while (cur <= lastDay) {
      histKeys.push(localDateStr(cur));
      histLabels.push(fmtDtShort(cur));
      cur.setDate(cur.getDate() + 1);
    }
  } else {
    // Year — Jan to Dec monthly buckets, use last snapshot per month
    for (let m = 0; m < 12; m++) {
      histKeys.push(`${nowDate.getFullYear()}-${String(m + 1).padStart(2, '0')}`);
      histLabels.push(monthNames[m]);
    }
    // Build monthly lookup: last snapshot of each month
    const monthSnap = {};
    filtered.forEach((s) => {
      const mk = s.date.slice(0, 7);
      monthSnap[mk] = s;
    });
    // Override snapshotMap for monthly access
    histKeys.forEach((mk) => {
      if (monthSnap[mk]) snapshotMap[mk] = monthSnap[mk];
    });
  }

  const snapVal = (key, field) => {
    const s = snapshotMap[key];
    return s ? s[field] || 0 : null;
  };

  const chartOpts = (yLabel, cb, extraOpts) => ({
    responsive: true,
    plugins: {
      legend: { display: true, labels: { boxWidth: 12, font: { size: 11 } } },
      tooltip: { callbacks: { label: cb || undefined } },
      ...((extraOpts && extraOpts.plugins) || {})
    },
    scales: {
      y: {
        ticks: { color: '#94a3b8', callback: (v) => v + yLabel },
        grid: { color: '#e2e8f0' },
        beginAtZero: true,
        ...(extraOpts && extraOpts.yMax ? { max: extraOpts.yMax } : {})
      },
      x: {
        ticks: { color: '#94a3b8', maxRotation: 45, font: { size: 9 } },
        grid: { display: false },
        ...(extraOpts && extraOpts.xStacked ? { stacked: true } : {})
      }
    }
  });
  const lineDs = (label, data, color, fill) => ({
    label,
    data,
    borderColor: color,
    backgroundColor: fill || color + '33',
    tension: 0.35,
    pointRadius: 2,
    pointBackgroundColor: color,
    fill: !!fill,
    spanGaps: true
  });

  // 1. Harvest chart
  const c1 = document.getElementById('ov-history-harvest-chart');
  if (c1) {
    if (ovHistHarvestInst) {
      ovHistHarvestInst.destroy();
      ovHistHarvestInst = null;
    }
    ovHistHarvestInst = new Chart(c1, {
      type: 'line',
      data: {
        labels: histLabels,
        datasets: [
          lineDs(
            t('dash.ov.harvestThisWeek'),
            histKeys.map((k) => {
              const v = snapVal(k, 'harvest_kg');
              return v !== null ? +v.toFixed(2) : null;
            }),
            '#c2703e',
            'rgba(194,112,62,0.10)'
          )
        ]
      },
      options: chartOpts('kg', (c) => c.parsed.y.toFixed(2) + 'kg')
    });
  }

  // 2. Pipeline chart — stacked area showing bag counts through stages
  const c3 = document.getElementById('ov-history-pipeline-chart');
  if (c3) {
    if (ovHistPipelineInst) {
      ovHistPipelineInst.destroy();
      ovHistPipelineInst = null;
    }
    const pipeStages = ['bags_spawn', 'bags_incubation', 'bags_fruiting', 'bags_contaminated'];
    const pipeFills = [
      'rgba(124,82,149,0.45)',
      'rgba(74,127,165,0.45)',
      'rgba(61,122,74,0.45)',
      'rgba(176,80,64,0.25)'
    ];
    const pipeBorders = ['#7c5295', '#4a7fa5', '#3d7a4a', '#b05040'];
    const pipeNames = [t('dash.ov.spawn'), t('dash.ov.incubation'), t('dash.ov.fruiting'), t('dash.ov.contaminated')];
    // Compute absolute bag counts + total line
    const pipeAbsData = pipeStages.map((stage) =>
      histKeys.map((k) => {
        const s = snapshotMap[k];
        return s ? s[stage] || 0 : null;
      })
    );
    const totalData = histKeys.map((k) => {
      const s = snapshotMap[k];
      if (!s) return null;
      // Contaminated bags are no part of the total; they have their own line.
      return (s.bags_spawn || 0) + (s.bags_incubation || 0) + (s.bags_fruiting || 0);
    });
    ovHistPipelineInst = new Chart(c3, {
      type: 'line',
      data: {
        labels: histLabels,
        datasets: [
          ...pipeStages.map((_, i) => ({
            label: pipeNames[i],
            data: pipeAbsData[i],
            borderColor: pipeBorders[i],
            backgroundColor: pipeFills[i],
            borderWidth: 1.5,
            fill: 'origin',
            tension: 0.35,
            pointRadius: 0,
            spanGaps: true,
            order: pipeStages.length - i
          })),
          {
            label: 'Total',
            data: totalData,
            borderColor: '#555',
            backgroundColor: 'transparent',
            borderWidth: 2,
            borderDash: [5, 3],
            tension: 0.35,
            pointRadius: 2,
            pointBackgroundColor: '#555',
            fill: false,
            spanGaps: true,
            order: 0
          }
        ]
      },
      options: {
        responsive: true,
        plugins: {
          legend: { display: true, labels: { boxWidth: 12, font: { size: 11 } } },
          tooltip: {
            mode: 'index',
            callbacks: {
              label: (c) => c.dataset.label + ': ' + c.parsed.y + ' ' + t('dash.ov.bags'),
              footer: (items) => {
                const total = items.find((i) => i.dataset.label === 'Total');
                return total ? '─── Total: ' + total.parsed.y + ' bags' : '';
              }
            }
          }
        },
        interaction: { mode: 'index', intersect: false },
        scales: {
          y: { stacked: false, ticks: { color: '#94a3b8' }, grid: { color: '#e2e8f0' }, beginAtZero: true },
          x: { ticks: { color: '#94a3b8', font: { size: 9 } }, grid: { display: false } }
        }
      }
    });
  }

  // 4. Contamination rate chart
  const c4 = document.getElementById('ov-history-contam-chart');
  if (c4) {
    if (ovHistContamInst) {
      ovHistContamInst.destroy();
      ovHistContamInst = null;
    }
    ovHistContamInst = new Chart(c4, {
      type: 'line',
      data: {
        labels: histLabels,
        datasets: [
          lineDs(
            t('dash.ov.contamRate'),
            histKeys.map((k) => {
              const v = snapVal(k, 'contam_rate_pct');
              return v !== null ? +v.toFixed(1) : null;
            }),
            '#b05040',
            'rgba(176,80,64,0.08)'
          )
        ]
      },
      options: chartOpts('%', (c) => c.parsed.y.toFixed(1) + '%')
    });
  }

  // 5. Stock levels chart
  const c5 = document.getElementById('ov-history-stock-chart');
  if (c5) {
    if (ovHistStockInst) {
      ovHistStockInst.destroy();
      ovHistStockInst = null;
    }
    ovHistStockInst = new Chart(c5, {
      type: 'line',
      data: {
        labels: histLabels,
        datasets: [
          lineDs(
            t('dash.ov.hardwoodUsed').replace(/ .*/, ''),
            histKeys.map((k) => {
              const v = snapVal(k, 'stock_hardwood_kg');
              return v !== null ? +v.toFixed(1) : null;
            }),
            '#8b5e3c'
          ),
          lineDs(
            t('dash.ov.wheatbranUsed').replace(/ .*/, ''),
            histKeys.map((k) => {
              const v = snapVal(k, 'stock_wheatbran_kg');
              return v !== null ? +v.toFixed(1) : null;
            }),
            '#c9a227'
          ),
          lineDs(
            t('dash.ov.grain'),
            histKeys.map((k) => {
              const v = snapVal(k, 'stock_grain_kg');
              return v !== null ? +v.toFixed(1) : null;
            }),
            '#6b7c3f'
          )
        ]
      },
      options: chartOpts('kg', (c) => c.parsed.y.toFixed(1) + 'kg')
    });
  }
}

async function takeKpiSnapshot() {
  try {
    const r = await fetch('/api/kpi-snapshots/now', { method: 'POST' });
    if (r.status === 401 || r.status === 403) {
      toast(t('dash.ov.snapshotAuthErr') || 'Admin login required', 'err');
      return;
    }
    if (!r.ok) throw new Error('Failed');
    await loadKpiHistory();
  } catch (e) {
    console.error('Snapshot failed', e);
    toast(t('dash.ov.snapshotErr') || 'Snapshot failed', 'err');
  }
}

function exportOverviewCSV() {
  const nowDate = new Date();
  const now = Date.now();
  let periodStart, periodLabel;
  if (ovPeriod === 'week') {
    periodStart = new Date(nowDate);
    periodStart.setDate(periodStart.getDate() - ((periodStart.getDay() + 6) % 7));
    periodStart.setHours(0, 0, 0, 0);
    periodLabel = 'KW ' + isoWeekNumber(nowDate);
  } else if (ovPeriod === 'month') {
    periodStart = new Date(nowDate.getFullYear(), nowDate.getMonth(), 1);
    periodLabel = String(nowDate.getMonth() + 1).padStart(2, '0') + '.' + nowDate.getFullYear();
  } else {
    periodStart = new Date(nowDate.getFullYear(), 0, 1);
    periodLabel = String(nowDate.getFullYear());
  }

  // Bags created
  const bagsCreated = batches.filter((b) => new Date(b.created) >= periodStart).reduce((s, b) => s + (b.qty || 0), 0);

  // Materials
  let grainUsed = 0,
    hardwoodUsed = 0,
    wheatbranUsed = 0;
  (inventory.log || []).forEach((e) => {
    if (e.type !== 'batch' || new Date(e.time) < periodStart) return;
    if (e.mat === 'grain') grainUsed += Math.abs(e.deltaKg || 0);
    else if (e.mat === 'hardwood') hardwoodUsed += Math.abs(e.deltaKg || 0);
    else if (e.mat === 'wheatbran') wheatbranUsed += Math.abs(e.deltaKg || 0);
  });

  // Harvests
  const periodHarvests = harvests.filter((h) => new Date(h.time) >= periodStart);
  const harvestKg = periodHarvests.reduce((s, h) => s + (h.grams || 0), 0) / 1000;

  // Harvest by species
  const bySpecies = {};
  periodHarvests.forEach((h) => {
    bySpecies[h.species] = (bySpecies[h.species] || 0) + (h.grams || 0);
  });

  // Harvest by day
  const byDay = {};
  periodHarvests.forEach((h) => {
    const key = localDateStr(new Date(h.time));
    byDay[key] = (byDay[key] || 0) + (h.grams || 0);
  });

  // Build CSV rows
  const rows = [];
  rows.push(['Overview Export — ' + periodLabel]);
  rows.push([]);
  rows.push(['KPI', 'Value']);
  rows.push(['Bags created', bagsCreated]);
  rows.push(['Grain used (kg)', +grainUsed.toFixed(2)]);
  rows.push(['Harvest (kg)', +harvestKg.toFixed(2)]);
  rows.push(['Hardwood used (kg)', +hardwoodUsed.toFixed(2)]);
  rows.push(['Wheat bran used (kg)', +wheatbranUsed.toFixed(2)]);
  rows.push(['Harvests logged', periodHarvests.length]);
  rows.push([]);

  // Harvest by species
  rows.push(['Harvest by species', 'kg']);
  Object.keys(bySpecies)
    .sort()
    .forEach((sp) => {
      rows.push([sp, +(bySpecies[sp] / 1000).toFixed(2)]);
    });
  rows.push([]);

  // Harvest by day
  const dayKeys = Object.keys(byDay).sort();
  if (dayKeys.length) {
    rows.push(['Date', 'Harvest (kg)']);
    dayKeys.forEach((k) => {
      rows.push([fmtDtShort(k) + '.' + new Date(k).getFullYear(), +(byDay[k] / 1000).toFixed(2)]);
    });
  }

  // KPI History snapshots for this period
  if (kpiHistoryData && kpiHistoryData.length) {
    const periodKey = localDateStr(periodStart);
    const snaps = kpiHistoryData.filter((s) => s.date >= periodKey);
    if (snaps.length) {
      rows.push([]);
      rows.push(['KPI History (daily snapshots)']);
      rows.push([
        'Date',
        'Bags created',
        'Harvest (kg)',
        'Grain (kg)',
        'Hardwood (kg)',
        'Wheat bran (kg)',
        'Contam rate (%)',
        'Spawn',
        'Incubation',
        'Fruiting',
        'Contaminated',
        'Stock HW (kg)',
        'Stock WB (kg)',
        'Stock Grain (kg)'
      ]);
      snaps.forEach((s) => {
        rows.push([
          fmtDtShort(s.date) + '.' + s.date.slice(0, 4),
          s.bags_created || 0,
          +(s.harvest_kg || 0).toFixed(2),
          +(s.grain_used_kg || 0).toFixed(2),
          +(s.hardwood_used_kg || 0).toFixed(2),
          +(s.wheatbran_used_kg || 0).toFixed(2),
          +(s.contam_rate_pct || 0).toFixed(1),
          s.bags_spawn || 0,
          s.bags_incubation || 0,
          s.bags_fruiting || 0,
          s.bags_contaminated || 0,
          +(s.stock_hardwood_kg || 0).toFixed(1),
          +(s.stock_wheatbran_kg || 0).toFixed(1),
          +(s.stock_grain_kg || 0).toFixed(1)
        ]);
      });
    }
  }

  const csv =
    '\uFEFF' +
    rows.map((r) => r.map((c) => '"' + String(c == null ? '' : c).replace(/"/g, '""') + '"').join(';')).join('\r\n');
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'overview_' + ovPeriod + '_' + nowDate.toISOString().slice(0, 10) + '.csv';
  a.click();
}

const CHEVRON_SVG =
  '<svg class="location-section-toggle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>';
let ZONE_LABELS = {};
let ZONE_COLORS = {};
function rackLabel(id) {
  const m = id.match(/\d+$/);
  return m ? t('dash.rackN', { n: m[0] }) : id.replace(/_/g, ' ');
}

function renderStatus() {
  const q = (document.getElementById('status-q')?.value || '').toLowerCase();
  const el = document.getElementById('dash-locations');
  if (!el) return;
  // The room view moved to its own sub-tab, and rebuilding it while that tab is
  // closed is pure waste: getRackBags/getZoneBags walk the entire scan log, and
  // this function calls them about thirty times. Only the Sorten tiles still
  // have to run, so only the rack markup is skipped.
  //
  // renderOverviewKPIs() stand hier einmal mit, aus der Zeit, als die
  // Betriebsseite sich nicht selbst zeichnete. Es endet in
  // renderOverviewCharts(), und das ruft .destroy() und new Chart() auf vier
  // Leinwaenden — auf einer Seite mit display:none, bei jedem Scan. Seit
  // go() und refresh() die Betriebsseite kennen, zeichnet sie sich selbst.
  const panel = document.getElementById('sp-batch-locations');
  if (panel && !panel.classList.contains('active')) {
    renderBatches();
    return;
  }
  if (!zones.length) {
    // Wie auf der Zonenseite: ohne Daten keine Aussage über die Daten.
    el.innerHTML = '<div class="empty">' + t(datenGeladen ? 'dash.noZones' : 'common.loading') + '</div>';
    renderBatches();
    return;
  }
  if (!batches.length) {
    el.innerHTML = '<div class="empty">' + t('dash.noBatches') + '</div>';
    renderBatches();
    return;
  }

  // Compute per-batch status.
  //
  // The four per-stage bag counters that used to be accumulated here fed
  // renderPipelineKPIs() and nothing else. The Sorten tiles need the same
  // numbers broken down per Sorte rather than as one total, so they are
  // summed in sorteRollup() from the same getStatus().c — one place doing
  // it once, instead of a total here and a breakdown there that could drift.
  const batchData = batches.map((b) => {
    const { c, total, status } = getStatus(b.batchId);
    const harv = getHarvested(b.batchId);
    const due = new Date(b.due);
    const ov =
      isBatchOverdue(b) && zones.some((z) => (z.role === 'incubation' || z.role === 'spawn') && (c[z.id] || 0) > 0);
    return { b, c, total, status, harv, due, ov };
  });

  // Filter by search
  const filtered = batchData.filter(
    (d) =>
      !q ||
      d.b.batchId.toLowerCase().includes(q) ||
      (d.b.species || '').toLowerCase().includes(q) ||
      (d.b.strain || '').toLowerCase().includes(q) ||
      (d.b.strainName || '').toLowerCase().includes(q)
  );

  // One sort control above all the rooms, not one per room — the question
  // "what am I looking at first" has the same answer in every tent.
  let html = locSortControlHtml();
  // Render zones dynamically by role
  const fruitingZones = zones.filter((z) => z.role === 'fruiting');
  const contamZones = zones.filter((z) => z.role === 'contaminated');
  zones
    .filter((z) => z.role !== 'fruiting' && z.role !== 'contaminated')
    .forEach((z) => {
      if (z.racks.length > 0)
        html += renderRackSection(
          z.id,
          z.racks.map((r) => r.id),
          filtered
        );
      else html += renderSimpleZoneSection(z, filtered);
    });
  if (fruitingZones.length) html += renderFruitingSection(fruitingZones, filtered);
  contamZones.forEach((z) => {
    const contamBags = getZoneBags(z.id);
    if (Object.keys(contamBags).length > 0) html += renderContamSection(z, filtered);
  });

  el.innerHTML = html;
  renderBatches();
  updateActionBar();
}

function renderRackSection(zone, racks, filtered) {
  const color = ZONE_COLORS[zone];
  const zoneObj = zones.find((z) => z.id === zone);
  const cap = zoneObj ? zoneObj.maxCapacity : null;
  let totalBags = 0;
  racks.forEach((r) => (totalBags += Object.keys(getRackBags(r)).length));
  const q = (document.getElementById('status-q')?.value || '').toLowerCase();

  const rackCards = racks
    .map((rackId) => {
      const bags = getRackBags(rackId);
      const count = Object.keys(bags).length;
      const byBatch = {};
      Object.entries(bags).forEach(([bagId, d]) => {
        if (!byBatch[d.batchId]) byBatch[d.batchId] = { sp: d.species, st: d.strain, bags: [] };
        byBatch[d.batchId].bags.push({ id: bagId, loc: rackId });
      });
      // Filter batches by search
      const batchEntries = sortZoneBatches(Object.entries(byBatch)).filter(
        ([bid, d]) =>
          !q || bid.toLowerCase().includes(q) || d.sp.toLowerCase().includes(q) || d.st.toLowerCase().includes(q)
      );

      let batchHtml = batchEntries
        .map(([bid, d]) => {
          const bd = filtered.find((f) => f.b.batchId === bid);
          const ov = bd ? bd.ov : false;
          d.bags.sort((a, b) => (parseInt(a.id.split('-').pop()) || 0) - (parseInt(b.id.split('-').pop()) || 0));
          return `<div class="batch-card${ov ? ' batch-overdue' : ''}" onclick="this.classList.toggle('expanded')">
        <div class="batch-card-header">
          <span class="batch-card-species">${esc(d.sp)}</span>
          <span class="batch-card-count">${d.bags.length}</span>
        </div>
        <div class="batch-card-meta">
          <span class="fs-micro" style="font-family:monospace">${esc(bid)}</span>
          <span>${esc(d.st)}</span>
          ${bd && bd.ov ? `<span class="overdue-text">${t('dash.overdue')}</span>` : ''}
        </div>
        <div class="batch-card-chips">${d.bags
          .map((bg) => {
            const sel = selectedLocBags.has(bg.id);
            return `<span class="bag-chip${sel ? ' selected' : ''}" data-bag="${esc(bg.id)}" data-batch="${esc(bid)}" data-loc="${esc(bg.loc)}">${bg.id.split('-').pop()}</span>`;
          })
          .join('')}</div>
      </div>`;
        })
        .join('');
      if (!batchEntries.length && !count)
        batchHtml = `<div class="fs-xs" style="color:var(--c-text-muted);font-style:italic">${t('dash.empty')}</div>`;

      return `<div class="rack-card-new">
      <div class="rack-card-header">
        <span class="rack-card-name">${rackLabel(rackId)}</span>
        <span class="rack-card-count">${tp('dash.bags', count)}</span>
      </div>
      <div class="rack-card-bar"><div class="rack-card-bar-fill" style="background:${color};width:${Math.min(100, Math.round((count / 20) * 100))}%"></div></div>
      <div class="rack-card-batches">${batchHtml}</div>
    </div>`;
    })
    .join('');

  const rackCount = racks.length;
  const gridClass = rackCount > 4 ? 'rack-grid rack-grid-5col' : 'rack-grid';
  const capHtml = cap
    ? `<div style="display:flex;align-items:center;gap:8px;margin-top:4px">
      <div style="flex:1;height:6px;background:var(--c-bg);border-radius:3px;overflow:hidden"><div style="height:100%;background:${totalBags > cap ? '#ef4444' : color};width:${Math.min(100, Math.round((totalBags / cap) * 100))}%;border-radius:3px"></div></div>
      <span class="fs-xs" style="color:${totalBags > cap ? '#ef4444' : 'var(--c-text-muted)'};white-space:nowrap">${Math.round((totalBags / cap) * 100)}%</span>
    </div>`
    : '';
  const zoneHasUrgent = filtered.some(
    (d) => d.ov && Object.keys(d.c).some((zid) => zid === zone || racks.includes(zid))
  );
  // Open when there is something to see. This used to read `dashMode === 'farm' &&`
  // — a toggle on the Dashboard silently deciding how the Chargen page looked, so
  // switching to Übersicht once left every zone here collapsed with no visible cause.
  const sectionClass = 'location-section' + (zoneHasUrgent ? '' : ' collapsed');
  return `<div class="${sectionClass}" data-zone="${esc(zone)}">
    <div class="location-section-header" onclick="this.parentElement.classList.toggle('collapsed')">
      <div class="location-section-title">${CHEVRON_SVG}<span class="zone-dot" style="background:${color}"></span>${esc(zoneDisplayName(zone))}</div>
      <span class="location-section-count">${cap ? totalBags + ' / ' + cap + ' Bags' : tp('dash.bags', totalBags)}</span>
      <button class="fs-xs" type="button" data-zone-check="${esc(zone)}" style="margin-left:8px;flex:none;font-weight:600;padding:5px 10px;border-radius:8px;border:1px solid var(--c-border);background:var(--c-card);cursor:pointer">${esc(t('zoneCheck.btn'))}</button>
    </div>
    <div class="location-section-body">${capHtml}
      <div class="${gridClass}">${rackCards}</div>
    </div>
  </div>`;
}

function renderFruitingSection(fruitingZones, filtered) {
  let totalBags = 0;
  fruitingZones.forEach((z) => (totalBags += Object.keys(getZoneBags(z.id)).length));
  const q = (document.getElementById('status-q')?.value || '').toLowerCase();
  const color = fruitingZones[0]?.color || ZONE_ROLE_COLOR.fruiting;

  const tentCols = fruitingZones
    .map((z) => {
      const bags = getZoneBags(z.id);
      const entries = Object.entries(bags);
      const byBatch = {};
      entries.forEach(([bagId, d]) => {
        if (!byBatch[d.batchId]) byBatch[d.batchId] = { sp: d.species, st: d.strain, bags: [] };
        byBatch[d.batchId].bags.push({ id: bagId, loc: d.loc });
      });
      const batchEntries = sortZoneBatches(Object.entries(byBatch)).filter(
        ([bid, d]) =>
          !q || bid.toLowerCase().includes(q) || d.sp.toLowerCase().includes(q) || d.st.toLowerCase().includes(q)
      );

      if (!batchEntries.length) {
        return `<div class="tent-column">
        <div class="tent-column-header">${esc(zoneDisplayName(z.id))}</div>
        <div class="tent-column-empty">${t('dash.empty')}</div>
      </div>`;
      }
      const cards = batchEntries
        .map(([bid, d]) => {
          const bd = filtered.find((f) => f.b.batchId === bid);
          const harv = bd ? bd.harv : 0;
          const due = bd ? bd.due : null;
          const ov = bd ? bd.ov : false;
          d.bags.sort((a, b) => (parseInt(a.id.split('-').pop()) || 0) - (parseInt(b.id.split('-').pop()) || 0));
          return `<div class="batch-card${ov ? ' batch-overdue' : ''}" onclick="this.classList.toggle('expanded')">
        <div class="batch-card-header">
          <span class="batch-card-species">${esc(d.sp)}</span>
          <span class="batch-card-count">${d.bags.length}</span>
        </div>
        <div class="batch-card-meta">
          <span class="fs-micro" style="font-family:monospace">${esc(bid)}</span>
          <span>${esc(d.st)}</span>
          ${harv > 0 ? `<span style="color:var(--c-amber-dark);font-weight:500">${t('dash.harvested')}: ${harv}g</span>` : ''}
          ${due ? `<span style="color:${ov ? 'var(--c-red-dark)' : 'var(--c-text-muted)'}">${t('dash.due')}: ${fmtDt(due)}${ov ? ' \u26a0' : ''}</span>` : ''}
        </div>
        <div class="batch-card-chips">${d.bags
          .map((bg) => {
            const sel = selectedLocBags.has(bg.id);
            return `<span class="bag-chip${sel ? ' selected' : ''}" data-bag="${esc(bg.id)}" data-batch="${esc(bid)}" data-loc="${esc(bg.loc)}">${bg.id.split('-').pop()}</span>`;
          })
          .join('')}</div>
      </div>`;
        })
        .join('');
      const cap = z.maxCapacity;
      const capBar = cap
        ? `<div style="display:flex;align-items:center;gap:6px;margin:4px 0">
        <div style="flex:1;height:5px;background:var(--c-bg);border-radius:3px;overflow:hidden"><div class="${entries.length > cap ? 'kap-voll' : ''}" style="height:100%;background-color:${z.color || color};width:${Math.min(100, Math.round((entries.length / cap) * 100))}%;border-radius:3px"></div></div>
        <span class="fs-micro" style="color:${entries.length > cap ? '#ef4444' : 'var(--c-text-muted)'}">${Math.round((entries.length / cap) * 100)}%</span>
      </div>`
        : '';
      return `<div class="tent-column">
      <div class="tent-column-header">${esc(zoneDisplayName(z.id))} <span class="fs-xs" style="font-weight:400;color:var(--c-text-muted)">(${cap ? entries.length + '/' + cap : entries.length})</span></div>${capBar}
      ${cards}
    </div>`;
    })
    .join('');

  const fruitSectionClass = 'location-section' + (totalBags > 0 ? '' : ' collapsed');
  return `<div class="${fruitSectionClass}" data-zone="fruiting">
    <div class="location-section-header" onclick="this.parentElement.classList.toggle('collapsed')">
      <div class="location-section-title">${CHEVRON_SVG}<span class="zone-dot" style="background:${color}"></span>${t('dash.fruitingTents')}</div>
      <span class="location-section-count">${tp('dash.bags', totalBags)}</span>
    </div>
    <div class="location-section-body">
      <div class="tent-columns">${tentCols}</div>
    </div>
  </div>`;
}

// The room lists came out in scan-log insertion order — effectively the order
// bags happened to be added, which is no order at all once a tent holds twenty
// batches. One sort, shared by the simple, rack and fruiting renderers so a
// room never sorts differently from the room next to it.
const LOC_SORTS = ['due', 'species', 'bags', 'id'];
const LOC_SORT_LABEL = { due: 'th.due', species: 'th.species', bags: 'worklist.bags', id: 'th.batchId' };
let _locSortKey = null;
function locSortKey() {
  if (_locSortKey === null) {
    // Guarded like the other stored preferences: storage revoked mid-session
    // must not throw inside a render.
    try {
      _locSortKey = localStorage.getItem('mp-loc-sort') || '';
    } catch (e) {
      _locSortKey = '';
    }
  }
  // Due date is the default because it is the one that says what to do next.
  return LOC_SORTS.includes(_locSortKey) ? _locSortKey : 'due';
}
function setLocSort(key) {
  if (!LOC_SORTS.includes(key)) return;
  _locSortKey = key;
  try {
    localStorage.setItem('mp-loc-sort', key);
  } catch (e) {
    /* storage disabled — the choice just won't survive a reload */
  }
  renderStatus();
}
function locSortControlHtml() {
  const cur = locSortKey();
  return (
    '<div style="display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px">' +
    '<span class="fs-xs" style="color:var(--c-text-muted);flex-shrink:0">' +
    esc(t('sort.by')) +
    '</span>' +
    LOC_SORTS.map((k) => {
      const on = k === cur;
      return (
        '<button type="button" class="fs-floor" data-action="loc-sort" data-key="' +
        esc(k) +
        '" aria-pressed="' +
        on +
        '" style="--fs-own:10.5px;padding:3px 8px;border-radius:11px;cursor:pointer;font-family:inherit;border:1px solid ' +
        (on ? 'var(--c-accent)' : 'var(--c-border)') +
        ';background:' +
        (on ? 'var(--c-accent)' : 'transparent') +
        ';color:' +
        (on ? '#fff' : 'var(--c-text-sec)') +
        '">' +
        esc(t(LOC_SORT_LABEL[k])) +
        '</button>'
      );
    }).join('') +
    '</div>'
  );
}
// batchEntries is [batchId, {sp, st, bags}] in all three renderers, so they can
// share this. Ties break on the batch id, which keeps the order stable rather
// than reshuffling equal rows on every re-render.
function sortZoneBatches(batchEntries) {
  const key = locSortKey();
  const dueOf = (bid) => {
    const b = batchById(bid);
    return b ? b.due : null;
  };
  return [...batchEntries].sort((x, y) => {
    if (key === 'species') return sortCmp(x[1].sp, y[1].sp) || sortCmp(x[0], y[0]);
    // Most bags first: "where is the bulk of the work" is the question here.
    if (key === 'bags') return y[1].bags.length - x[1].bags.length || sortCmp(x[0], y[0]);
    if (key === 'id') return sortCmp(x[0], y[0]);
    // sortCmp already sinks null to the end, so an undated batch lands last
    // rather than pretending to be the most urgent thing in the room.
    return sortCmp(dueOf(x[0]), dueOf(y[0])) || sortCmp(x[0], y[0]);
  });
}
function renderSimpleZoneSection(zone, filtered) {
  const bags = getZoneBags(zone.id);
  const entries = Object.entries(bags);
  const q = (document.getElementById('status-q')?.value || '').toLowerCase();
  const byBatch = {};
  entries.forEach(([bagId, d]) => {
    if (!byBatch[d.batchId]) byBatch[d.batchId] = { sp: d.species, st: d.strain, bags: [] };
    byBatch[d.batchId].bags.push({ id: bagId, loc: d.loc });
  });
  const batchEntries = sortZoneBatches(Object.entries(byBatch)).filter(
    ([bid, d]) =>
      !q || bid.toLowerCase().includes(q) || d.sp.toLowerCase().includes(q) || d.st.toLowerCase().includes(q)
  );
  const cards = batchEntries
    .map(([bid, d]) => {
      d.bags.sort((a, b) => (parseInt(a.id.split('-').pop()) || 0) - (parseInt(b.id.split('-').pop()) || 0));
      return `<div class="batch-card" onclick="this.classList.toggle('expanded')">
      <div class="batch-card-header"><span class="batch-card-species">${esc(d.sp)}</span><span class="batch-card-count">${d.bags.length}</span></div>
      <div class="batch-card-meta"><span class="fs-micro" style="font-family:monospace">${esc(bid)}</span><span>${esc(d.st)}</span></div>
      <div class="batch-card-chips">${d.bags
        .map((bg) => {
          const sel = selectedLocBags.has(bg.id);
          return `<span class="bag-chip${sel ? ' selected' : ''}" data-bag="${esc(bg.id)}" data-batch="${esc(bid)}" data-loc="${esc(bg.loc)}">${bg.id.split('-').pop()}</span>`;
        })
        .join('')}</div>
    </div>`;
    })
    .join('');
  if (!cards) return '';
  const cap = zone.maxCapacity;
  const capHtml = cap
    ? `<div style="display:flex;align-items:center;gap:8px;margin-top:4px">
      <div style="flex:1;height:6px;background:var(--c-bg);border-radius:3px;overflow:hidden"><div style="height:100%;background:${entries.length > cap ? '#ef4444' : zone.color};width:${Math.min(100, Math.round((entries.length / cap) * 100))}%;border-radius:3px"></div></div>
      <span class="fs-xs" style="color:${entries.length > cap ? '#ef4444' : 'var(--c-text-muted)'};white-space:nowrap">${Math.round((entries.length / cap) * 100)}%</span>
    </div>`
    : '';
  return `<div class="location-section">
    <div class="location-section-header">
      <div class="location-section-title"><span class="zone-dot" style="background:${zone.color}"></span>${esc(zoneDisplayName(zone.id))}</div>
      <span class="location-section-count">${cap ? entries.length + ' / ' + cap + ' Bags' : tp('dash.bags', entries.length)}</span>
    </div>${capHtml}
    <div style="display:flex;flex-direction:column;gap:6px">${cards}</div>
  </div>`;
}

function renderContamSection(zone, filtered) {
  const bags = getZoneBags(zone.id);
  const entries = Object.entries(bags);
  const q = (document.getElementById('status-q')?.value || '').toLowerCase();
  const byBatch = {};
  entries.forEach(([bagId, d]) => {
    if (!byBatch[d.batchId]) byBatch[d.batchId] = { sp: d.species, st: d.strain, bags: [] };
    byBatch[d.batchId].bags.push({ id: bagId, loc: d.loc });
  });
  const batchEntries = sortZoneBatches(Object.entries(byBatch)).filter(
    ([bid, d]) =>
      !q || bid.toLowerCase().includes(q) || d.sp.toLowerCase().includes(q) || d.st.toLowerCase().includes(q)
  );
  if (!batchEntries.length) return '';

  const cards = batchEntries
    .map(([bid, d]) => {
      d.bags.sort((a, b) => (parseInt(a.id.split('-').pop()) || 0) - (parseInt(b.id.split('-').pop()) || 0));
      return `<div class="batch-card" onclick="this.classList.toggle('expanded')">
      <div class="batch-card-header">
        <span class="batch-card-species">${esc(d.sp)}</span>
        <span class="batch-card-count">${d.bags.length}</span>
      </div>
      <div class="batch-card-meta">
        <span class="fs-micro" style="font-family:monospace">${esc(bid)}</span>
        <span>${esc(d.st)}</span>
      </div>
      <div class="batch-card-chips">${d.bags
        .map((bg) => {
          const sel = selectedLocBags.has(bg.id);
          return `<span class="bag-chip${sel ? ' selected' : ''}" data-bag="${esc(bg.id)}" data-batch="${esc(bid)}" data-loc="${esc(bg.loc)}">${bg.id.split('-').pop()}</span>`;
        })
        .join('')}</div>
    </div>`;
    })
    .join('');

  return `<div class="location-section contam-section">
    <div class="location-section-header">
      <div class="location-section-title"><span class="zone-dot" style="background:${zone.color}"></span>\u26a0 ${esc(zoneDisplayName(zone.id))}</div>
      <span class="location-section-count">${tp('dash.bags', entries.length)}</span>
    </div>
    <div style="display:flex;flex-direction:column;gap:6px">${cards}</div>
  </div>`;
}

function updateActionBar() {
  const bar = document.getElementById('loc-action-bar');
  if (!bar) return;
  const n = selectedLocBags.size;
  if (n > 0) {
    bar.style.display = 'flex';
    bar.innerHTML = `<span class="action-bar-count">${tp('dash.bagsSelected', n)}</span><span style="flex:1"></span>
      <button class="btn btn-sm fs-xs" onclick="locSelectAllVisible()">${t('dash.selectAll')}</button>
      <button class="btn btn-sm fs-xs" onclick="selectedLocBags.clear();renderStatus()">${t('dash.clear')}</button>
      <button class="btn btn-sm btn-p fs-xs" onclick="openLocMovePopup()">${t('dash.move')}</button>
      <button class="btn btn-sm btn-r fs-xs" onclick="locRemoveSelected()">${t('dash.remove')}</button>`;
  } else {
    bar.style.display = 'none';
  }
}

function locSelectAllVisible() {
  const q = (document.getElementById('status-q')?.value || '').toLowerCase();
  ZONES.forEach((z) => {
    const bags = getZoneBags(z);
    Object.entries(bags).forEach(([bagId, d]) => {
      if (
        !q ||
        bagId.toLowerCase().includes(q) ||
        (d.batchId || '').toLowerCase().includes(q) ||
        (d.species || '').toLowerCase().includes(q) ||
        (d.strain || '').toLowerCase().includes(q)
      )
        selectedLocBags.set(bagId, { batchId: d.batchId, loc: d.loc });
    });
  });
  renderStatus();
}
function setOvPeriod(p) {
  ovPeriod = p;
  localStorage.setItem('mp-ov-period', p);
  renderOverviewKPIs();
  renderKpiHistory();
}
function applyOvPeriod() {
  ['week', 'month', 'year'].forEach((p) => {
    const btn = document.getElementById('ov-p-' + p);
    if (btn) btn.classList.toggle('active', ovPeriod === p);
  });
}

// ─── WORK LIST (pick / walk sheet) ──────────────────────────
// Tapping a summary chip opens a findable, printable list of the batches that
// need moving / harvesting, grouped by their CURRENT location so a worker can
// walk zone by zone. Print goes to a normal browser printer (e.g. a Canon),
// separate from the Zebra label printer.
let _workListData = null;
let _workListMode = localStorage.getItem('mp-wl-group') === 'batch' ? 'batch' : 'location';
function _batchZoneCounts(batch, lastByBag) {
  const counts = {};
  (batch.bags || []).forEach((bag) => {
    const last = lastByBag.get(bag.toUpperCase());
    if (!last || last.action === 'REMOVE' || !last.to || isContamLoc(last.to)) return;
    const z = toZone(last.to);
    counts[z] = (counts[z] || 0) + 1;
  });
  return counts;
}
// Flattens the move/harvest tasks into (batch, zone, count) rows. The same rows
// then group either by zone or by batch (see _groupWorkList). total is the
// distinct batch count, so the title matches the dashboard summary chip.
function buildWorkList(kind) {
  const lastByBag = buildLastScanByBag();
  const rows = [];
  const pushRows = (batchId, species, strain, note) => {
    const b = batches.find((x) => x.batchId === batchId);
    if (!b) return;
    const counts = _batchZoneCounts(b, lastByBag);
    Object.keys(counts).forEach((z) =>
      rows.push({
        batchId,
        species: species || b.species,
        strain: (strain || '').trim(),
        zone: z,
        zoneName: zoneDisplayName(z),
        count: counts[z],
        note: note || ''
      })
    );
  };
  if (kind === 'move') {
    buildAutoTasks()
      .filter((tk) => tk.taskAction === 'move' && tk.ready)
      .forEach((tk) => {
        const b = batches.find((x) => x.batchId === tk.batchId);
        pushRows(tk.batchId, b && b.species, b && (b.strainText || b.strain), tk.detail);
      });
  } else if (kind === 'harvest') {
    buildHarvestTasks().forEach((tk) => pushRows(tk.batchId, tk.species, tk.strain, tp('harvest.daysFruiting', tk.daysFruiting)));
  }
  return {
    kind,
    title: kind === 'move' ? t('dash.sumMove') : t('dash.sumHarvest'),
    rows,
    total: new Set(rows.map((r) => r.batchId)).size
  };
}
// Groups the flat rows for one of the two views. 'location' → a card per zone
// listing its batches (walk the room zone by zone); 'batch' → a card per batch
// listing where that batch's bags currently sit.
function _groupWorkList(rows, mode) {
  const byKey = new Map();
  rows.forEach((r) => {
    const key = mode === 'batch' ? r.batchId : r.zone;
    if (!byKey.has(key)) {
      byKey.set(key, {
        key,
        batchId: r.batchId,
        name: mode === 'batch' ? r.batchId : r.zoneName,
        sub: mode === 'batch' ? r.species + (r.strain ? ' · ' + r.strain : '') : '',
        bags: 0,
        items: []
      });
    }
    const g = byKey.get(key);
    g.items.push(r);
    g.bags += r.count;
  });
  const list = [...byKey.values()];
  list.sort((a, b) => a.name.localeCompare(b.name));
  list.forEach((g) =>
    g.items.sort((a, b) => (mode === 'batch' ? a.zoneName.localeCompare(b.zoneName) : a.batchId.localeCompare(b.batchId)))
  );
  return list;
}
function openWorkList(kind) {
  _workListData = buildWorkList(kind);
  renderWorkList();
  document.getElementById('m-worklist').classList.add('open');
}
// Renders the open work list for the current _workListMode: title count, the
// Ort/Charge toggle's active state, and the grouped body. Split from
// openWorkList so flipping the toggle can re-render without rebuilding tasks.
function renderWorkList() {
  const data = _workListData;
  if (!data) return;
  const mode = _workListMode;
  const bags = esc(t('worklist.bags'));
  document.getElementById('wl-title').textContent = data.title + ' (' + data.total + ')';
  document.querySelectorAll('#wl-group [data-wl-group]').forEach((b) => {
    const on = b.dataset.wlGroup === mode;
    b.style.background = on ? 'var(--c-primary, #16a34a)' : 'transparent';
    b.style.color = on ? '#fff' : 'var(--c-text-sec)';
    b.style.borderColor = on ? 'var(--c-primary, #16a34a)' : 'var(--c-border)';
    b.setAttribute('aria-pressed', on ? 'true' : 'false');
  });
  const body = document.getElementById('wl-body');
  const groups = _groupWorkList(data.rows, mode);
  if (!groups.length) {
    body.innerHTML =
      '<div style="color:var(--c-text-muted);font-style:italic;padding:14px 0">' + esc(t('worklist.empty')) + '</div>';
    return;
  }
  body.innerHTML = groups
    .map((g) => {
      if (mode === 'batch') {
        const zoneRows = g.items
          .map(
            (it) =>
              `<div style="display:flex;align-items:center;gap:10px;padding:8px 6px 8px 14px;border-bottom:0.5px solid var(--c-border)">` +
              `<span class="fs-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(it.zoneName)}</span>` +
              `<span class="fs-floor" style="--fs-own:0.875rem;font-weight:600;white-space:nowrap">${it.count} ${bags}</span>` +
              `</div>`
          )
          .join('');
        return (
          `<div data-wl-batch="${esc(g.batchId)}" style="margin-bottom:14px;cursor:pointer">` +
          `<div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px;padding:6px 4px;border-bottom:2px solid var(--c-border)">` +
          `<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><span class="fs-sm" style="font-family:monospace;font-weight:700">${esc(g.batchId)}</span>${g.sub ? `<span class="fs-meta" style="color:var(--c-text-sec)"> · ${esc(g.sub)}</span>` : ''}</span>` +
          `<span class="fs-meta" style="color:var(--c-text-muted);white-space:nowrap">${g.bags} ${bags}</span></div>` +
          zoneRows +
          `</div>`
        );
      }
      const batchRows = g.items
        .map(
          (it) =>
            `<div data-wl-batch="${esc(it.batchId)}" style="display:flex;align-items:center;gap:10px;padding:10px 6px;border-bottom:0.5px solid var(--c-border);cursor:pointer">` +
            `<span class="fs-sm" style="font-family:monospace;font-weight:600">${esc(it.batchId)}</span>` +
            `<span class="fs-meta" style="flex:1;min-width:0;color:var(--c-text-sec);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(it.species)}${it.strain ? ' · ' + esc(it.strain) : ''}</span>` +
            `<span class="fs-floor" style="--fs-own:0.875rem;font-weight:600;white-space:nowrap">${it.count} ${bags}</span>` +
            `</div>`
        )
        .join('');
      return (
        '<div style="margin-bottom:14px">' +
        `<div style="display:flex;justify-content:space-between;align-items:baseline;padding:6px 4px;border-bottom:2px solid var(--c-border)"><span class="fs-sm" style="font-weight:700">${esc(g.name)}</span><span class="fs-meta" style="color:var(--c-text-muted)">${g.bags} ${bags}</span></div>` +
        batchRows +
        '</div>'
      );
    })
    .join('');
}
function setWorkListMode(mode) {
  const m = mode === 'batch' ? 'batch' : 'location';
  if (m === _workListMode) return;
  _workListMode = m;
  localStorage.setItem('mp-wl-group', m);
  renderWorkList();
}
function printWorkList() {
  const d = _workListData;
  if (!d) return;
  const rows = _groupWorkList(d.rows, _workListMode)
    .flatMap((g) => g.items)
    .map(
      (it) =>
        `<tr><td class="c">&#9744;</td><td>${esc(it.zoneName)}</td><td class="m">${esc(it.batchId)}</td><td>${esc(it.species)}${it.strain ? ' · ' + esc(it.strain) : ''}</td><td class="n">${it.count}</td></tr>`
    )
    .join('');
  // px-auf-papier: a whole print document of its own, opened in a new window
  // and sent to a printer. Its sizes are physical -- 13px in that table is an
  // eighth of an inch of paper -- and a reader's browser font setting has no
  // business changing how many rows fit on a sheet.
  const html =
    '<!doctype html><html><head><meta charset="utf-8"><title>' +
    esc(d.title) +
    '</title><style>body{font-family:Arial,Helvetica,sans-serif;margin:18px;color:#000}h1{font-size:18px;margin:0 0 2px}.s{color:#555;font-size:12px;margin-bottom:14px}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:7px 8px;border-bottom:1px solid #ccc}th{border-bottom:2px solid #000;font-size:11px;text-transform:uppercase}.c{width:22px;font-size:16px}.m{font-family:monospace}.n{text-align:right;width:56px;font-weight:bold}</style></head><body>' +
    '<h1>' +
    esc(d.title) +
    '</h1><div class="s">' +
    esc(localDateStr(new Date())) +
    ' · ' +
    d.total +
    ' ' +
    esc(t('worklist.batches')) +
    '</div><table><thead><tr><th></th><th>' +
    esc(t('worklist.location')) +
    '</th><th>' +
    esc(t('worklist.batchCol')) +
    '</th><th>' +
    esc(t('batch.species')) +
    '</th><th style="text-align:right">' +
    esc(t('worklist.bags')) +
    '</th></tr></thead><tbody>' +
    rows +
    '</tbody></table><scr' +
    'ipt>window.onload=function(){window.print()}<\/scr' +
    'ipt></body></html>';
  const win = window.open('', '_blank');
  if (!win) {
    toast(t('worklist.popupBlocked'), 'err');
    return;
  }
  win.document.write(html);
  win.document.close();
}
// Warnungen is for problems you can't see anywhere else on the dashboard: low
// stock, lab issues, zones at capacity. It deliberately does NOT restate
// due-today / overdue batches — those are already the "zu verschieben" chip and
// the Chargen-Aufgaben list directly above, and repeating them as two more lines
// said nothing new while burying the alerts that only appear here.
function buildAlertTasks() {
  const out = [];
  const add = (a, rang) => out.push({ ...a, rang });
  // Bestand zuerst. Leer werden dauert länger zu beheben als voll sein: Nachschub
  // muss bestellt oder angesetzt werden, ein voller Raum wird beim nächsten Umzug
  // von selbst leerer. Und die Liste zeigt sechs Zeilen, bevor sie zuklappt.
  for (const a of getInvAlerts()) add({ ...a, goPage: 'inv', goBtn: 'n-inv' }, 0);
  for (const a of getLabAlerts()) add({ ...a, goPage: 'lab', goBtn: 'n-lab' }, 0);
  for (const z of zones) {
    if (!z.maxCapacity) continue;
    let cnt = 0;
    if (z.racks && z.racks.length) z.racks.forEach((r) => (cnt += Object.keys(getRackBags(r.id)).length));
    else cnt = Object.keys(getZoneBags(z.id)).length;
    const pct = Math.round((cnt / z.maxCapacity) * 100);
    if (pct >= 90)
      add(
        {
          text: zoneDisplayName(z.id) + ': ' + cnt + '/' + z.maxCapacity + ' (' + pct + '%)',
          urgent: pct >= 100,
          goPage: 'zones',
          goBtn: 'n-zones'
        },
        1
      );
  }
  return out.sort((a, b) => a.rang - b.rang || Number(b.urgent) - Number(a.urgent));
}
// Split-batch detection: flag batches whose active bags straddle multiple
// production stages ({spawn, incubation, fruiting}). Harvested, removed, and
// contaminated bags are excluded so deliberate placements don't trigger alerts.
// lastByBag is optional: the dashboard has already built this index by the time
// it calls us, and rebuilding it walked the whole scan log a second time.
function getSplitBatches(sharedLastByBag) {
  const STAGE_ORDER = { spawn: 1, incubation: 2, fruiting: 3 };
  const zoneRole = {};
  zones.forEach((z) => (zoneRole[z.id] = z.role));
  const harvestedBags = new Set();
  harvests.forEach((h) => h && h.bag && harvestedBags.add(String(h.bag).toUpperCase()));
  // Reuse the caller's index when it passes one — the dashboard has already built
  // exactly this map by the time it calls us. Only the per-batch last-move time
  // still needs a pass of its own, and that one allocates nothing per entry.
  const own = sharedLastByBag ? null : new Map();
  const lastMoveTimeByBatch = {};
  scanLog.forEach((e) => {
    if (own && e.bag) own.set(String(e.bag).toUpperCase(), e);
    if (e.batch && (e.action === 'ADD' || e.action === 'MOVE' || e.action === 'MOVE_BATCH')) {
      const cur = lastMoveTimeByBatch[e.batch];
      if (!cur || (e.time && e.time > cur)) lastMoveTimeByBatch[e.batch] = e.time;
    }
  });
  const lastByBag = sharedLastByBag || own;
  const now = Date.now();
  const STALE_HOURS = 24;
  const out = [];
  batches.forEach((b) => {
    const bags = b.bags || [];
    if (!bags.length) return;
    const zoneCounts = {};
    const stageCounts = {};
    bags.forEach((bag) => {
      const key = String(bag).toUpperCase();
      if (harvestedBags.has(key)) return;
      const last = lastByBag.get(key);
      if (!last || last.action === 'REMOVE' || !last.to) return;
      const z = toZone(last.to);
      const role = zoneRole[z];
      if (!role || role === 'contaminated') return;
      // Stage comes from the batch, not the room — see stageOf(). Without it,
      // grain sitting in an incubation room read as "incubation" and blocks
      // parked in a spawn room read as "spawn", so batches were reported split
      // across stages when every bag was really at the same one.
      const stage = stageOf(role, b.batchType);
      if (!STAGE_ORDER[stage]) return;
      zoneCounts[z] = (zoneCounts[z] || 0) + 1;
      stageCounts[stage] = (stageCounts[stage] || 0) + 1;
    });
    const stages = Object.keys(stageCounts);
    if (stages.length < 2) return;
    const behindStage = stages.reduce((a, c) => (STAGE_ORDER[c] < STAGE_ORDER[a] ? c : a));
    const behindCount = stageCounts[behindStage];
    const entries = Object.keys(zoneCounts).map((z) => ({
      zone: z,
      role: stageOf(zoneRole[z], b.batchType),
      count: zoneCounts[z],
      behind: stageOf(zoneRole[z], b.batchType) === behindStage
    }));
    entries.sort((a, c) => (STAGE_ORDER[a.role] || 99) - (STAGE_ORDER[c.role] || 99));
    const lastTime = lastMoveTimeByBatch[b.batchId];
    const ageHours = lastTime ? (now - new Date(lastTime).getTime()) / 3600000 : STALE_HOURS + 1;
    const urgent = ageHours > STALE_HOURS;
    out.push({
      batchId: b.batchId,
      species: b.species || '',
      strain: b.strain || b.species || '',
      behindStage,
      behindCount,
      entries,
      // Per stage, not per zone: two incubation rooms are one stage, and the
      // card is about how far along the bags are, not which room they sit in.
      stages: Object.keys(stageCounts)
        .sort((a, c) => (STAGE_ORDER[a] || 99) - (STAGE_ORDER[c] || 99))
        .map((role) => ({ role, count: stageCounts[role], behind: role === behindStage })),
      urgent
    });
  });
  out.sort((a, c) => {
    if (a.urgent !== c.urgent) return a.urgent ? -1 : 1;
    return (c.behindCount || 0) - (a.behindCount || 0);
  });
  return out;
}
// Preferred one-tap fruiting destination: the most recently used fruiting
// zone/rack (matches how it's actually done — nearly always the same tent),
// else the first zone tagged role 'fruiting'. Null if no fruiting zone exists.
function _fruitingDest() {
  const isFruiting = (id) => {
    const z = ZONE_BY_ID[toZone(id)];
    return z && z.role === 'fruiting';
  };
  for (let i = scanLog.length - 1; i >= 0; i--) {
    const e = scanLog[i];
    if ((e.action === 'MOVE' || e.action === 'MOVE_BATCH') && e.to && isFruiting(e.to)) return e.to;
  }
  const first = zones.find((z) => z.role === 'fruiting');
  return first ? first.id : null;
}
// One-tap whole-batch move to fruiting for the dashboard "needs to move" cards.
// Same move semantics as the Verschieben picker (moveBatchTo skips bags that
// are unplaced/removed or already there) — just with the destination preset.
// ── Undo for bag moves ──────────────────────────────────────
// A "Rückgängig" snackbar reverses a move: we snapshot each bag's location
// before it, then move the exact same bags back on undo (a compensating MOVE,
// so the scan history stays honest). Used by every whole-batch move path —
// one-tap → Fruchtung, the per-zone bulk, the Verschieben picker and the
// scan action sheet — so a mistap costs one tap to reverse wherever it happens.
// Bags that were not placed before the move are absent from the snapshot and
// stay where they were put: there is no prior location to restore them to.
let _undoTimer = null;
function showUndoBar(msg, undoCb) {
  const bar = document.getElementById('undo-bar');
  if (!bar) return;
  document.getElementById('undo-msg').textContent = msg;
  const btn = document.getElementById('undo-btn');
  btn.textContent = t('dash.undo');
  btn.style.display = '';
  btn.onclick = () => {
    clearTimeout(_undoTimer);
    hideUndoBar();
    if (undoCb) undoCb();
  };
  bar.onclick = null;
  bar.classList.remove('is-err');
  bar.setAttribute('role', 'status');
  bar.setAttribute('aria-live', 'polite');
  bar.classList.add('show');
  clearTimeout(_undoTimer);
  _undoTimer = setTimeout(hideUndoBar, 8000);
}
// The app's own message line, and the reason it exists twice over. flashUndoBar
// is the receipt half — the bar with no button on it — and `toast` is the same
// bar carrying what used to go through alert() 77 times.
//
// alert() was the wrong control for that in four ways, and the fourth is why it
// could not simply be styled: it is not the app. On a phone it renders as
// `192.168.x.x sagt:` in the OS font, ignoring the theme and the 56px touch
// floor everything else in this app now guarantees. It also blocks — a gloved
// thumb has to find one small OK before anything else can happen — and it
// detaches the message from the field that caused it, so the user dismisses a
// sentence and then has to work out which of nine inputs it meant. This bar
// fixes the first three. The fourth, putting the message under its own field,
// is field-level validation and is not this change.
//
// Errors are read out assertively and sit for six seconds rather than two and a
// half, because a validation message that leaves before the eye reaches it is a
// message nobody got. A tap dismisses either kind.
function toast(msg, kind) {
  const bar = document.getElementById('undo-bar');
  if (!bar) return;
  const err = kind === 'err';
  document.getElementById('undo-msg').textContent = msg;
  document.getElementById('undo-btn').style.display = 'none';
  bar.classList.toggle('is-err', err);
  bar.setAttribute('role', err ? 'alert' : 'status');
  bar.setAttribute('aria-live', err ? 'assertive' : 'polite');
  // Tap anywhere to dismiss. Only on this path: an undo offer that vanished
  // under a stray tap would take the undo with it.
  bar.onclick = hideUndoBar;
  bar.classList.add('show');
  clearTimeout(_undoTimer);
  _undoTimer = setTimeout(hideUndoBar, err ? 6000 : 2500);
}
function flashUndoBar(msg) {
  toast(msg);
}
function hideUndoBar() {
  const bar = document.getElementById('undo-bar');
  if (!bar) return;
  bar.classList.remove('show');
  // Dropped on the way out, not on the way in: the bar fades rather than
  // vanishing, and removing the skin while it is still visible recolours it
  // mid-fade. The next caller sets its own anyway.
  setTimeout(() => {
    if (!bar.classList.contains('show')) bar.classList.remove('is-err');
  }, 250);
}
function _snapshotBeforeMove(batchList, dest, lastByBag) {
  const snap = [];
  const d = dest.toUpperCase();
  batchList.forEach((b) => {
    (b.bags || []).forEach((bag) => {
      const last = lastByBag.get(bag.toUpperCase());
      if (!last || last.action === 'REMOVE' || !last.to) return;
      if (last.to.toUpperCase() === d) return; // already there → won't move
      snap.push({ batch: b, bag, from: last.to });
    });
  });
  return snap;
}
function _undoMove(snap) {
  if (!snap || !snap.length) return;
  const groups = new Map();
  snap.forEach((s) => {
    const key = s.batch.batchId + '|' + s.from;
    if (!groups.has(key)) groups.set(key, { batch: s.batch, dest: s.from, bags: [] });
    groups.get(key).bags.push(s.bag);
  });
  let back = 0;
  // Marked 'undo', so the bags get their old move-in day back in getZoneBags()
  // instead of arriving today.
  groups.forEach((g) => moveBagsTo(g.batch, g.bags, g.dest, (m) => (back += m || 0), { reason: 'undo' }));
  updateSD();
  renderBatches();
  renderStatus();
  renderDashBatchTasks();
  flashUndoBar(t('dash.undone', { n: back }));
}
function moveBatchToFruiting(batchId) {
  const b = batches.find((x) => x.batchId === batchId);
  if (!b) return;
  const dest = fruitingTarget();
  if (!dest) {
    setFb('err', t('dash.noFruitingZone'));
    return;
  }
  const snap = _snapshotBeforeMove([b], dest, buildLastScanByBag());
  moveBatchTo(b, dest, function (moved, skipped) {
    updateSD();
    renderBatches();
    renderStatus();
    renderDashBatchTasks();
    if (!moved) {
      setFb(
        'ok',
        skipped ? t('batch.allAlreadyAt', { n: skipped, loc: zoneDisplayName(dest) }) : t('batch.noBagsToMove')
      );
    } else {
      showUndoBar(b.batchId + ': ' + moved + ' → ' + zoneDisplayName(dest), () => _undoMove(snap));
    }
  });
}
// One-tap action button(s) for a single batch-tasks row.
function dashTaskBtn(tk) {
  const id = esc(tk.batchId);
  if (tk.taskAction === 'inoculate') {
    // Colonised grain: the useful next step is making blocks from it, so go
    // straight to the create dialog rather than offering a move.
    return `<button class="btn btn-sm btn-p fs-xs" data-action="inoculate-from" data-batch="${id}" style="padding:3px 10px;flex-shrink:0">${esc(t('dash.doInoculate'))}</button>`;
  }
  if (tk.taskAction === 'move' && tk.ready) {
    // One-tap \u2192 Fruchtung (the dominant move) as the primary action; the
    // Verschieben picker stays for any other destination. Only offer it for
    // batches actually in incubation \u2014 never for SPAWN RUN, where jumping
    // grain spawn straight to a fruiting tent skips a whole growth stage.
    //
    // tk.ready gates both: a batch 3-7 days out is listed so the week ahead is
    // visible, but offering a one-tap move for it ends incubation early, and the
    // only way back is an 8-second undo snackbar. Not ready \u2014 falls through to
    // the passive "Ansehen" button below, which is what it did before the tabs.
    const canFruit = getStatus(tk.batchId).status === 'INCUBATING' && zones.some((z) => z.role === 'fruiting');
    const toFruiting = canFruit
      ? `<button class="btn btn-sm btn-p fs-xs" data-action="move-to-fruiting" data-batch="${id}" style="padding:3px 10px;flex-shrink:0">\u2192 ${esc(t('dash.toFruiting'))}</button>`
      : '';
    return (
      toFruiting +
      `<button class="btn btn-sm fs-xs" data-action="open-move-modal" data-batch="${id}" style="padding:3px 10px;flex-shrink:0;margin-left:${toFruiting ? '4px' : '0'}">${t('dash.move')}</button>`
    );
  }
  return `<button class="btn btn-sm fs-xs" data-action="go-to-batch" data-batch="${id}" style="padding:3px 10px;flex-shrink:0">${t('dash.view')}</button>`;
}
// The urgency sections of the batch-tasks card, in the order a worker triages:
// what is late, what is due today, what is coming, and what got left behind.
// 'left' is not date-driven — it is bags of a batch still sitting a stage behind
// the rest of it (see getSplitBatches), which is why it sits below the dated
// sections instead of being forced into "today".
const DASH_SECTIONS = ['overdue', 'today', 'week', 'left'];
// Which fruiting tent the one-tap moves target, remembered across reloads.
//
// This used to be _fruitingDest() alone: whichever tent something was last moved
// into. With one tent that is always right. With several it is a hidden global —
// move one batch into tent 3 by hand and every "→ Fruchtung" on the card silently
// retargets to tent 3, with nothing on screen saying so. Worse, a tent that has
// never received a move is unreachable from the card entirely.
//
// So the choice is explicit and visible. _fruitingDest() stays as the seed for a
// user who has never picked one, which keeps the single-tent case zero-effort.
function fruitingZones() {
  return zones.filter((z) => z.role === 'fruiting');
}
let _fruitingTargetKey = null;
function fruitingTarget() {
  const avail = fruitingZones();
  if (!avail.length) return null;
  if (_fruitingTargetKey === null) {
    // Guarded like the read in initDashCollapse(): storage revoked mid-session
    // (site data blocked, partitioned context, a backgrounded PWA evicted by
    // iOS) must not throw inside a render and abort everything queued behind it.
    try {
      _fruitingTargetKey = localStorage.getItem('mp-fruiting-target') || '';
    } catch (e) {
      _fruitingTargetKey = '';
    }
  }
  // The stored tent can have been deleted, renamed to another id, or had its
  // role changed since it was picked — never move bags at a stale target.
  if (_fruitingTargetKey && avail.some((z) => z.id === _fruitingTargetKey)) return _fruitingTargetKey;
  return _fruitingDest();
}
function setFruitingTarget(id) {
  if (!fruitingZones().some((z) => z.id === id)) return;
  _fruitingTargetKey = id;
  try {
    localStorage.setItem('mp-fruiting-target', id);
  } catch (e) {
    /* storage disabled — the choice just won't survive a reload */
  }
  renderDashBatchTasks();
}
// How full a tent is, and whether this many more bags would overflow it.
// max_capacity is optional and unset by default: with no capacity recorded there
// is nothing to warn about, and the (linear) getZoneBags walk is skipped
// entirely. Reuses getZoneBags rather than re-deriving occupancy, so the
// same-zone-move rule it encodes cannot drift out of sync here.
function fruitingFill(zoneId, incoming) {
  const z = zones.find((x) => x.id === zoneId);
  const cap = z && z.maxCapacity ? z.maxCapacity : 0;
  if (!cap) return null;
  const used = Object.keys(getZoneBags(zoneId)).length;
  return { used, cap, incoming: incoming || 0, over: used + (incoming || 0) > cap };
}
// How many rows the open day shows. Six leaves the whole week on one screen
// with a day open; the rest is one tap away rather than pushed off the bottom.
const DASH_DAY_CAP = 6;
// Ab wie vielen gerissenen Labor-Untergrenzen sie zu einer Zeile werden.
//
// Alle Sorten mal vier Typen sind schnell Dutzende Zeilen an einem Tag, und sie
// sortieren vor den Rundgang — einzeln aufgeführt füllten sie die sechs, die
// der Tag zeigt, und schoben den Weg hinter die Klappe. Eine Zeile, die sagt
// wie viele, und sich auf Tippen öffnet, lässt beides stehen.
const LABMIN_GROUP_AT = 3;
let dashLabGroupOpen = false;
const _dashRoomOpen = {};
function toggleDashRoom(key) {
  _dashRoomOpen[key] = !_dashRoomOpen[key];
  renderDashBatchTasks();
}
// Which day of the week strip is open, as an offset from today rather than a
// weekday number — a stored weekday would silently point at a different day
// after midnight, and 0 always means "today" no matter when it is read.
let _dashDayOffset = 0;
let _dashDayOnly = localStorage.getItem('mp-dash-view') === 'day';
function setDashView(nurTag) {
  _dashDayOnly = !!nurTag;
  localStorage.setItem('mp-dash-view', _dashDayOnly ? 'day' : 'week');
  renderDashBatchTasks();
}
function setDashDay(off) {
  const n = Number(off);
  if (!Number.isInteger(n) || n < 0 || n > 6) return;
  _dashDayOffset = n;
  renderDashBatchTasks();
}
// The theme for a weekday, and whether it is the farm's decision or only this
// card's guess from history. Showing "no theme" on a card whose whole purpose
// is to give the week a shape was the worst possible default; a labelled guess
// is honest and useful, and the editor turns it into a decision.
let _rhythmGuess = null;
function themeFor(weekday) {
  const set = themeOf(weekday);
  if (set) return { theme: set, suggested: false };
  if (Object.keys(weekRhythm).length) return { theme: '', suggested: false };
  if (!_rhythmGuess) _rhythmGuess = suggestWeekRhythm();
  return { theme: _rhythmGuess[weekday] || '', suggested: true };
}
// ─── Weekly rhythm ──────────────────────────────────────────
// Must stay in step with WEEK_THEMES in db.js, which rejects anything else.
// Nur, was sich planen UND zaehlen laesst. Muss mit WEEK_THEMES in db.js
// uebereinstimmen, die alles andere ablehnt.
//
// Fruchtung, Ernte und Labor standen hier, weil sie zaehlbar sind. Geplant hat
// sie nie jemand: man zieht um, wenn durchwachsen ist, und erntet, wenn reif
// ist, nicht dienstags. Sechs Eintraege, von denen zwei benutzt werden, machen
// aus einer Auswahl eine Suche.
//
// Alles Uebrige, was regelmaessig zu tun ist, ist jetzt eine eigene Aufgabe mit
// eigenem Namen und eigenem Takt -- der Platz, an dem so etwas wachsen darf,
// ohne dass diese Liste mitwaechst.
const WEEK_THEMES = ['substrate', 'grain', 'free'];
// Weekdays in the order a working week reads, not the order getDay() numbers
// them — Monday first, Sunday last, while the stored keys stay 0 = Sunday.
const WEEK_DAYS = [1, 2, 3, 4, 5, 6, 0];
// Ein Thema, das die Auswahl nicht mehr kennt, darf trotzdem nicht als
// Rohschluessel auf dem Schirm landen. Migration 81 laesst aufgezeichnete Tage
// bewusst stehen, wie sie waren -- eine Aufnahme der Vergangenheit anzufassen,
// weil sich ein Menue geaendert hat, waere die Umkehrung von allem, was hier
// gilt. Also bleiben die drei alten Beschriftungen erhalten, und alles, wofuer
// es gar keine gibt, liest "ohne Thema" statt "rhythm.theme.irgendwas".
const WEEK_THEME_LABELS = [...WEEK_THEMES, 'fruiting', 'harvest', 'lab'];
function weekThemeLabel(theme) {
  if (!theme || !WEEK_THEME_LABELS.includes(theme)) return t('rhythm.theme.none');
  return t('rhythm.theme.' + theme);
}
// Which theme a given weekday carries, or '' if the farm has not said.
// The whole entry for a weekday: {theme, targetQty, strainId, note}. A day the
// farm has not set returns null. Older payloads stored a bare theme string, so
// one is normalised rather than left to blow up on .theme.
function rhythmOf(weekday) {
  const v = weekRhythm[weekday] || weekRhythm[String(weekday)] || null;
  if (!v) return null;
  return typeof v === 'string' ? { theme: v, targetQty: null, strainId: null, note: null } : v;
}
function themeOf(weekday) {
  const r = rhythmOf(weekday);
  return r ? r.theme || '' : '';
}
function _ymd(d) {
  const p = (n) => String(n).padStart(2, '0');
  return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
// The tracked job for a date, if one was planned. Past days and today are
// snapshots taken when they arrived; a future day has none yet, so it is read
// from the template and marked as still only a plan.
function rhythmTaskOn(date) {
  const key = _ymd(date);
  const row = rhythmTasks.find((x) => x.date === key);
  if (row) return row;
  const r = rhythmOf(date.getDay());
  if (!r || r.theme === 'free' || !r.targetQty) return null;
  return {
    date: key,
    weekday: date.getDay(),
    theme: r.theme,
    targetQty: r.targetQty,
    strainId: r.strainId,
    note: r.note,
    doneQty: 0,
    planned: true
  };
}
// Was an einem Tag wirklich entstanden ist, gemessen am Thema des Tages.
//
// Vorher trug der Rhythmus eine eigene Zahl, die nur der "Erfassen"-Knopf
// bewegte. Wer Blöcke ansetzte und nicht daneben eintippte, dass er es getan
// hatte, sah den Tag weiter offen stehen — die Anlage führte zwei
// Buchhaltungen über dieselbe Arbeit, und die zweite war die, die niemand
// pflegt. Dieselbe Entscheidung wie bei den Versorgungslücken und den
// Labor-Untergrenzen: abgeleitet, nicht abgehakt. Es gibt nichts zu tippen und
// nichts zu vergessen.
//
// Ein Thema misst das, was an dem Tag dabei herauskommt: Substrattag zaehlt
// angesetzte Blöcke, Körnertag die Kilogramm Brut, Fruchtungstag die Beutel,
// die in eine Fruchtungszone gezogen sind, Erntetag die erfassten Ernten,
// Labortag die angelegten Kulturen.
// Was an welchem Tag entstanden ist, in EINEM Durchgang je Auffrischung.
//
// rhythmMadeOn() lief vorher je rückständigem Tag und ging dabei jedes Mal
// batches, scanLog, harvests und cultures ganz durch, mit einem Date-Aufbau je
// Element — und rhythmArrears() warf danach die erledigten Tage weg, die den
// Durchgang schon bezahlt hatten. rhythmTasks wächst um eine Zeile je Tag und
// wird nie beschnitten, also wuchs das mit dem Alter der Anlage: gemessen 156 ms
// nach drei Monaten, 2314 ms nach einem Jahr, auf der Startseite, bei jedem
// SSE-Ereignis. Ein Index, gebaut wie _statusByBatch und in refresh() genauso
// verworfen, macht daraus einen Kartenzugriff.
let _rhythmIndex = null;
function _rhythmMadeIndex() {
  if (_rhythmIndex) return _rhythmIndex;
  const idx = new Map();
  const bucket = (wert) => {
    if (!wert) return null;
    const d = new Date(wert);
    if (isNaN(d)) return null;
    const k = _ymd(d);
    let e = idx.get(k);
    if (!e) idx.set(k, (e = { substrate: 0, grain: 0, fruiting: 0, harvest: 0, lab: 0 }));
    return e;
  };
  for (const b of batches || []) {
    const e = bucket(b.created);
    if (!e) continue;
    // Beides zaehlt, was an dem Tag ENTSTANDEN ist, und nicht, was davon heute
    // noch dasteht. _grainKgOf() waere hier falsch: es gibt 0 zurueck, sobald
    // eine Charge archiviert ist, also kam ein erfuellter Koernertag als
    // Rueckstand zurueck, sobald die Glaeser verbraucht waren -- dauerhaft, und
    // ohne Knopf, der ihn noch schliessen koennte.
    //
    // Und in Glaesern, nicht in Kilogramm: das Ziel wird als Anzahl eingegeben
    // ('Wie viele am {date}?', setRhythmTarget verlangt eine ganze Zahl), also
    // muss die Zahl daneben dieselbe Einheit haben. In Kilogramm gemessen war
    // ein Ziel von 20 mit sieben 3-kg-Glaesern erfuellt, und mit 0,5-kg-Glaesern
    // nie.
    if (b.batchType === 'grain') e.grain += b.qty || 0;
    else e.substrate += b.qty || 0;
  }
  for (const s of scanLog || []) {
    if (!s || !s.bag || !s.to) continue;
    if (s.action !== 'MOVE' && s.action !== 'MOVE_BATCH') continue;
    const z = ZONE_BY_ID[toZone(s.to)];
    if (!z || z.role !== 'fruiting') continue;
    const e = bucket(s.time);
    if (e) e.fruiting++;
  }
  for (const h of harvests || []) {
    const e = bucket(h && h.time);
    if (e) e.harvest++;
  }
  for (const c of cultures || []) {
    const e = bucket(c && c.created);
    if (e) e.lab++;
  }
  _rhythmIndex = idx;
  return idx;
}
function rhythmMadeOn(dateKey, theme) {
  const tag = _rhythmMadeIndex().get(dateKey);
  return tag && tag[theme] != null ? tag[theme] : 0;
}
// Ab wann der Rhythmus überhaupt mitzählt.
//
// Ohne das steht jeder Montag vor der Inbetriebnahme als verfehlt da, und an
// einer Woche, die niemand mehr bearbeiten kann, ist nichts nachzuholen.
function rhythmCountsFrom() {
  return (inventory && inventory.bagCountFrom) || null;
}

// --- Eigene Aufgaben der Anlage ---------------------------------------------
//
// Der Rhythmus zaehlt Produktion; er kann nur, was eine Buchung hinterlaesst.
// Das hier ist alles andere, was regelmaessig ansteht und keine hinterlaesst:
// putzen, warten, pruefen. Selbst benannt, selbst getaktet, selbst abgehakt.
//
// Abgehakt und nicht gezaehlt -- als Einziges in dieser Datei. Das ist keine
// Ruecknahme des Grundsatzes, sondern seine Bedingung: gezaehlt wird, wo es
// etwas zu zaehlen gibt, und hinter einem geputzten Raum steht nichts, woraus
// sich das ableiten liesse. Ein Haken ist hier die einzige Quelle, die es gibt.

// Wann eine Aufgabe faellt, rechnet der Server aus: listRecurringTasks() legt
// die Termine des Zeitraums als `due` bei. Hier stand dieselbe Regel ein
// zweites Mal, und ein Test hielt die beiden Fassungen ueber 480 Daten
// gegeneinander -- eine Naht, die nur haelt, solange jemand an sie denkt, und
// die gegen einen Unterschied in dem, was die Zwillinge AUFRUFEN, ohnehin
// blind war. Jetzt gibt es die Regel einmal, und zwar dort, wo sie auch
// beim Abhaken geprueft wird.
function recurringDueOn(task, dateKey) {
  return !!(task && task.due && task.due.indexOf(dateKey) >= 0);
}
function recurringDoneOn(task, dateKey) {
  return !!(task && task.done && task.done.indexOf(dateKey) >= 0);
}
// Was an einem Tag faellt, mit dem Stand des Hakens.
function recurringOn(dateKey) {
  return (recurringTasks || [])
    .filter((x) => x && x.active && recurringDueOn(x, dateKey))
    .map((x) => ({ task: x, done: recurringDoneOn(x, dateKey) }));
}
// Versaeumte Termine -- je Aufgabe hoechstens einer, naemlich der juengste.
//
// Wer zweimal nicht geputzt hat, putzt einmal und ist wieder aktuell. Drei
// offene Putztermine nebeneinander waeren eine Liste, die niemand abarbeiten
// kann und die deshalb keiner mehr liest: genau die Dauerwarnung, gegen die an
// dieser Stelle ueberall geschrieben ist.
//
// Faellt die Aufgabe heute ohnehin an, steht sie schon in der Tagesliste. Den
// versaeumten Termin daneben zu setzen hiesse: zwei Zeilen mit zwei Haken fuer
// einmal Putzen.
//
// Kein Rueckwaertsgang mehr ueber Kalendertage: `due` kommt fertig vom Server
// und reicht so weit zurueck, wie der laengste Takt es verlangt. Der alte
// Gang in festen 864e5 Millisekunden uebersprang im Fruehjahr den Tag der
// Zeitumstellung -- ein an ihm versaeumter Termin war damit unsichtbar.
function recurringArrears(today) {
  const heute = _ymd(today);
  const out = [];
  for (const x of recurringTasks || []) {
    if (!x || !x.active || !x.due || !x.due.length) continue;
    if (x.due.indexOf(heute) >= 0) continue;
    const vergangen = x.due.filter((d) => d < heute);
    if (!vergangen.length) continue;
    const letzter = vergangen[vergangen.length - 1];
    if (!recurringDoneOn(x, letzter)) out.push({ task: x, date: letzter });
  }
  return out;
}
// "jede Woche" / "alle 2 Wochen" -- eine Eins liest sich anders als eine Zahl.
function recurEveryLabel(n) {
  return Number(n) === 1 ? t('recur.everyWeek') : t('recur.everyN', { n: Number(n) || 1 });
}
// Everything asked for before today and not finished. This is the point of
// tracking at all: 10 planned for one day with 8 made means the next day still owes
// 2. Without it the shortfall simply vanished and the same 10 came round again
// the following week as though nothing had been missed.
function rhythmArrears(today) {
  const cut = _ymd(today);
  const ab = rhythmCountsFrom();
  const geplant = rhythmTasks
    .filter((x) => x.date < cut && x.targetQty && (!ab || x.date >= ab))
    .sort((a, b) => (a.date < b.date ? -1 : 1));
  if (!geplant.length) return [];

  // Ein Tag wird von der Arbeit bezahlt, die an ihm ODER SPÄTER getan wurde.
  //
  // Vorher zählte jeder Tag nur, was an genau diesem Datum entstand. Der Plan
  // sagt "10 Substrat" für einen Tag, gemacht wird es später — also stand der
  // Tag auf 10 offen, während in derselben Woche 20 Blöcke entstanden waren,
  // und blieb dort. Genau die Dauerwarnung, gegen die alles hier geschrieben
  // ist, nur eine Ebene höher: erst wurde das Melden abgeschafft, und dann
  // half das Zählen nicht, weil es am falschen Tag suchte.
  //
  // Ein Tag ist eine Vorgabe, kein Termin. Also ein laufendes Konto je Thema:
  // Produktion zahlt die älteste offene Vorgabe zuerst, und was übrig bleibt,
  // steht für die nächste bereit. Wer später in der Woche 20 macht, hat den Tag
  // erledigt, und niemand muss das irgendwo eintragen.
  //
  // Nur vorwärts: Arbeit von vor einer Vorgabe zahlt sie nicht. Sonst hätte
  // eine gute Vorwoche eine schlechte Woche im Voraus entschuldigt.
  const idx = _rhythmMadeIndex();
  const rest = new Map(geplant.map((t) => [t.date, t.targetQty]));
  const themen = {};
  for (const t of geplant) (themen[t.theme] = themen[t.theme] || []).push(t);

  for (const [theme, liste] of Object.entries(themen)) {
    const tage = [...new Set([...liste.map((t) => t.date), ...idx.keys()])]
      .filter((d) => d >= liste[0].date && d < cut)
      .sort();
    let guthaben = 0;
    let i = 0;
    for (const d of tage) {
      guthaben += (idx.get(d) || {})[theme] || 0;
      while (i < liste.length && liste[i].date <= d && guthaben > 0) {
        const offen = rest.get(liste[i].date);
        const zahlung = Math.min(offen, guthaben);
        rest.set(liste[i].date, offen - zahlung);
        guthaben -= zahlung;
        if (rest.get(liste[i].date) <= 0) i++;
      }
    }
  }

  return geplant
    .map((t) => Object.assign({}, t, { doneQty: t.targetQty - rest.get(t.date), outstanding: rest.get(t.date) }))
    .filter((t) => t.outstanding > 0);
}
// The recipe behind the plan, for the day that is actually open. Empty when no
// Sorte is chosen or that Sorte carries no substrate recipe.
function rhythmMixText(weekday) {
  const r = rhythmOf(weekday);
  if (!r || !r.strainId) return '';
  const ms = mushroomStrains.find((m) => m.id === r.strainId);
  if (!ms) return '';
  const bits = [];
  if (ms.recBagKg) bits.push(ms.recBagKg + ' kg');
  if (ms.recHardwoodPct) bits.push('HW ' + ms.recHardwoodPct + '%');
  if (ms.recWheatbranPct) bits.push('WB ' + ms.recWheatbranPct + '%');
  // recGypsum is a flag ("add gypsum"), not a percentage — the app labels it
  // "Gips (~1%)" everywhere else, so printing the flag as a number would invent
  // a figure the recipe never held.
  if (ms.recGypsum) bits.push(t('inv.gypsum'));
  return bits.join(' · ');
}
// Propose a rhythm from what the farm already does, so the editor opens on a
// draft to correct rather than seven empty rows to invent.
//
// Picking the busiest activity per day would just print "substrate" on every
// weekday — blocks far outnumber grain runs, so they win everywhere. What
// makes a day *characteristic* is the share of an activity that lands on it:
// most grain runs can fall on one weekday even though grain is rare. So
// each day takes the activity most concentrated on it, and a day that holds no
// meaningful share of anything is offered as free rather than mislabelled.
function suggestWeekRhythm() {
  // Nur Themen, die WEEK_THEMES auch kennt.
  //
  // 'fruiting' stand hier noch, nachdem es aus der Auswahl geflogen war. Zwei
  // Folgen, beide still: der Wochenkopf zeichnete den Rohschluessel
  // "rhythm.theme.fruiting", weil t() bei fehlender Uebersetzung den Schluessel
  // selbst zurueckgibt -- und im Editor fand das <select> keine passende
  // Option, waehlte die erste ('substrate'), und _rhythmSyncFromForm() las beim
  // Speichern genau diese zurueck. Wer den Vorschlag ungeprueft bestaetigte,
  // speicherte einen Substrattag, ohne etwas geaendert zu haben.
  const KINDS = ['substrate', 'grain'];
  const byDay = {};
  const totals = { substrate: 0, grain: 0 };
  for (let d = 0; d < 7; d++) byDay[d] = { substrate: 0, grain: 0 };
  const typeOf = {};
  for (const b of batches) typeOf[b.batchId] = b.batchType;
  for (const e of scanLog) {
    if (!e || !e.time) continue;
    const dt = new Date(e.time);
    if (isNaN(dt)) continue;
    const d = dt.getDay();
    let kind = null;
    if (e.action === 'ADD') kind = typeOf[e.batch] === 'grain' ? 'grain' : 'substrate';
    if (!kind) continue;
    byDay[d][kind]++;
    totals[kind]++;
  }
  // Share alone over-rewards rare work. Say moves into fruiting number 100 against
  // 1000 block builds: a day holding 30 moves scores a higher share
  // (0.30) than the 250 blocks built the same day (0.25) — and that day would be
  // labelled a fruiting day when most of what happens on it is substrate.
  //
  // So share only decides when it decides clearly. A kind must lead the
  // runner-up by half again to name the day; otherwise the day is named by what
  // there is simply most of. That still names a grain day where grain is
  // genuinely concentrated (say 0.5 against 0.2), without letting a thin margin
  // rename a day around a handful of events.
  const MARGIN = 1.5;
  // A day holding almost nothing all year is offered as free rather than being
  // given a theme on the strength of a handful of entries.
  const QUIET = 0.1;
  const dayTotal = (d) => KINDS.reduce((n, k) => n + byDay[d][k], 0);
  let busiest = 0;
  for (let d = 0; d < 7; d++) busiest = Math.max(busiest, dayTotal(d));

  const out = {};
  for (let d = 0; d < 7; d++) {
    if (!busiest || dayTotal(d) < QUIET * busiest) {
      out[d] = 'free';
      continue;
    }
    const shares = KINDS.map((k) => ({ k, share: totals[k] ? byDay[d][k] / totals[k] : 0, n: byDay[d][k] })).sort(
      (a, b) => b.share - a.share
    );
    const runnerUp = shares[1] ? shares[1].share : 0;
    if (shares[0].share > 0 && shares[0].share >= runnerUp * MARGIN) out[d] = shares[0].k;
    else out[d] = KINDS.reduce((best, k) => (byDay[d][k] > byDay[d][best] ? k : best), KINDS[0]);
  }
  return out;
}
// The next seven days, starting today, each with the work that lands on it.
//
// A rolling window rather than Monday-to-Sunday: a calendar week spends part of
// itself in the past, and the first question this card exists to answer is what
// to do now. Every weekday still appears exactly once, so the rhythm is just as
// visible — today is simply the one at the top.
//
// Overdue work folds onto today. It is not Tuesday's job any more; it is this
// morning's, and leaving it on the day it was missed would show an empty today
// beside a backlog nobody is looking at.

// A Sorte with nothing coming is work, and it was only ever visible on the
// Chargen tab. Somebody reading the day's list had no way to learn that a
// Sorte had run out — the list only ever knew about batches that already exist,
// so the one thing missing from the farm was the one thing it could not say.
//
// These rows are DERIVED, like the move and harvest rows beside them, and that
// is what makes "until it is actually made" work without any bookkeeping: make
// the grain and the Sorte stops being 'now'; make the blocks and it stops being
// 'nospawn'. There is no done flag to set, and none to forget.
//
// Only Sorten in this season's programme (v78) qualify. Without that, a Sorte
// nobody is growing this half of the year has no grain and nothing incubating
// by definition, so it would report the same gap every day forever — and a
// warning that is always on drags the real ones down with it.
function buildSupplyTasks() {
  const out = [];
  for (const r of sorteRollup()) {
    if (r.supply !== 'now' && r.supply !== 'nospawn') continue;
    // The rollup already resolved this against the programme, by key. Rebuilding
    // the same judgement from names here is what made this function return an
    // empty list on real data: a block batch's name carries a " (KZ)" suffix the
    // strain row does not.
    //
    // A Sorte with no Pilzsorten row is left out rather than assumed in. The
    // tile may show it — an observation can err towards telling you something —
    // but a task is an instruction, and "nothing said otherwise" is not enough
    // to issue one.
    if (!r.imProgramm || !r.known) continue;
    out.push({
      key: r.key,
      name: r.name,
      supply: r.supply,
      // What to make, in the order the chain runs: no grain means grain first.
      taskAction: r.grain === 0 ? 'make-grain' : 'make-blocks'
      // Ohne Begruendung. Hier stand "keine Koerner, nichts in der Inkubation --
      // nach der Fruchtung (12) ist Schluss": eine Herleitung dessen, was der
      // Knopf daneben ohnehin verlangt, und in der Zeile, die man im Vorbeigehen
      // liest, die laengste Zeile von allen. Wer die Sorte kennt, weiss das; und
      // dass es eilt, sagt der rote Streifen, den supply === 'now' schon setzt.
    });
  }
  // Worst first, and stable, so the row does not jump about between renders.
  return out.sort((a, b) => SUPPLY_RANK[a.supply] - SUPPLY_RANK[b.supply] || a.name.localeCompare(b.name));
}

// Laborarbeit, die ansteht: je Art, was ihr fehlt.
//
// Dieselben Zahlen wie Nachproduzieren auf der Laborseite (db.labStock), nicht
// eine zweite Rechnung aus den Kulturen hier: zwei Rechnungen über dasselbe
// Regal sind genau das, was Kacheln und Verkaufsbestand auseinanderlaufen
// ließ. Eine Zeile je Art, denn aus einer Quelle setzt man alles an, was ihr
// fehlt (lbAnsetzen). Eine Art außer Programm fehlt nirgends, nur eine offene
// Bestellung zählt noch; das entscheidet der Server.
//
// Leer, bis die Zahlen da sind: loadLabStock() zeichnet die Tagesliste neu,
// sobald sie ankommen.
function buildLabMinTasks() {
  return _lbSpecies()
    .map((sp) => ({ sp, fehlt: _lbFehlt(sp) }))
    .filter((x) => x.fehlt.length)
    .map(({ sp, fehlt }) => ({
      strainId: sp.strainId,
      key: 'id:' + sp.strainId,
      name: sp.name,
      kz: sp.kuerzel,
      // "empty" heißt in der Tagesliste "überfällig": nur eine Bestellung, die
      // nicht rausgehen kann. Alles andere ist knapp, auch bei null.
      empty: _lbDringend(sp),
      detail: fehlt.map(_lbChip).join(' · '),
      gaps: fehlt.length
    }))
    .sort((a, b) => Number(b.empty) - Number(a.empty) || b.gaps - a.gaps || a.name.localeCompare(b.name));
}

function buildWeekPlan() {
  const midnight = new Date();
  midnight.setHours(0, 0, 0, 0);
  const lastByBag = buildLastScanByBag();
  const days = [];
  for (let off = 0; off < 7; off++) {
    // Auch hier addDays(): mit festen 864e5 fielen im Oktober zwei Spalten auf
    // dasselbe Datum (25.10. zweimal) und der Montag darauf verschwand. Seit
    // die eigenen Aufgaben an _ymd(days[i].date) haengen, ist der Schluessel
    // tragend: zwei Spalten zeichneten denselben Haken.
    const date = addDays(midnight, off);
    days.push({ offset: off, date, weekday: date.getDay(), theme: themeOf(date.getDay()), items: [] });
  }
  const put = (off, item) => days[Math.max(0, Math.min(6, off))].items.push(item);
  const biggestZone = (counts) => {
    let top = '';
    for (const z in counts) if (!top || counts[z] > counts[top]) top = z;
    return top;
  };

  // Ein niedriger Bestand ist Arbeit: er sagt "bestell nach" oder "setz an",
  // genau wie eine Versorgungslücke. Als eigene Karte stand er neben der Liste,
  // die alles andere zu tun hatte — das einzige auf dem Schirm, das zweimal
  // gelesen werden musste, und beim Aufräumen der Nachbarkarten hatte es sich
  // auch noch die ganze Zeilenbreite geerbt.
  for (const a of buildAlertTasks()) {
    put(0, {
      kind: 'alert',
      species: '',
      label: a.text,
      detail: '',
      bags: 0,
      zone: '',
      overdue: !!a.urgent,
      ready: true,
      task: a
    });
  }
  // Eigene Aufgaben: was an dem Tag faellt, samt Stand des Hakens.
  for (let i = 0; i < days.length; i++) {
    const key = _ymd(days[i].date);
    for (const f of recurringOn(key)) {
      put(i, {
        kind: 'chore',
        species: '',
        label: f.task.name,
        detail: recurEveryLabel(f.task.everyWeeks),
        bags: 0,
        zone: '',
        overdue: false,
        ready: true,
        done: f.done,
        task: { id: f.task.id, date: key, done: f.done }
      });
    }
  }
  // Und der eine versaeumte Termin je Aufgabe, auf heute gezogen und mit seinem
  // eigenen Datum beschriftet -- so wie ein Rueckstand aus dem Rhythmus auch.
  for (const r of recurringArrears(midnight)) {
    put(0, {
      kind: 'chore',
      species: '',
      label: r.task.name,
      detail: t('recur.missedFrom', { date: fmtDt(r.date) }),
      bags: 0,
      zone: '',
      overdue: true,
      ready: true,
      done: false,
      task: { id: r.task.id, date: r.date, done: false }
    });
  }
  const laborMin = buildLabMinTasks();
  const gebuendelt = laborMin.length > LABMIN_GROUP_AT;
  if (gebuendelt) {
    put(0, {
      kind: 'labgroup',
      species: '',
      label: t('todo.labGroup', { n: laborMin.length }),
      // Die ersten Arten mit Namen, der Rest steht in den Zeilen darunter,
      // sobald man aufklappt.
      detail:
        laborMin
          .slice(0, 3)
          .map((l) => l.name)
          .join(', ') + (laborMin.length > 3 ? ' …' : ''),
      bags: 0,
      zone: '',
      overdue: laborMin.some((l) => l.empty),
      ready: true,
      // Wie viele Zeilen diese eine vertritt, sagt PLAN_KINDS.labgroup.weight --
      // ausgerechnet aus task.rows, die ohnehin hier stehen.
      task: { rows: laborMin }
    });
  }
  for (const l of gebuendelt && !dashLabGroupOpen ? [] : laborMin) {
    // Today, like the supply rows and for the same reason: a floor breached is
    // not a dated job, it is a state, and it is only ever "still under".
    put(0, {
      kind: 'labmin',
      species: l.name,
      label: l.name,
      detail: l.detail,
      bags: 0,
      zone: '',
      overdue: l.empty,
      ready: true,
      task: l
    });
  }
  for (const s of buildSupplyTasks()) {
    // Always today. This is not a dated job — it is a gap, and a gap is only
    // ever "still open". It disappears the moment the batch exists, because it
    // is derived from the same counts the Sorten card reads; there is nothing
    // to tick off and nothing that can be forgotten in a done-list.
    put(0, {
      kind: 'supply',
      species: s.name,
      label: s.name,
      detail: s.detail,
      bags: 0,
      zone: '',
      overdue: s.supply === 'now',
      ready: true,
      task: s
    });
  }
  for (const tk of buildAutoTasks()) {
    const place = _dashTaskPlace(tk.batchId, lastByBag);
    put(Math.max(0, tk.dueIn), {
      kind: tk.taskAction === 'inoculate' ? 'grain' : 'fruiting',
      batchId: tk.batchId,
      species: tk.species,
      label: tk.batchId,
      detail: tk.detail,
      bags: place.bags,
      zone: biggestZone(place.counts),
      overdue: tk.dueIn < 0,
      ready: tk.ready,
      // Carry the task itself so the row reuses dashTaskBtn, which already
      // encodes every guard: never offer a tent to grain spawn, never offer a
      // one-tap move to a batch that is not due yet, fall back to "Ansehen".
      task: tk
    });
  }
  for (const h of buildHarvestTasks()) {
    // remaining > 0 means it is still filling out; anything at or past its target
    // is work for today. No target at all means judge it by eye, so it is listed.
    const off = h.remaining == null ? 0 : Math.max(0, h.remaining);
    if (off > 6) continue;
    put(off, {
      kind: 'harvest',
      batchId: h.batchId,
      species: h.species,
      label: h.batchId,
      detail: h.target ? t('todo.dueIn', { n: Math.max(0, h.remaining) }) : '',
      bags: h.bagsInTent,
      zone: (h.zoneIds && h.zoneIds[0]) || '',
      overdue: h.target ? h.remaining < 0 : false,
      ready: true,
      task: h
    });
  }
  // Bags a split left a stage behind: not dated, but they are not going to fix
  // themselves either, so they belong in today's work rather than nowhere.
  for (const r of _dashLeftBehindRows(getSplitBatches(lastByBag))) {
    put(0, {
      kind: 'left',
      batchId: r.batchId,
      species: r.species,
      label: r.batchId,
      // These rows carry an instruction ("still in incubation") rather than a
      // due date, and offer no one-tap move — the split has to be looked at.
      detail: r.instruction || r.detail || '',
      bags: r.bags || 0,
      zone: '',
      overdue: false,
      ready: false,
      task: null
    });
  }
  for (const mt of manualTasks) {
    if (!mt || mt.done || !mt.dueDate) continue;
    const due = new Date(mt.dueDate);
    if (isNaN(due)) continue;
    due.setHours(0, 0, 0, 0);
    const off = Math.round((due - midnight) / 864e5);
    if (off > 6) continue;
    put(Math.max(0, off), {
      kind: 'manual',
      taskId: mt.id,
      label: mt.text || '',
      detail: '',
      bags: 0,
      zone: '',
      overdue: off < 0,
      ready: true,
      task: mt
    });
  }
  return days;
}
// How much of today is already behind you. Generated work vanishes as it is
// done — a moved batch simply stops being a task — so a bare "N left" can only
// ever shrink and never reads as progress. Counting what was actually recorded
// today gives the denominator something to move against.
function todayProgress(todayItems) {
  const midnight = new Date();
  midnight.setHours(0, 0, 0, 0);
  let done = 0;
  for (const e of scanLog) {
    if (!e || !e.time) continue;
    const dt = new Date(e.time);
    if (isNaN(dt) || dt < midnight) continue;
    if (e.action === 'ADD') done++;
    else if ((e.action === 'MOVE' || e.action === 'MOVE_BATCH') && e.to) {
      const z = ZONE_BY_ID[toZone(e.to)];
      if (z && z.role === 'fruiting') done++;
    }
  }
  for (const h of harvests) {
    if (!h || !h.time) continue;
    const dt = new Date(h.time);
    if (!isNaN(dt) && dt >= midnight) done++;
  }
  for (const mt of manualTasks) if (mt && mt.done && mt.dueDate) done++;
  // Eine abgehakte eigene Aufgabe ist erledigte Arbeit wie jede andere.
  for (const it of todayItems) if (it && it.done) done++;
  // Supply and lab-floor rows are derived from stock levels: they clear when
  // the stock changes, never by being ticked, and counting them in the
  // denominator made a finished day read 5/8. They are work, but they are not
  // work this bar can measure.
  // Abgeleitete Zeilen zählen nicht mit — welche das sind, sagt PLAN_KINDS.
  //
  // Und Erledigtes auch nicht. total ist done + countable, also zaehlte eine
  // abgehakte Aufgabe zweimal: einmal oben im Zaehler und einmal hier im
  // Nenner. Ein fertiger Tag las 4/5 und konnte nie 100% erreichen -- derselbe
  // Fehler wie bei den abgeleiteten Zeilen, nur von der anderen Seite.
  //
  // Genau das tut die Liste fuer manuelle Aufgaben schon an der Quelle
  // (`if (!mt || mt.done || !mt.dueDate) continue`); die eigenen Aufgaben
  // muessen in der Liste bleiben, weil man den Haken auch zuruecknehmen darf.
  const countable = todayItems.filter((it) => {
    if (it && it.done) return false;
    const k = planKind(it);
    return k ? k.counts : true;
  });
  return { done, total: done + countable.length };
}
// The editor works on a draft rather than straight off the DOM. Changing a
// theme has to redraw the row — a free day carries no target — and redrawing
// off the DOM would discard whatever else was half-typed. The draft holds the
// answer; the form is only ever a view of it.
let _rhythmDraft = null;
function _rhythmSyncFromForm() {
  const rows = document.getElementById('rhythm-rows');
  if (!rows || !_rhythmDraft) return;
  rows.querySelectorAll('select[data-rhythm-day]').forEach((s) => {
    const d = s.getAttribute('data-rhythm-day');
    const e = (_rhythmDraft[d] = _rhythmDraft[d] || {});
    e.theme = s.value;
  });
  rows.querySelectorAll('[data-rhythm-qty]').forEach((i) => {
    const e = _rhythmDraft[i.getAttribute('data-rhythm-qty')];
    if (e) e.targetQty = i.value === '' ? null : Number(i.value);
  });
  rows.querySelectorAll('[data-rhythm-strain]').forEach((i) => {
    const e = _rhythmDraft[i.getAttribute('data-rhythm-strain')];
    if (e) e.strainId = i.value === '' ? null : Number(i.value);
  });
  rows.querySelectorAll('[data-rhythm-note]').forEach((i) => {
    const e = _rhythmDraft[i.getAttribute('data-rhythm-note')];
    if (e) e.note = i.value.trim() || null;
  });
}
function _renderRhythmRows() {
  const rows = document.getElementById('rhythm-rows');
  if (!rows || !_rhythmDraft) return;
  const seit = document.getElementById('rhythm-start-now');
  if (seit) seit.textContent = inventory && inventory.bagCountFrom ? fmtDt(inventory.bagCountFrom) : t('rhythm.startNone');
  // '' is a real choice: a day can have a target without naming a Sorte.
  const strainOpts = (sel) =>
    '<option value="">—</option>' +
    [...mushroomStrains]
      .sort((a, b) => String(a.name).localeCompare(String(b.name)))
      .map((m) => '<option value="' + m.id + '"' + (m.id === sel ? ' selected' : '') + '>' + esc(m.name) + '</option>')
      .join('');
  rows.innerHTML = WEEK_DAYS.map((d) => {
    const cur = _rhythmDraft[d] || {};
    const sel = cur.theme || 'free';
    const opts = WEEK_THEMES.map(
      (k) => '<option value="' + esc(k) + '"' + (k === sel ? ' selected' : '') + '>' + esc(weekThemeLabel(k)) + '</option>'
    ).join('');
    // A free day takes no target, so it is not asked for one.
    const detail =
      sel === 'free'
        ? ''
        : '<div style="display:flex;gap:6px;margin:0 0 5px 46px">' +
          '<input type="number" min="0" step="1" data-rhythm-qty="' +
          d +
          '" value="' +
          esc(cur.targetQty == null ? '' : String(cur.targetQty)) +
          '" placeholder="' +
          esc(t('rhythm.qtyPh')) +
          '" style="width:70px;padding:4px" />' +
          '<select data-rhythm-strain="' +
          d +
          '" style="flex:1;min-width:0;padding:4px">' +
          strainOpts(cur.strainId || 0) +
          '</select></div>' +
          '<div style="margin:0 0 9px 46px"><input type="text" maxlength="200" data-rhythm-note="' +
          d +
          '" value="' +
          esc(cur.note || '') +
          '" placeholder="' +
          esc(t('rhythm.notePh')) +
          '" style="width:100%;padding:4px" /></div>';
    return (
      '<label style="display:flex;align-items:center;gap:8px;margin-bottom:4px">' +
      '<span class="fs-sm" style="width:38px;flex-shrink:0">' +
      esc(t('rhythm.day.' + d)) +
      '</span><select data-rhythm-day="' +
      d +
      '" style="flex:1;padding:5px">' +
      opts +
      '</select></label>' +
      detail
    );
  }).join('');
  rows.querySelectorAll('select[data-rhythm-day]').forEach((s) => {
    s.addEventListener('change', () => {
      _rhythmSyncFromForm();
      _renderRhythmRows();
    });
  });
  // Die Summe rechnet beim Tippen mit, aber die Zeilen werden dabei NICHT neu
  // gebaut: ein Neuaufbau mitten in der Eingabe nimmt dem Feld den Fokus und
  // setzt den Cursor zurück, und man tippt "70" als "0".
  rows.querySelectorAll('[data-rhythm-qty]').forEach((i) => {
    i.addEventListener('input', () => {
      _rhythmSyncFromForm();
      _renderRhythmTotal();
    });
  });
  _renderRhythmTotal();
}
// Was die Woche verlangt, je Thema.
//
// Getrennt und nicht als eine Zahl: Substrat sind Blöcke, Körnerbrut sind
// Gläser, Ernte ist Kilogramm. "100 diese Woche" über drei Einheiten summiert
// wäre eine Zahl, die nichts misst — und genau die Frage, die hier gestellt
// wird ("50 Substrat die Woche"), stellt sich ohnehin je Thema.
function _rhythmWeekTotals() {
  const sums = {};
  for (const d of WEEK_DAYS) {
    const e = (_rhythmDraft && _rhythmDraft[d]) || {};
    const th = e.theme || 'free';
    // NaN aus einem halb getippten Feld ist falsy und fällt hier heraus, statt
    // die Summe auf NaN zu ziehen.
    if (th === 'free' || !e.targetQty) continue;
    sums[th] = (sums[th] || 0) + e.targetQty;
  }
  return sums;
}
function _renderRhythmTotal() {
  const el = document.getElementById('rhythm-total');
  if (!el) return;
  const sums = _rhythmWeekTotals();
  const teile = WEEK_THEMES.filter((k) => sums[k]).map((k) => sums[k] + ' ' + weekThemeLabel(k));
  el.innerHTML =
    '<div class="fs-sm rhythm-total-sum">' +
    esc(t('rhythm.weekTotal')) +
    ' ' +
    esc(teile.length ? teile.join(' · ') : t('rhythm.weekTotalNone')) +
    '</div>' +
    '<div class="fs-xs rhythm-total-hint">' +
    esc(t('rhythm.weekTotalHint')) +
    '</div>';
}
// Opens on the saved week, or — the first time, when nothing has been saved —
// on the one derived from the farm's own history, so the first interaction is
// correcting a draft rather than filling in seven blanks. A suggestion can only
// guess the theme; the target and the Sorte are the farm's call.
function openRhythmEditor() {
  const modal = document.getElementById('m-rhythm');
  const hint = document.getElementById('rhythm-hint');
  if (!modal) return;
  const saved = Object.keys(weekRhythm).length > 0;
  const guess = saved ? null : suggestWeekRhythm();
  _rhythmDraft = {};
  for (const d of WEEK_DAYS) {
    const cur = rhythmOf(d);
    _rhythmDraft[d] = saved
      ? { theme: (cur && cur.theme) || 'free', targetQty: (cur && cur.targetQty) || null, strainId: (cur && cur.strainId) || null, note: (cur && cur.note) || null }
      : { theme: guess[d] || 'free', targetQty: null, strainId: null, note: null };
  }
  if (hint) hint.textContent = saved ? '' : t('rhythm.suggested');
  _renderRhythmRows();
  _ownEdit = null;
  _renderRhythmOwn();
  // .modal-bg is display:none and only .modal-bg.open is display:flex, so
  // clearing the hidden attribute alone leaves the dialog invisible.
  modal.hidden = false;
  modal.classList.add('open');
}
// Die eigenen Aufgaben, verwaltet an derselben Stelle wie der Rhythmus: beides
// beantwortet dieselbe Frage, naemlich was diese Anlage regelmaessig tut.
//
// Nur fuer Admins sichtbar. Eine wiederkehrende Aufgabe anzulegen ist eine
// Festlegung fuer alle und fortan -- dieselbe Grenze, die der Server zwischen
// /api/recurring-task (Admin) und /api/recurring-done (jeder) zieht. Ein Feld
// anzubieten, dessen Speichern mit 403 zurueckkommt, waere schlimmer als keins.
const RECUR_EVERY_CHOICES = [1, 2, 3, 4, 6, 8, 12];
// Welche Aufgabe gerade bearbeitet wird, oder null.
let _ownEdit = null;
// Die drei Felder einer Aufgabe, einmal geschrieben: die Zeile zum Anlegen und
// die zum Bearbeiten sind dieselben drei Fragen.
function _ownFieldsHtml(suffix, wert) {
  const w = wert || {};
  const wd = w.anchor ? new Date(w.anchor + 'T00:00:00').getDay() : new Date().getDay();
  return (
    '<input type="text" id="own-name' +
    suffix +
    '" maxlength="80" value="' +
    esc(w.name || '') +
    '" placeholder="' +
    esc(t('recur.namePh')) +
    '" /><select id="own-every' +
    suffix +
    '">' +
    RECUR_EVERY_CHOICES.map(
      (n) => '<option value="' + n + '"' + (Number(w.everyWeeks) === n ? ' selected' : '') + '>' + esc(recurEveryLabel(n)) + '</option>'
    ).join('') +
    '</select><select id="own-day' +
    suffix +
    '">' +
    WEEK_DAYS.map(
      (d) => '<option value="' + d + '"' + (d === wd ? ' selected' : '') + '>' + esc(t('rhythm.day.' + d)) + '</option>'
    ).join('') +
    '</select>'
  );
}
function _renderRhythmOwn() {
  const el = document.getElementById('rhythm-own');
  if (!el) return;
  const admin = !!(currentUser && currentUser.role === 'admin');
  el.hidden = !admin;
  if (!admin) return;
  const zeilen = (recurringTasks || [])
    .map((x) => {
      // In Bearbeitung: dieselben drei Felder wie beim Anlegen, plus Speichern
      // und Abbrechen. Vorher gab es das gar nicht -- ein Vertipper im Namen
      // oder der falsche Wochentag liessen sich nur durch Loeschen und
      // Neuanlegen beheben, und Loeschen nimmt die ganze Haken-Historie mit.
      if (_ownEdit === x.id) {
        return (
          '<div class="rhythm-own-edit" data-id="' +
          esc(String(x.id)) +
          '">' +
          _ownFieldsHtml('-edit', x) +
          '<button type="button" class="btn btn-sm btn-p" data-action="own-save" data-id="' +
          esc(String(x.id)) +
          '">' +
          esc(t('common.save')) +
          '</button><button type="button" class="btn btn-sm" data-action="own-cancel">' +
          esc(t('common.cancel')) +
          '</button></div>'
        );
      }
      return (
        '<div class="rhythm-own-row' +
        (x.active ? '' : ' ruht') +
        '"><span class="rhythm-own-name">' +
        esc(x.name) +
        '</span><span class="fs-xs rhythm-own-when">' +
        esc(
          recurEveryLabel(x.everyWeeks) +
            ' · ' +
            t('rhythm.day.' + new Date(x.anchor + 'T00:00:00').getDay()) +
            (x.active ? '' : ' · ' + t('recur.paused'))
        ) +
        '</span><button type="button" class="btn btn-sm fs-xs" data-action="own-edit" data-id="' +
        esc(String(x.id)) +
        '">' +
        esc(t('recur.edit')) +
        '</button><button type="button" class="btn btn-sm fs-xs" data-action="own-pause" data-id="' +
        esc(String(x.id)) +
        '">' +
        esc(t(x.active ? 'recur.pause' : 'recur.resume')) +
        '</button><button type="button" class="btn btn-sm fs-xs" data-action="own-del" data-id="' +
        esc(String(x.id)) +
        '">' +
        esc(t('recur.remove')) +
        '</button></div>'
      );
    })
    .join('');
  el.innerHTML =
    '<div class="fs-sm rhythm-own-head">' +
    esc(t('recur.title')) +
    '</div><div class="fs-xs rhythm-own-hint">' +
    esc(t('recur.hint')) +
    '</div>' +
    (zeilen || '<div class="fs-xs rhythm-own-hint">' + esc(t('recur.none')) + '</div>') +
    '<div class="rhythm-own-add">' +
    _ownFieldsHtml('', null) +
    '<button type="button" class="btn btn-sm btn-p" data-action="own-add">' +
    esc(t('recur.add')) +
    '</button></div>';
}
// Der Anker zu einem gewaehlten Wochentag: der naechste solche Tag, heute
// eingeschlossen. Damit ist er zugleich Wochentag und Phase des Takts -- und
// eine heute angelegte Aufgabe hat nicht rueckwirkend gefehlt.
//
// addDays() und nicht plus 864e5: mit festen Millisekunden landete eine am
// Samstag vor der Umstellung angelegte Montagsaufgabe auf einem Sonntag.
function _ownAnchorFor(weekday) {
  const heute = new Date();
  heute.setHours(0, 0, 0, 0);
  return _ymd(addDays(heute, (Number(weekday) - heute.getDay() + 7) % 7));
}
// Die drei Felder auslesen. suffix trennt die Zeile zum Anlegen von der zum
// Bearbeiten, die gleichzeitig im DOM stehen koennen.
function _ownRead(suffix) {
  const feld = (id) => document.getElementById(id + suffix);
  const name = ((feld('own-name') || {}).value || '').trim();
  const every = Number((feld('own-every') || {}).value) || 1;
  const wd = Number((feld('own-day') || {}).value);
  return { name, everyWeeks: every, anchor: _ownAnchorFor(wd) };
}
function _ownSpeichern(daten, fertig) {
  apiPost('/api/recurring-task', daten).then((res) => {
    if (!res || res.error) {
      toast((res && res.error) || t('recur.saveFailed'), 'err');
      return;
    }
    fertig(res);
    _renderRhythmOwn();
    renderDashBatchTasks();
  });
}
function addOwnTask() {
  const daten = _ownRead('');
  if (!daten.name) {
    setFb('err', t('recur.needName'));
    return;
  }
  _ownSpeichern(daten, (res) => {
    recurringTasks.push(Object.assign({ id: res.id, active: true, done: [], due: [] }, daten));
    setFb('ok', t('recur.added', { name: daten.name }));
  });
}
function saveOwnTask(id) {
  const daten = _ownRead('-edit');
  if (!daten.name) {
    setFb('err', t('recur.needName'));
    return;
  }
  const alt = (recurringTasks || []).find((x) => String(x.id) === String(id));
  _ownSpeichern(Object.assign({ id: Number(id), active: alt ? alt.active : true }, daten), () => {
    if (alt) Object.assign(alt, daten);
    _ownEdit = null;
    setFb('ok', t('recur.saved'));
  });
}
// Stilllegen statt loeschen: die Aufgabe verschwindet aus dem Plan, ihre
// Termine und Haken bleiben. Der uebliche Fall ist "gerade nicht", nicht "nie
// wieder", und nur fuer den zweiten gibt es Loeschen.
function pauseOwnTask(id) {
  const x = (recurringTasks || []).find((y) => String(y.id) === String(id));
  if (!x) return;
  _ownSpeichern({ id: Number(id), name: x.name, everyWeeks: x.everyWeeks, anchor: x.anchor, active: !x.active }, () => {
    x.active = !x.active;
  });
}
async function delOwnTask(id) {
  const x = (recurringTasks || []).find((y) => String(y.id) === String(id));
  const n = x && x.done ? x.done.length : 0;
  // Nachfragen, weil es die Haken mitnimmt und nichts es zurueckholt. Beim
  // Abhaken selbst ist eine Rueckfrage teurer als der Fehler -- ein zweiter
  // Tipp nimmt sie zurueck. Hier gibt es keinen zweiten Tipp.
  const frage = n ? t('recur.removeWithTicks', { name: (x && x.name) || '', n }) : t('recur.removeAsk', { name: (x && x.name) || '' });
  if (!(await askConfirm(t('recur.remove'), frage, t('recur.remove')))) return;
  apiDelete('/api/recurring-task/' + encodeURIComponent(id)).then((res) => {
    if (res && res.error) {
      toast(res.error, 'err');
      return;
    }
    const i = (recurringTasks || []).findIndex((y) => String(y.id) === String(id));
    if (i >= 0) recurringTasks.splice(i, 1);
    if (_ownEdit === Number(id)) _ownEdit = null;
    _renderRhythmOwn();
    renderDashBatchTasks();
  });
}

// Ein Tipp hakt ab, der naechste nimmt es zurueck. Der Server laesst den Haken
// nur auf einem Datum zu, an dem die Aufgabe wirklich faellt.
function setChoreDone(id, date, done) {
  apiPost('/api/recurring-done', { taskId: Number(id), date, done: !!done }).then((res) => {
    if (!res || res.error) {
      toast((res && res.error) || t('recur.saveFailed'), 'err');
      return;
    }
    const task = (recurringTasks || []).find((x) => String(x.id) === String(id));
    if (task) {
      task.done = task.done || [];
      const i = task.done.indexOf(date);
      if (done && i < 0) task.done.push(date);
      if (!done && i >= 0) task.done.splice(i, 1);
    }
    renderDashBatchTasks();
  });
}
function closeRhythmEditor() {
  const m = document.getElementById('m-rhythm');
  if (m) m.classList.remove('open');
  _rhythmDraft = null;
}
// Ab wann der Rhythmus mitzählt. Steht beim Bearbeiten der Ziele, weil es
// dieselbe Frage ist: was diese Anlage sich vornimmt, und ab wann.
function setRhythmStart() {
  const jetzt = (inventory && inventory.bagCountFrom) || '';
  prompt2(t('rhythm.startPrompt'), jetzt, function (raw) {
    if (raw === null || raw === undefined) return;
    const s = String(raw).trim();
    apiPost('/api/rhythm-start', { bagCountFrom: s || null }).then((res) => {
      if (res && res.error) {
        toast(res.error, 'err');
        return;
      }
      inventory.bagCountFrom = res && res.bagCountFrom !== undefined ? res.bagCountFrom : s || null;
      setFb('ok', t('rhythm.startSaved'));
      renderDashBatchTasks();
    });
  });
}
function saveRhythmEditor() {
  _rhythmSyncFromForm();
  if (!_rhythmDraft) return;
  const map = {};
  // 'free' is stored, not omitted: a day deliberately kept clear is a real
  // answer, and dropping it would look identical to never having decided —
  // which is what makes the editor offer a suggestion again.
  for (const d of WEEK_DAYS) {
    const e = _rhythmDraft[d] || {};
    map[d] =
      e.theme === 'free'
        ? { theme: 'free' }
        : { theme: e.theme || 'free', targetQty: e.targetQty, strainId: e.strainId, note: e.note };
  }
  apiPut('/api/week-rhythm', { rhythm: map }).then((res) => {
    if (res && res.error) {
      toast(res.error, 'err');
      return;
    }
    weekRhythm = map;
    closeRhythmEditor();
    setFb('ok', t('rhythm.saved'));
    renderDashBatchTasks();
  });
}
// Bags of this batch that are actually placed somewhere, plus the per-zone
// breakdown the row label needs. Returns counts rather than a formatted room so
// the (linear) zoneDisplayName lookup happens only for the rows actually shown.
function _dashTaskPlace(batchId, lastByBag) {
  const b = batchById(batchId);
  const counts = b ? _batchZoneCounts(b, lastByBag) : {};
  let bags = 0;
  for (const z in counts) bags += counts[z];
  return { bags, counts };
}
// Turns the "bags left behind" splits into rows shaped like every other row, so
// the section reads the same way: N bags of a batch, still one stage back, with
// the rest of the batch noted in the meta line.
function _dashLeftBehindRows(splits) {
  return splits.map((s) => {
    // behindStage / behindCount are already computed by getSplitBatches \u2014 do not
    // re-derive them, and never fabricate a fallback stage the batch is not in.
    const rest = s.stages
      .filter((x) => !x.behind)
      .map((x) => x.count + ' ' + t('dash.splitBatches.in') + ' ' + t('stage.' + x.role))
      .join(' \u00b7 ');
    return {
      batchId: s.batchId,
      species: s.species,
      bags: s.behindCount,
      instruction: t('dash.stillIn', { stage: t('stage.' + s.behindStage) }),
      where: rest ? '(' + rest + ')' : '',
      detail: s.urgent ? t('dash.splitBatches.stale') : '',
      // s.urgent means "unmoved for over 24h", which is a warning, not an overdue
      // date \u2014 it drives the orange dot and the amber stripe on .todo-row.warn.
      warn: s.urgent,
      taskAction: 'view',
      bucket: 'left'
    };
  });
}
// Where things are, on the screen the app opens on: one chip per zone with its
// bag count, and under the chosen one what is in it — a species with the day
// it went in and how long it has been there ("Blue Oyster, 4 Beutel, seit
// 30.09. · heute"), oldest first, because the bags that have been in longest
// are the ones to look at. The dates are the point of the card: how long a
// block has been fruiting is what the team works from. Batches and bag numbers
// fold away under each row.
//
// One zone at a time. Listing every zone with its rows ran every fruiting,
// spawn and incubation zone (a lot of bags) down the page and pushed the
// day's tasks off the phone. Fruiting zones first, then spawn run and incubation.
//
// Drawn with the day's tasks, so it refreshes whenever they do — which includes
// the 30-second tick, so the chosen zone and what someone unfolded are kept
// here rather than reset under their thumb. The zone also survives a reload.
const _wkTentFold = { open: new Set(), zone: null };
function _wkZoneList() {
  return fruitingZones().concat(zones.filter((z) => z.role === 'spawn' || z.role === 'incubation'));
}
function _wkZoneRowsHtml(z, bags) {
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const groups = new Map();
  for (const [bagId, d] of Object.entries(bags)) {
    const day = d.since ? new Date(d.since) : null;
    if (day) day.setHours(0, 0, 0, 0);
    const key = (d.species || '') + '|' + (day ? day.getTime() : '');
    if (!groups.has(key)) groups.set(key, { species: d.species || '—', day: day, batches: new Map() });
    const g = groups.get(key);
    if (!g.batches.has(d.batchId)) g.batches.set(d.batchId, []);
    g.batches.get(d.batchId).push(bagId);
  }
  return [...groups.values()]
    .sort((a, b) => (a.day ? a.day.getTime() : 0) - (b.day ? b.day.getTime() : 0) || a.species.localeCompare(b.species))
    .map((g) => {
      let n = 0;
      // A scan entry does not have to name its batch: the server takes one
      // without and stores an empty one as none, which MCP's scan tool lets
      // through. Its bags land here under a null key, and localeCompare on
      // that threw. This card is drawn before the day's tasks, so the tasks
      // went down with it.
      const detail = [...g.batches.entries()]
        .sort((a, b) => String(a[0] || '').localeCompare(String(b[0] || '')))
        .map(([bid, ids]) => {
          n += ids.length;
          const nums = ids
            .map((id) => id.split('-').pop())
            .sort((a, b) => (parseInt(a) || 0) - (parseInt(b) || 0))
            .join(', ');
          return '<div><span class="wk-tent-batch">' + esc(bid || '—') + '</span> ' + esc(nums) + '</div>';
        })
        .join('');
      let when = '';
      if (g.day) {
        const days = Math.round((today - g.day) / 864e5);
        when =
          t('work.tentSince', { date: fmtDtShort(g.day) }) +
          ' · ' +
          (days <= 0 ? t('work.tentToday') : tp('work.tentDays', days));
      }
      const rk = z.id + '|' + g.species + '|' + (g.day ? g.day.getTime() : '');
      return (
        '<details class="wk-tent-row" data-fold="' +
        esc(rk) +
        '"' +
        (_wkTentFold.open.has(rk) ? ' open' : '') +
        '><summary>' +
        '<span class="wk-tent-sp">' +
        esc(g.species) +
        '</span><span class="wk-tent-n">' +
        esc(tp('dash.bags', n)) +
        '</span>' +
        (when ? '<span class="wk-tent-when">' + esc(when) + '</span>' : '') +
        '</summary><div class="wk-tent-detail">' +
        detail +
        '</div></details>'
      );
    })
    .join('');
}
function renderWorkTents() {
  const card = document.getElementById('wk-tents');
  if (!card) return;
  const list = _wkZoneList();
  card.hidden = !list.length;
  if (!list.length) return;
  const bagsBy = {};
  for (const z of list) bagsBy[z.id] = getZoneBags(z.id);
  if (_wkTentFold.zone === null) {
    try {
      _wkTentFold.zone = localStorage.getItem('mp-work-zone') || '';
    } catch (_) {
      _wkTentFold.zone = '';
    }
  }
  // The remembered zone if it still exists; otherwise the first tent with bags
  // in it, since an empty tent is the least useful thing to open on.
  let sel = list.find((z) => z.id === _wkTentFold.zone);
  if (!sel) sel = list.find((z) => z.role === 'fruiting' && Object.keys(bagsBy[z.id]).length) || list[0];
  const chips = list
    .map((z) => {
      const on = z.id === sel.id;
      return (
        '<button type="button" class="wk-zchip" data-wk-zone="' +
        esc(z.id) +
        '" aria-pressed="' +
        on +
        '"><span class="zone-dot" style="background:' +
        esc(z.color || ZONE_ROLE_COLOR[z.role] || ZONE_ROLE_COLOR.fruiting) +
        '"></span><span class="wk-zchip-name">' +
        esc(zoneDisplayName(z.id)) +
        '</span><span class="wk-zchip-n">' +
        Object.keys(bagsBy[z.id]).length +
        '</span></button>'
      );
    })
    .join('');
  const rows = _wkZoneRowsHtml(sel, bagsBy[sel.id]);
  const box = document.getElementById('wk-tents-list');
  box.innerHTML =
    '<div class="wk-zchips" role="group" aria-label="' +
    esc(t('work.tentsTitle')) +
    '">' +
    chips +
    '</div><div class="wk-zone-rows">' +
    (rows || '<div class="wk-tent-empty">' + esc(t('work.tentEmpty')) + '</div>') +
    '</div>';
  if (!box.dataset.bound) {
    box.dataset.bound = '1';
    box.addEventListener('click', (ev) => {
      const chip = ev.target.closest('[data-wk-zone]');
      if (!chip) return;
      _wkTentFold.zone = chip.dataset.wkZone;
      try {
        localStorage.setItem('mp-work-zone', _wkTentFold.zone);
      } catch (_) {
        /* the choice just lasts until the page reloads */
      }
      renderWorkTents();
    });
    // toggle does not bubble; capture catches it for every <details> in the box.
    box.addEventListener(
      'toggle',
      (ev) => {
        const d = ev.target;
        if (d.dataset && d.dataset.fold) _wkTentFold.open[d.open ? 'add' : 'delete'](d.dataset.fold);
      },
      true
    );
  }
}
// Today in one line, above the task list: "Heute: 3 Umziehen · 2 Ernten". The
// list itself folds away behind it, so the start screen shows the tiles, where
// things are and what today asks for without scrolling; a tap opens the week.
// Open or closed is remembered per device.
//
// The strip above the tiles only appears when something is wrong: work that is
// overdue, or a zone past its capacity. On a normal day it takes no room.
let _wkTasksOpen = null;
function _wkTasksAreOpen() {
  if (_wkTasksOpen === null) {
    try {
      _wkTasksOpen = localStorage.getItem('mp-work-tasks-open') === '1';
    } catch (_) {
      _wkTasksOpen = false;
    }
  }
  return _wkTasksOpen;
}
function setWorkTasksOpen(open) {
  _wkTasksOpen = !!open;
  try {
    localStorage.setItem('mp-work-tasks-open', open ? '1' : '0');
  } catch (_) {
    /* kept for this session only */
  }
  const card = document.getElementById('dash-batch-tasks-card');
  if (card) card.classList.toggle('wk-tasks-closed', !open);
  const btn = document.getElementById('wk-today');
  if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function renderWorkSummary(week) {
  const todayItems = (week && week[0] ? week[0].items : []).filter((it) => !it.done);
  const counts = countByCategory(todayItems);
  const overdue = todayItems.reduce((n, i) => n + planOverdue(i), 0);
  const sum = document.getElementById('wk-today-sum');
  if (sum) {
    const parts = PLAN_CATS.filter((c) => counts[c]).map((c) => counts[c] + ' ' + t(PLAN_CAT_LABEL[c]));
    sum.textContent = parts.length ? parts.join(' · ') : t('work.todayNone');
  }
  setWorkTasksOpen(_wkTasksAreOpen());
  const btn = document.getElementById('wk-today');
  if (btn && !btn.dataset.bound) {
    btn.dataset.bound = '1';
    btn.addEventListener('click', () => setWorkTasksOpen(!_wkTasksAreOpen()));
  }
  const alert = document.getElementById('wk-alert');
  if (!alert) return;
  if (!alert.dataset.bound) {
    alert.dataset.bound = '1';
    aler