app.js
Rama main · réplica de lectura del repositorio original.
// ══════════════════════════════════════════════════════════════
// 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 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">☐</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