chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 45bc449ea0
53 changed files with 12329 additions and 0 deletions
+565
View File
@@ -0,0 +1,565 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nährwerte vervollständigen</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js"></script>
<script src="/shared/boehmi-runner.js"></script>
<script src="/shared/boehmi-backups.js"></script>
<style>
/* Nur Layout alles Optische kommt aus /shared/boehmi.css */
.facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; }
.fact { background: var(--bt-surface); border: 1px solid var(--bt-line); border-radius: var(--bt-r-lg); padding: 13px 14px; box-shadow: var(--bt-shadow); }
.fact b { display: block; font-size: 24px; font-weight: 850; letter-spacing: -.03em; }
.fact span { font-size: 10.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--bt-muted); }
.fact.warn b { color: var(--bt-warn); }
.fact.ok b { color: var(--bt-ok); }
.coverage { display: grid; gap: 9px; }
.cov { display: grid; grid-template-columns: 130px 1fr 92px; gap: 10px; align-items: center; font-size: 13px; }
.cov .nm { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.track { height: 9px; border-radius: 999px; background: var(--bt-bg-2); border: 1px solid var(--bt-line-soft); overflow: hidden; }
.fill { height: 100%; background: var(--bt-accent); border-radius: 999px; transition: width .4s ease; }
.fill.low { background: var(--bt-err); }
.fill.mid { background: var(--bt-warn); }
.cov .n { text-align: right; font-family: var(--bt-mono); font-size: 11.5px; color: var(--bt-muted); }
.matrix { overflow: auto; max-height: 62vh; border: 1px solid var(--bt-line); border-radius: var(--bt-r); }
table.mx { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
table.mx th { position: sticky; top: 0; z-index: 2; background: var(--bt-surface-2); padding: 8px 7px; text-align: left; font-size: 10.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--bt-muted); border-bottom: 1px solid var(--bt-line); white-space: nowrap; }
table.mx td { padding: 6px 7px; border-bottom: 1px solid var(--bt-line-soft); white-space: nowrap; }
table.mx tr:hover td { background: var(--bt-surface-2); }
td.v { text-align: right; font-family: var(--bt-mono); font-size: 11.5px; }
td.v.gap { background: var(--bt-err-soft); color: var(--bt-err); font-weight: 700; }
td.v.gap::after { content: "fehlt"; font-size: 9.5px; }
.rname { font-weight: 600; max-width: 230px; overflow: hidden; text-overflow: ellipsis; }
.rn { text-align: right; font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
.prop { display: grid; grid-template-columns: 26px 1fr auto; gap: 9px; align-items: center; padding: 9px 10px; border-bottom: 1px solid var(--bt-line-soft); }
.prop:last-child { border-bottom: none; }
.prop .pv { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
.pv label { display: flex; align-items: center; gap: 3px; font-size: 11px; color: var(--bt-muted); }
.pv input { width: 74px; padding: 4px 6px; font-size: 11.5px; font-family: var(--bt-mono); text-align: right; }
.plist { max-height: 54vh; overflow: auto; border: 1px solid var(--bt-line); border-radius: var(--bt-r); }
.runbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.chips { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px; }
.chip { cursor: pointer; user-select: none; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--bt-line); background: var(--bt-surface); font-size: 12px; font-weight: 650; }
.chip.on { background: var(--bt-accent-soft); border-color: var(--bt-accent); color: var(--bt-accent); }
.chip input { display: none; }
@media (max-width: 900px) { .facts { grid-template-columns: repeat(2, 1fr); } .cov { grid-template-columns: 96px 1fr 72px; } }
</style>
</head>
<body data-bt-title="Nährwerte" data-bt-icon="🥑">
<main class="bt-main wide">
<div class="bt-pagehead">
<h1>Nährwerte vervollständigen</h1>
<p>Findet Zutaten, bei denen Eigenschaften fehlen, lässt die Lücken
schätzen und schreibt sie nach Prüfung zurück. Rezept-Nährwerte rechnet
Tandoor danach selbst aus.</p>
</div>
<div id="warn" class="bt-notice err bt-hidden"></div>
<div class="bt-tabs" id="tabs">
<button class="bt-tab active" data-tab="pruefen">1 · Prüfen</button>
<button class="bt-tab" data-tab="vorschlagen">2 · Vorschlagen</button>
<button class="bt-tab" data-tab="uebernehmen">3 · Übernehmen</button>
<button class="bt-tab" data-tab="sicherungen">Sicherungen</button>
</div>
<!-- ══════════════════════════════════════════════════ 1 Prüfen -->
<section id="tab-pruefen" class="tab">
<div class="bt-card">
<div class="bt-row" style="align-items:center">
<div style="flex:1">
<h2 style="margin:0">Bestandsaufnahme</h2>
<p class="hint" style="margin:4px 0 0">Liest alle Zutaten und Eigenschaften. Verändert nichts.</p>
</div>
<button class="primary" id="scan">Jetzt prüfen</button>
</div>
<div id="scanStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="scanLog" class="bt-log bt-hidden"></div>
</div>
<div id="reportBox" class="bt-empty">Noch nicht geprüft. Oben auf „Jetzt prüfen“.</div>
</section>
<!-- ═════════════════════════════════════════════ 2 Vorschlagen -->
<section id="tab-vorschlagen" class="tab bt-hidden">
<div class="bt-card">
<h2>Was soll geschätzt werden?</h2>
<p class="hint">Ein Sprachmodell schätzt die fehlenden Werte je 100 g.
Nach Tandoor geschrieben wird hier noch nichts.</p>
<label>Eigenschaften</label>
<div class="chips" id="propChips"></div>
<label>Zutaten</label>
<div class="chips">
<span class="chip on" id="modeGaps" data-mode="gaps">nur Lücken</span>
<span class="chip" id="modeUsed" data-mode="used">nur Lücken in benutzten Zutaten</span>
<span class="chip" id="modeSel" data-mode="sel">Auswahl unten</span>
</div>
<div class="bt-grid2">
<div>
<label for="limit">Höchstens so viele Zutaten (0 = alle)</label>
<input type="number" id="limit" value="0" min="0" max="2000">
</div>
<div>
<label for="model">Modell</label>
<input type="text" id="model" value="">
</div>
</div>
<label class="bt-check" style="margin-top:8px">
<input type="checkbox" id="overwrite"> Auch vorhandene Werte neu schätzen
</label>
<div class="runbar" style="margin-top:12px">
<button class="primary" id="propose">Vorschläge holen</button>
<button class="ghost" id="probe">Verbindung testen</button>
<span class="bt-spacer"></span>
<span class="bt-badge" id="costHint"></span>
</div>
<p class="hint" style="margin:8px 0 0">„Verbindung testen“ macht eine
einzelne Beispiel-Abfrage und zeigt, ob das Modell erreichbar ist und
antwortet — nützlich, wenn ein Lauf „ohne Ergebnis“ zurückkommt.</p>
<div id="propStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="propLog" class="bt-log bt-hidden"></div>
</div>
<div class="bt-card">
<h2>Zutaten auswählen</h2>
<div class="listhead bt-row" style="margin-bottom:9px">
<input type="search" id="foodSearch" placeholder="Zutat suchen …" style="flex:1">
<button class="mini ghost" id="selAllGaps">Alle mit Lücken</button>
<button class="mini ghost" id="selNone">Keine</button>
<span class="bt-badge accent" id="selCount">0</span>
</div>
<div class="matrix" id="foodPick"></div>
</div>
</section>
<!-- ══════════════════════════════════════════ 3 Übernehmen -->
<section id="tab-uebernehmen" class="tab bt-hidden">
<div class="bt-card">
<div class="bt-row" style="align-items:flex-end">
<div style="flex:1">
<label for="proposalFile">Vorschlagsdatei</label>
<select id="proposalFile"></select>
</div>
<button class="ghost" id="reload">Neu laden</button>
</div>
<p class="hint" id="proposalMeta" style="margin:8px 0 0"></p>
</div>
<div class="bt-notice warn">
Die Werte sind <b>Schätzungen eines Sprachmodells</b>, keine Laboranalysen.
Für Hausgebrauch und Größenordnung gedacht. Bitte vor dem Übernehmen
durchsehen — falsche Zahlen fallen später kaum noch auf.
</div>
<div class="bt-card">
<div class="bt-row" style="align-items:center;margin-bottom:8px">
<h2 style="margin:0;flex:1">Vorschläge prüfen</h2>
<button class="mini ghost" id="ackAll">Alle anhaken</button>
<button class="mini ghost" id="ackNone">Keine</button>
<span class="bt-badge accent" id="ackCount">0</span>
</div>
<div class="plist" id="proposalList"></div>
<div class="bt-grid2" style="margin-top:12px">
<div>
<label for="baseAmount">Bezugsmenge</label>
<input type="number" id="baseAmount" value="100" min="1" step="1">
</div>
<div>
<label for="baseUnit">Bezugseinheit (muss es in Tandoor geben)</label>
<input type="text" id="baseUnit" value="g">
</div>
</div>
<p class="hint">Wird nur bei Zutaten gesetzt, die noch keine Bezugsmenge haben.</p>
<div class="runbar" style="margin-top:12px">
<button id="dry">Trockenübung</button>
<button class="primary" id="apply">Werte übernehmen</button>
<span class="bt-spacer"></span>
<button class="ghost mini" id="cancel" disabled>Abbrechen</button>
</div>
<div id="applyStatus" class="bt-status">Bereit.</div>
<div id="applyLog" class="bt-log"></div>
</div>
</section>
<!-- ══════════════════════════════════════════ Sicherungen -->
<section id="tab-sicherungen" class="tab bt-hidden"></section>
</main>
<script>
"use strict";
const $ = (id) => document.getElementById(id);
let state = {};
let report = null;
let proposal = null;
const picked = new Set();
const activeProps = new Set();
let mode = "gaps";
/* ------------------------------------------------------------ Reiter */
$("tabs").addEventListener("click", (e) => {
const b = e.target.closest("button[data-tab]");
if (!b) return;
document.querySelectorAll("#tabs button").forEach((x) => x.classList.remove("active"));
document.querySelectorAll("main > .tab").forEach((x) => x.classList.add("bt-hidden"));
b.classList.add("active");
$(`tab-${b.dataset.tab}`).classList.remove("bt-hidden");
if (b.dataset.tab === "sicherungen") backupsView.reload();
});
/* ------------------------------------------------- Läufe (3 Bereiche) */
function makeRunner(endpoint, logId, statusId, after) {
return BT.Runner({
endpoint,
log: $(logId), status: $(statusId),
onStart: () => { $(logId).classList.remove("bt-hidden"); $(statusId).classList.remove("bt-hidden"); },
onFinish: (info) => {
BT.toast(info.status === "done" ? "Fertig." : "Beendet: " + info.status,
info.status === "done" ? "ok" : "err");
if (after) after(info);
},
});
}
const scanRunner = makeRunner("/api/run/scan", "scanLog", "scanStatus", () => load());
const propRunner = makeRunner("/api/run/propose", "propLog", "propStatus", () => load());
const probeRunner = makeRunner("/api/run/probe", "propLog", "propStatus");
const applyRunner = makeRunner("/api/run/apply", "applyLog", "applyStatus", () => { $("cancel").disabled = true; });
/* ------------------------------------------------------ Sicherungen */
const restoreRunner = BT.Runner({
endpoint: "/api/run/restore",
onFinish: (info) => {
BT.toast(info.status === "done" ? "Fertig." : "Beendet: " + info.status,
info.status === "done" ? "ok" : "err");
backupsView.reload();
load();
},
});
const backupsView = BT.Backups({
mount: $("tab-sicherungen"),
runner: restoreRunner,
detail: true,
note: `Zurückspielen setzt die Nährwerte auf den Stand vor dem Lauf —
genau die Werte, die dieser Lauf gesetzt hat, verschwinden wieder.
Was du seither von Hand geändert hast, wird erkannt und übersprungen.`,
});
/* ------------------------------------------------------- 1 · Bericht */
function renderReport() {
if (!report) return;
const s = report.summary;
const types = report.types;
$("reportBox").className = "";
$("reportBox").innerHTML = `
<div class="facts">
<div class="fact"><b>${s.foods}</b><span>Zutaten</span></div>
<div class="fact ok"><b>${s.percent_complete}%</b><span>vollständig</span></div>
<div class="fact warn"><b>${s.incomplete}</b><span>mit Lücken</span></div>
<div class="fact warn"><b>${s.incomplete_used_in_recipes}</b><span>Lücken in benutzten Zutaten</span></div>
</div>
<section class="bt-card">
<h2>Abdeckung je Eigenschaft</h2>
<div class="coverage">${report.per_type.map((t) => {
const cls = t.percent < 34 ? "low" : t.percent < 67 ? "mid" : "";
return `<div class="cov">
<span class="nm" title="${BT.escape(t.name)}">${BT.escape(t.name)}</span>
<span class="track"><span class="fill ${cls}" style="width:${t.percent}%"></span></span>
<span class="n">${t.percent}% · ${t.missing} offen</span>
</div>`;
}).join("")}</div>
${s.without_base_amount ? `<p class="hint" style="margin-top:10px">
${s.without_base_amount} Zutaten haben keine Bezugsmenge. Ohne die
(z. B. „pro 100 g“) kann Tandoor keine Rezeptwerte rechnen — Schritt 3
setzt sie mit.</p>` : ""}
</section>
<section class="bt-card">
<div class="bt-row" style="align-items:center;margin-bottom:9px">
<h2 style="margin:0;flex:1">Wo genau fehlt was</h2>
<input type="search" id="mxSearch" placeholder="filtern …" style="max-width:220px">
<label class="bt-check"><input type="checkbox" id="onlyGaps" checked> nur Lücken</label>
</div>
<div class="matrix">
<table class="mx">
<thead><tr>
<th>Zutat</th><th style="text-align:right">Rezepte</th>
${types.map((t) => `<th style="text-align:right">${BT.escape(t.name)}${t.unit ? ` <span style="opacity:.6">${BT.escape(t.unit)}</span>` : ""}</th>`).join("")}
</tr></thead>
<tbody id="mxBody"></tbody>
</table>
</div>
</section>`;
const draw = () => {
const q = ($("mxSearch").value || "").toLowerCase();
const only = $("onlyGaps").checked;
const rows = report.foods.filter((r) =>
(!only || !r.complete) && (!q || r.name.toLowerCase().includes(q)));
$("mxBody").innerHTML = rows.slice(0, 400).map((r) => `
<tr>
<td class="rname" title="${BT.escape(r.name)}">${BT.escape(r.name)}</td>
<td class="rn">${r.numrecipe}</td>
${types.map((t) => {
const v = r.values[String(t.id)];
return v === null || v === undefined
? `<td class="v gap"></td>`
: `<td class="v">${Number(v).toLocaleString("de-DE")}</td>`;
}).join("")}
</tr>`).join("") || `<tr><td colspan="${types.length + 2}" style="padding:22px;text-align:center;color:var(--bt-muted)">Nichts gefunden.</td></tr>`;
};
$("mxSearch").addEventListener("input", draw);
$("onlyGaps").addEventListener("change", draw);
draw();
}
/* --------------------------------------------------- 2 · Vorschlagen */
function renderPropChips() {
if (!report) { $("propChips").innerHTML = `<span class="hint">Erst prüfen.</span>`; return; }
if (!activeProps.size) report.types.forEach((t) => activeProps.add(t.id));
$("propChips").innerHTML = report.types.map((t) =>
`<span class="chip ${activeProps.has(t.id) ? "on" : ""}" data-prop="${t.id}">${BT.escape(t.name)}</span>`
).join("");
}
$("propChips").addEventListener("click", (e) => {
const c = e.target.closest("[data-prop]");
if (!c) return;
const id = Number(c.dataset.prop);
activeProps.has(id) ? activeProps.delete(id) : activeProps.add(id);
renderPropChips(); updateCost();
});
document.querySelectorAll("[data-mode]").forEach((c) => c.addEventListener("click", () => {
mode = c.dataset.mode;
document.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x === c));
updateCost();
}));
function targetFoods() {
if (!report) return [];
if (mode === "sel") return report.foods.filter((f) => picked.has(f.id));
if (mode === "used") return report.foods.filter((f) => !f.complete && f.numrecipe > 0);
return report.foods.filter((f) => !f.complete);
}
function updateCost() {
const n = targetFoods().length;
const limit = Number($("limit").value) || 0;
const eff = limit ? Math.min(n, limit) : n;
$("costHint").textContent = eff
? `${eff} Zutaten · etwa ${Math.ceil(eff / 20)} Anfragen`
: "nichts ausgewählt";
$("propose").disabled = !eff || !state.openai;
}
$("limit").addEventListener("input", updateCost);
function renderFoodPick() {
if (!report) return;
const q = ($("foodSearch").value || "").toLowerCase();
const rows = report.foods.filter((f) => !q || f.name.toLowerCase().includes(q));
$("foodPick").innerHTML = `<table class="mx"><thead><tr>
<th style="width:30px"></th><th>Zutat</th><th style="text-align:right">Rezepte</th><th style="text-align:right">fehlt</th>
</tr></thead><tbody>${rows.slice(0, 400).map((f) => `
<tr>
<td><input type="checkbox" data-food="${f.id}" ${picked.has(f.id) ? "checked" : ""}></td>
<td class="rname">${BT.escape(f.name)}</td>
<td class="rn">${f.numrecipe}</td>
<td class="rn">${f.missing.length ? f.missing.length + " von " + report.types.length : "—"}</td>
</tr>`).join("")}</tbody></table>`;
$("selCount").textContent = picked.size;
}
$("foodPick").addEventListener("change", (e) => {
const b = e.target.closest("[data-food]");
if (!b) return;
const id = Number(b.dataset.food);
b.checked ? picked.add(id) : picked.delete(id);
$("selCount").textContent = picked.size;
updateCost();
});
$("foodSearch").addEventListener("input", renderFoodPick);
$("selAllGaps").addEventListener("click", () => {
report.foods.filter((f) => !f.complete).forEach((f) => picked.add(f.id));
mode = "sel";
document.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x.dataset.mode === "sel"));
renderFoodPick(); updateCost();
});
$("selNone").addEventListener("click", () => { picked.clear(); renderFoodPick(); updateCost(); });
$("scan").addEventListener("click", () => scanRunner.start({}));
$("propose").addEventListener("click", () => {
const foods = mode === "sel" ? [...picked] : [];
propRunner.start({
foods,
properties: [...activeProps],
limit: Number($("limit").value) || 0,
overwrite: $("overwrite").checked,
model: $("model").value.trim() || null,
});
});
$("probe").addEventListener("click", () => {
$("propLog").classList.remove("bt-hidden");
$("propStatus").classList.remove("bt-hidden");
probeRunner.start({ model: $("model").value.trim() || null });
});
/* ---------------------------------------------------- 3 · Übernehmen */
async function loadProposal(name) {
if (!name) { proposal = null; $("proposalList").innerHTML = ""; $("proposalMeta").textContent = ""; return; }
proposal = await BT.api(`/api/proposals/${encodeURIComponent(name)}`);
const d = new Date(proposal.created_at);
$("proposalMeta").textContent =
`${proposal.proposals.length} Vorschläge · Modell ${proposal.model} · ${d.toLocaleString("de-DE")}` +
(proposal.failed?.length ? ` · ${proposal.failed.length} ohne Ergebnis` : "");
renderProposal();
}
function emptyProposalNotice() {
const n = proposal?.failed?.length || 0;
const err = proposal?.error;
if (!n && !err) {
return `<div class="bt-empty" style="border:none">Diese Datei enthält keine Vorschläge.</div>`;
}
return `<div class="bt-notice warn" style="margin:0">
<b>Diese Datei enthält keine übernehmbaren Vorschläge${n ? ` (${n} ohne Ergebnis)` : ""}.</b>
Das deutet auf ein grundsätzliches Problem mit der Modell-Abfrage hin, nicht
auf einzelne Zutaten.
${err ? `<br><br>Erster Fehler der API:<br><code>${BT.escape(err)}</code>` : ""}
<br><br>Prüf über <b>2 · Vorschlagen → „Verbindung testen“</b>, ob das Modell
erreichbar ist und antwortet. Häufige Ursachen: falscher oder nicht
freigeschalteter Modellname, fehlendes OpenAI-Guthaben, oder ein Modell, das
bestimmte Aufruf-Parameter ablehnt.</div>`;
}
function renderProposal() {
if (!proposal) return;
const names = Object.fromEntries(proposal.types.map((t) => [String(t.id), t]));
$("proposalList").innerHTML = proposal.proposals.map((p) => `
<div class="prop" data-row="${p.food_id}">
<input type="checkbox" data-ack="${p.food_id}" ${p.accept !== false ? "checked" : ""}>
<div>
<b style="font-size:13px">${BT.escape(p.food_name)}</b>
<span class="bt-muted" style="font-size:11px"> · ${p.numrecipe} Rezepte</span>
</div>
<div class="pv">
${Object.entries(p.values).map(([tid, v]) => `
<label>${BT.escape(names[tid]?.name || tid)}
<input type="number" step="0.01" min="0" data-food="${p.food_id}" data-type="${tid}" value="${v}">
<span>${BT.escape(names[tid]?.unit || "")}</span>
</label>`).join("")}
</div>
</div>`).join("") || emptyProposalNotice();
updateAck();
}
function updateAck() {
const n = document.querySelectorAll("[data-ack]:checked").length;
$("ackCount").textContent = `${n} angehakt`;
$("apply").disabled = !n || !state.tandoor;
$("dry").disabled = !n;
}
$("proposalList").addEventListener("change", updateAck);
$("ackAll").addEventListener("click", () => {
document.querySelectorAll("[data-ack]").forEach((c) => c.checked = true); updateAck();
});
$("ackNone").addEventListener("click", () => {
document.querySelectorAll("[data-ack]").forEach((c) => c.checked = false); updateAck();
});
$("proposalFile").addEventListener("change", (e) => loadProposal(e.target.value));
$("reload").addEventListener("click", () => load());
async function saveEdits() {
const rows = proposal.proposals.map((p) => {
const values = {};
document.querySelectorAll(`input[data-food="${p.food_id}"][data-type]`).forEach((i) => {
values[i.dataset.type] = i.value;
});
return {
food_id: p.food_id,
accept: $(`[data-ack="${p.food_id}"]`)?.checked ?? true,
values,
};
});
return BT.api(`/api/proposals/${encodeURIComponent($("proposalFile").value)}`,
{ method: "POST", body: JSON.stringify({ proposals: rows }) });
}
async function runApply(apply) {
try {
const saved = await saveEdits();
if (!saved.accepted) { BT.toast("Nichts angehakt.", "err"); return; }
if (apply && !confirm(
`${saved.accepted} Zutaten in Tandoor mit geschätzten Nährwerten füllen?\n\n` +
`Vorhandene Werte bleiben unangetastet. Von jeder geänderten Zutat wird ` +
`vorher eine Sicherung ins Laufverzeichnis geschrieben.`)) return;
$("cancel").disabled = false;
applyRunner.start({
proposal: $("proposalFile").value,
apply,
overwrite: false,
continue_on_error: true,
base_amount: Number($("baseAmount").value) || 100,
base_unit: $("baseUnit").value.trim() || "g",
});
} catch (err) { BT.toast(err.message, "err"); }
}
$("dry").addEventListener("click", () => runApply(false));
$("apply").addEventListener("click", () => runApply(true));
$("cancel").addEventListener("click", () => applyRunner.cancel());
/* ------------------------------------------------------------- Laden */
async function load() {
state = await BT.api("/api/state");
report = state.report;
const fehlt = [];
if (!state.tandoor) fehlt.push("Tandoor-URL und Token");
if (!state.openai) fehlt.push("OpenAI-Key");
$("warn").classList.toggle("bt-hidden", !fehlt.length);
if (fehlt.length) {
$("warn").innerHTML = `Es fehlt: ${fehlt.join(" und ")}. Bitte in den
<a href="/settings">Einstellungen</a> hinterlegen.`;
}
$("scan").disabled = !state.tandoor;
if (!$("model").value) $("model").value = state.model;
const sel = $("proposalFile");
const previous = sel.value;
sel.innerHTML = state.proposals.map((f) =>
`<option value="${BT.escape(f)}">${BT.escape(f.replace(".json", ""))}</option>`).join("");
if (state.proposals.length) {
sel.value = state.proposals.includes(previous) ? previous : state.proposals[0];
await loadProposal(sel.value);
} else {
$("proposalList").innerHTML = `<div class="bt-empty" style="border:none">Noch keine Vorschläge. Schritt 2.</div>`;
}
if (report) { renderReport(); renderPropChips(); renderFoodPick(); }
updateCost(); updateAck();
if (state.running) {
const lbl = state.running.label || "";
const r = lbl.startsWith("Prüfen") ? scanRunner
: lbl.startsWith("Vorschlagen") ? propRunner
: lbl.startsWith("OpenAI-Verbindung") ? probeRunner
: lbl.startsWith("Zurückspielen") || lbl.startsWith("Trockenübung Zurück") ? restoreRunner
: applyRunner;
if (r === probeRunner) { $("propLog").classList.remove("bt-hidden"); $("propStatus").classList.remove("bt-hidden"); }
r.attach(state.running.id);
}
}
load();
</script>
</body>
</html>