8 Commits
3 changed files with 270 additions and 47 deletions
+2
View File
@@ -2,6 +2,8 @@
Pizzateig-Kalkulator als statische Single-Page-App.
Aktuelle Version: **v1.1**
- Bäckerprozent
- Mehl-Datenbank
- Vorteig
+190 -47
View File
@@ -3,27 +3,29 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>TEIG//TERMINAL — Pizzateig-Kalkulator</title>
<title>TEIG//TERMINAL v1.1 — Pizzateig-Kalkulator</title>
<meta name="description" content="Pizzateig-Rechner mit Bäckerprozent, Mehl-Datenbank, Vorteig und Rückwärts-Zeitplan.">
<meta name="color-scheme" content="dark">
<style>
:root{
--bg:#07080B;
--panel:#0E1117;
--panel-2:#121722;
--line:#1B2430;
--line-hot:#243447;
--panel:#10151F;
--panel-2:#151C29;
--line:#263241;
--line-hot:#34475C;
--cyan:#00F0FF;
--magenta:#FF2D78;
--amber:#FFB000;
--lime:#8CFF3B;
--txt:#C9D6E2;
--dim:#6E7B8B;
--txt:#E2ECF5;
--dim:#A5B4C5;
--muted:#8796A8;
--mono: ui-monospace, "JetBrains Mono", "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--r:2px;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
html{touch-action:manipulation}
body{
background:var(--bg);
color:var(--txt);
@@ -37,6 +39,7 @@ body{
linear-gradient(90deg, rgba(0,240,255,.028) 1px, transparent 1px);
background-size:32px 32px;
}
button,input,select,.chip,.stepper,.quick-summary{touch-action:manipulation}
.wrap{max-width:760px;margin:0 auto;padding:0 14px}
/* ---------- Kopf ---------- */
@@ -48,8 +51,8 @@ header{padding:28px 0 18px;border-bottom:1px solid var(--line);margin-bottom:20p
text-shadow:0 0 18px rgba(0,240,255,.35);
}
.brand em{font-style:normal;color:var(--magenta);text-shadow:0 0 18px rgba(255,45,120,.45)}
.tag{color:var(--dim);font-size:11px;letter-spacing:.22em;text-transform:uppercase;margin:10px 0 0}
.status{display:flex;gap:8px;align-items:center;margin-top:12px;font-size:11px;letter-spacing:.14em;color:var(--dim);text-transform:uppercase}
.tag{color:var(--muted);font-size:11px;letter-spacing:.22em;text-transform:uppercase;margin:10px 0 0}
.status{display:flex;gap:8px;align-items:center;margin-top:12px;font-size:11px;letter-spacing:.14em;color:var(--muted);text-transform:uppercase}
.dot{width:7px;height:7px;background:var(--lime);border-radius:50%;box-shadow:0 0 10px var(--lime);animation:pulse 2.4s infinite}
@keyframes pulse{50%{opacity:.25}}
@@ -79,7 +82,7 @@ header{padding:28px 0 18px;border-bottom:1px solid var(--line);margin-bottom:20p
.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip{
font:inherit;font-size:12px;
background:transparent;color:var(--dim);
background:rgba(255,255,255,.015);color:#B4C3D3;
border:1px solid var(--line-hot);border-radius:var(--r);
padding:7px 11px;cursor:pointer;letter-spacing:.06em;
transition:.15s;
@@ -90,11 +93,54 @@ header{padding:28px 0 18px;border-bottom:1px solid var(--line);margin-bottom:20p
box-shadow:0 0 16px rgba(0,240,255,.35);font-weight:700;
}
/* ---------- UX-Modus & Schnellstart ---------- */
.quick-summary{
position:sticky;top:0;z-index:20;
display:flex;gap:8px;overflow:auto;align-items:center;
margin:0 -14px 14px;padding:8px 14px;
background:rgba(7,8,11,.92);backdrop-filter:blur(8px);
border-bottom:1px solid var(--line-hot);
scrollbar-width:none;
}
.quick-summary::-webkit-scrollbar{display:none}
.quick-summary span{
white-space:nowrap;border:1px solid var(--line-hot);border-radius:var(--r);
background:#0A0E15;color:var(--txt);padding:6px 9px;font-size:12px;
font-variant-numeric:tabular-nums;
}
.quick-summary b{color:var(--cyan);font-weight:700}
@media(max-width:520px){
.quick-summary{
display:grid;
grid-template-columns:1fr 1fr;
gap:6px;
overflow:visible;
padding:8px 14px 10px;
}
.quick-summary span{
min-width:0;
white-space:normal;
line-height:1.25;
font-size:11.5px;
padding:7px 8px;
}
}
body[data-ux="simple"] .advanced-only{display:none!important}
body[data-ux="simple"] .pro-only-note{display:block}
.pro-only-note{display:none}
.help{
margin-left:6px;width:18px;height:18px;border:1px solid var(--line-hot);border-radius:50%;
background:#0A0E15;color:var(--cyan);font:inherit;font-size:11px;line-height:16px;
display:inline-flex;align-items:center;justify-content:center;cursor:help;padding:0;
}
.warn b{color:#fff;font-weight:700}
/* ---------- Controls ---------- */
.ctl{display:block;margin:15px 0 0}
.ctl:first-of-type{margin-top:0}
.ctl-top{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin-bottom:5px}
.ctl-l{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--dim)}
.ctl-l{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
.ctl-v{font-size:17px;color:var(--cyan);font-weight:700;text-shadow:0 0 12px rgba(0,240,255,.3);font-variant-numeric:tabular-nums}
.ctl-v small{color:var(--dim);font-size:11px;font-weight:400;letter-spacing:.1em;text-shadow:none}
input[type=range]{
@@ -112,7 +158,12 @@ input[type=range]::-moz-range-thumb{
}
input[type=range]:focus-visible::-webkit-slider-thumb{outline:2px solid var(--magenta);outline-offset:2px}
.row{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:520px){.row{grid-template-columns:1fr}}
@media(max-width:520px){.row{grid-template-columns:1fr}
body{font-size:16px}
.ctl-v{font-size:19px}
.chip{font-size:13px;padding:9px 12px}
.panel{padding:18px 15px 20px}
}
select,input[type=number],input[type=datetime-local]{
font:inherit;font-size:14px;width:100%;
@@ -138,7 +189,7 @@ select:focus,input:focus{outline:2px solid var(--cyan);outline-offset:1px}
.badge{
font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;
border:1px solid var(--line-hot);border-radius:var(--r);
padding:4px 8px;color:var(--dim);background:#0A0E15
padding:4px 8px;color:var(--muted);background:#0A0E15
}
.badge b{color:var(--txt);font-weight:700}
.note{font-size:12px;color:var(--dim);margin-top:10px;line-height:1.5}
@@ -158,8 +209,8 @@ select:focus,input:focus{outline:2px solid var(--cyan);outline-offset:1px}
.sub:first-child{margin-top:0}
table{width:100%;border-collapse:collapse}
td{padding:8px 0;border-bottom:1px dotted var(--line-hot);vertical-align:baseline}
td.n{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;color:#EAF6FF;white-space:nowrap}
td.p{text-align:right;width:62px;color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums}
td.n{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;color:#F3FAFF;white-space:nowrap}
td.p{text-align:right;width:62px;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums}
tr.tot td{border-bottom:0;border-top:1px solid var(--line-hot);padding-top:11px;color:var(--cyan)}
tr.tot td.n{color:var(--cyan);text-shadow:0 0 12px rgba(0,240,255,.3);font-size:17px}
.hero{
@@ -186,7 +237,7 @@ tr.tot td.n{color:var(--cyan);text-shadow:0 0 12px rgba(0,240,255,.3);font-size:
.tl .t{font-size:17px;font-weight:700;color:var(--cyan);font-variant-numeric:tabular-nums;text-shadow:0 0 12px rgba(0,240,255,.25)}
.tl .t em{font-style:normal;font-size:11px;letter-spacing:.14em;color:var(--dim);margin-left:8px;text-transform:uppercase;text-shadow:none}
.tl .a{color:#EAF6FF;font-size:14px;margin-top:1px}
.tl .d{color:var(--dim);font-size:12px;margin-top:2px;line-height:1.45}
.tl .d{color:var(--muted);font-size:12px;margin-top:2px;line-height:1.45}
.tl li.bake .t{color:var(--amber);text-shadow:0 0 12px rgba(255,176,0,.3)}
.countdown{
border:1px solid var(--magenta);background:rgba(255,45,120,.07);
@@ -204,7 +255,7 @@ tr.tot td.n{color:var(--cyan);text-shadow:0 0 12px rgba(0,240,255,.3);font-size:
.btn:hover{border-color:var(--cyan);color:var(--cyan)}
.btn.pri{background:var(--magenta);border-color:var(--magenta);color:#fff;font-weight:700}
.btn.pri:hover{box-shadow:0 0 20px rgba(255,45,120,.5);color:#fff}
footer{color:var(--dim);font-size:11.5px;line-height:1.6;margin-top:22px;border-top:1px solid var(--line);padding-top:16px}
footer{color:var(--muted);font-size:11.5px;line-height:1.6;margin-top:22px;border-top:1px solid var(--line);padding-top:16px}
footer b{color:var(--txt)}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
@@ -253,9 +304,25 @@ footer b{color:var(--txt)}
<header>
<h1 class="brand">TEIG<em>//</em>TERMINAL</h1>
<p class="tag">Pizzateig-Kalkulator · Bäckerprozent · Vorteig · Rückwärts-Zeitplan</p>
<div class="status"><span class="dot"></span><span id="statusline">System bereit</span></div>
<div class="status"><span class="dot"></span><span id="statusline">System bereit · v1.1</span></div>
</header>
<div class="quick-summary noprint" id="quickSummary" aria-label="Kurzüberblick"></div>
<section class="panel noprint">
<p class="ph"><b>00 // Schnellstart</b><span>Einfach oder Profi</span></p>
<div class="ctl">
<div class="ctl-top"><span class="ctl-l">Ansicht</span></div>
<div class="chips" id="uxModes" role="group" aria-label="Ansicht"></div>
<p class="note">Einfach fragt nur: Wann soll die Pizza fertig sein, welcher Stil, wie viele Ballen und wie schwer pro Ballen. Profi blendet alle Stellschrauben ein.</p>
</div>
<label class="ctl">
<div class="ctl-top"><span class="ctl-l">Pizza fertig am</span></div>
<input type="datetime-local" id="bakeAtQuick">
</label>
<p class="note pro-only-note">Profi-Werte wie Hydration, Gare und Vorteig laufen mit sinnvollen Defaults im Hintergrund. Wechsel auf „Profi“, wenn du sie anpassen möchtest.</p>
</section>
<!-- CHARGE -->
<section class="panel noprint">
<p class="ph"><b>01 // Charge</b><span id="doughsum"></span></p>
@@ -271,7 +338,7 @@ footer b{color:var(--txt)}
</div>
</div>
<div class="ctl">
<div class="ctl advanced-only">
<div class="ctl-top"><span class="ctl-l">Größe bestimmen über</span></div>
<div class="chips" id="modes" role="group" aria-label="Berechnungsart"></div>
</div>
@@ -279,18 +346,18 @@ footer b{color:var(--txt)}
<div id="modeW">
<label class="ctl">
<div class="ctl-top"><span class="ctl-l">Gewicht pro Teigling</span><span class="ctl-v" id="v-ball"></span></div>
<input type="range" id="ball" min="120" max="1200" step="5">
<input type="range" id="ball" min="150" max="400" step="5">
</label>
</div>
<div id="modeD" hidden>
<div id="modeD" class="advanced-only" hidden>
<label class="ctl">
<div class="ctl-top"><span class="ctl-l">Durchmesser</span><span class="ctl-v" id="v-dia"></span></div>
<input type="range" id="dia" min="16" max="45" step="1">
</label>
</div>
<div id="modeP" hidden>
<div id="modeP" class="advanced-only" hidden>
<div class="row">
<label class="ctl">
<div class="ctl-top"><span class="ctl-l">Blech Länge</span><span class="ctl-v" id="v-panL"></span></div>
@@ -305,7 +372,7 @@ footer b{color:var(--txt)}
</section>
<!-- MEHL -->
<section class="panel noprint">
<section class="panel noprint advanced-only">
<p class="ph"><b>02 // Mehl</b><span id="flourW"></span></p>
<select id="flour" aria-label="Mehl auswählen"></select>
<div class="meta" id="flourMeta"></div>
@@ -316,7 +383,7 @@ footer b{color:var(--txt)}
</section>
<!-- REZEPTUR -->
<section class="panel noprint">
<section class="panel noprint advanced-only">
<p class="ph"><b>03 // Rezeptur</b><span>Bäckerprozent</span></p>
<label class="ctl">
<div class="ctl-top"><span class="ctl-l">Hydration</span><span class="ctl-v" id="v-hyd"></span></div>
@@ -343,7 +410,7 @@ footer b{color:var(--txt)}
</section>
<!-- GARE -->
<section class="panel noprint">
<section class="panel noprint advanced-only">
<p class="ph"><b>04 // Gare</b><span id="fermsum"></span></p>
<div class="ctl">
@@ -385,7 +452,7 @@ footer b{color:var(--txt)}
</section>
<!-- VORTEIG -->
<section class="panel noprint">
<section class="panel noprint advanced-only">
<p class="ph"><b>05 // Vorteig</b><span id="pfsum"></span></p>
<div class="chips" id="pfTypes" role="group" aria-label="Vorteig"></div>
<div id="pfBox" hidden>
@@ -423,7 +490,7 @@ footer b{color:var(--txt)}
<!-- ZEITPLAN -->
<section class="panel">
<p class="ph"><b>07 // Zeitplan</b><span>rückwärts ab Backzeit</span></p>
<label class="ctl noprint" style="margin-bottom:16px">
<label class="ctl noprint advanced-only" style="margin-bottom:16px">
<div class="ctl-top"><span class="ctl-l">Pizza fertig am</span></div>
<input type="datetime-local" id="bakeAt">
</label>
@@ -434,8 +501,8 @@ footer b{color:var(--txt)}
<div id="printfoot" class="printonly"></div>
<div class="actions noprint">
<button type="button" class="btn pri" id="share">Rezept-Link kopieren</button>
<button type="button" class="btn" id="pdf">Als PDF speichern</button>
<button type="button" class="btn pri" id="share">Rezept teilen</button>
<button type="button" class="btn" id="pdf">Druck/PDF</button>
<button type="button" class="btn" id="copyTxt">Als Text kopieren</button>
<button type="button" class="btn" id="reset">Zurücksetzen</button>
</div>
@@ -486,10 +553,10 @@ const FLOURS = [
{g:"Spezial", id:"custom", n:"Eigenes Mehl / Blend", w:"—", pr:0, hyd:[50,90], reife:[0,120], vt:true, note:"Keine Prüfung — du weißt, was du tust."}
];
/* ---------- Stile (tf = Teigdichte in g/cm²) ---------- */
/* ---------- Stile (tf = Teigdichte in g/cm², yeastK = Frischhefe-Referenz) ---------- */
const STYLES = {
napo: {n:"Napoletana", ball:250, hyd:62, salt:2.8, oil:0, sugar:0, tf:0.36, mode:"w"},
contemp: {n:"Contemporanea", ball:280, hyd:70, salt:2.8, oil:0, sugar:0, tf:0.39, mode:"w"},
napo: {n:"Napoletana", ball:250, hyd:62, salt:2.8, oil:0, sugar:0, tf:0.36, mode:"w", yeastK:1.5},
contemp: {n:"Contemporanea", ball:280, hyd:70, salt:2.8, oil:0, sugar:0, tf:0.39, mode:"w", yeastK:1.7},
ny: {n:"New York", ball:300, hyd:63, salt:2.0, oil:2.5, sugar:1.5, tf:0.31, mode:"w"},
tonda: {n:"Römisch tonda", ball:200, hyd:57, salt:2.2, oil:4, sugar:0, tf:0.26, mode:"w"},
teglia: {n:"Teglia / Blech", ball:800, hyd:78, salt:2.2, oil:2, sugar:0, tf:0.55, mode:"p"},
@@ -515,7 +582,7 @@ const SEQ = {
warmcold: {n:"Warm → Kalt", fridge:true,
note:"Hybrid: erst Stockgare bei Raumtemperatur, dann in den Kühlschrank, kurz vor dem Backen aufwärmen. Sehr verbreitet, braucht wenig Hefe — aber der Teig kühlt langsam durch und gärt im Kühlschrank wärmer als gedacht."},
coldwarm: {n:"Kalt → Warm", fridge:true,
note:"Lehmann-Methode: direkt nach dem Kneten kalt stellen, die Gare passiert im Kühlschrank, die warme Zeit danach dient dem Handling. Am besten planbar, braucht deutlich mehr Hefe."}
note:"Direkt nach dem Kneten kalt stellen, anschließend in warmer Stückgare ausreifen lassen. Gut planbar und besonders für längere Führungen geeignet; die warme Endgare macht die Ballen sichtbar aktiv und dehnbar."}
};
const STAGLIO = {
before:"Ballen vor der Kälte",
@@ -525,13 +592,14 @@ const STAGLIO = {
const MODES = {w:"Ballgewicht", d:"Durchmesser", p:"Blech L×B"};
const YEASTS = {fresh:"Frischhefe", dry:"Trockenhefe", sour:"Sauerteig"};
const PFS = {none:"Ohne (Direktteig)", poolish:"Poolish", biga:"Biga"};
const UX_MODES = {simple:"Einfach", pro:"Profi"};
const PF_HYD = {poolish:1.0, biga:0.45};
/* ============================================================
STATE
============================================================ */
const DEFAULT = {
style:"napo", flour:"cap-pizzeria", balls:4, mode:"w",
style:"napo", flour:"cap-pizzeria", balls:4, mode:"w", uxMode:"simple",
ball:250, dia:30, panL:40, panW:30,
hyd:62, salt:2.8, oil:0, sugar:0, yeast:"fresh",
plan:"d24", seq:"warmcold", staglio:"before",
@@ -540,11 +608,16 @@ const DEFAULT = {
bake:null
};
let S = Object.assign({}, DEFAULT);
const BALL_MIN = 150;
const BALL_MAX = 400;
const SIMPLE_PREP_H = 0.75; // Kneten + Formen im Zeitplan
const SIMPLE_SAME_DAY_MAX_H = 12; // Same-Day im Einfach-Modus immer warm führen
/* ============================================================
RECHENKERN
============================================================ */
const clamp = (x,a,b)=>Math.min(b,Math.max(a,x));
const clampBall = x => clamp(Number.isFinite(+x) ? +x : DEFAULT.ball, BALL_MIN, BALL_MAX);
const rate = T => Math.pow(2.5,(T-20)/10); // Q10 = 2.5
/* Eine Garephase mit Temperaturwechsel. tc = Stunden, bis der Teigkern
@@ -597,7 +670,7 @@ function calc(s){
const pfFrac= hasPf ? s.pfPct/100 : 0;
// --- Triebmittel in % vom Mehl ---
let fresh = 6/U; // Frischhefe-Referenz
let fresh = (st.yeastK ?? 6)/U; // stilbezogene Frischhefe-Referenz
fresh *= 1 + Math.max(0, s.salt-2.5)*0.06; // Salz bremst
if (hasPf) fresh *= (1 - 0.8*pfFrac); // Vorteig bringt schon Trieb
fresh = clamp(fresh, 0.01, 6);
@@ -778,11 +851,43 @@ function applyPlan(){
if (S.seq==="coldwarm") S.roomH=Math.min(S.roomH,3);
}
/* Einfach-Modus: Der Nutzer gibt nur Zielzeit, Stil und Teigmenge vor.
Die Führung wird daraus automatisch gewählt:
- Same-Day: immer warm, ohne Kühlschrank.
- Länger als Same-Day: Lehmann-Methode (Kalt → Warm) als Standard. */
function applySimpleFermentation(){
if (S.uxMode!=="simple" || !S.bake) return;
const available = (S.bake - Date.now())/3600e3;
const doughH = clamp(Math.round((available - SIMPLE_PREP_H)*2)/2, 2, 72);
S.yeast = "fresh";
S.pf = "none";
if (doughH <= SIMPLE_SAME_DAY_MAX_H){
S.plan = "same";
S.seq = "room";
S.staglio = "before";
S.fridgeH = 0;
S.roomH = doughH;
return;
}
S.seq = "coldwarm";
S.staglio = "after";
S.roomH = 4;
S.fridgeH = Math.max(4, Math.round(doughH - S.roomH));
const total = S.roomH + S.fridgeH;
S.plan = total <= 18 ? "night" : total <= 30 ? "d24" : total <= 54 ? "d48" : "d72";
}
function initUI(){
buildFlourSelect();
chips($("uxModes"), UX_MODES, "uxMode", k=>{
if (k==="simple") S.mode="w";
});
chips($("styles"), STYLES, "style", k=>{
const st=STYLES[k];
S.ball=st.ball; S.hyd=st.hyd; S.salt=st.salt; S.oil=st.oil; S.sugar=st.sugar; S.mode=st.mode;
S.ball=clampBall(st.ball); S.hyd=st.hyd; S.salt=st.salt; S.oil=st.oil; S.sugar=st.sugar; S.mode=st.mode;
if (S.uxMode==="simple") S.mode="w";
});
chips($("modes"), MODES, "mode");
chips($("yeastTypes"), YEASTS, "yeast");
@@ -807,9 +912,11 @@ function initUI(){
});
$("ballsMinus").onclick=()=>{ S.balls=Math.max(1,S.balls-1); render(); };
$("ballsPlus").onclick =()=>{ S.balls=Math.min(60,S.balls+1); render(); };
$("bakeAt").addEventListener("input", e=>{
const bakeInputHandler = e=>{
if (e.target.value){ S.bake=new Date(e.target.value).getTime(); render(); }
});
};
$("bakeAt").addEventListener("input", bakeInputHandler);
$("bakeAtQuick").addEventListener("input", bakeInputHandler);
$("applyFlour").onclick=()=>{
const f=FLOURS.find(x=>x.id===S.flour);
@@ -823,16 +930,20 @@ function initUI(){
$("share").onclick=share;
$("copyTxt").onclick=copyText;
$("pdf").onclick=savePdf;
$("reset").onclick=()=>{ S=Object.assign({},DEFAULT); S.bake=defaultBake(); location.hash=""; render(); };
$("reset").onclick=()=>{ if(!confirm("Alle Werte zurücksetzen?")) return; S=Object.assign({},DEFAULT); S.bake=defaultBake(); location.hash=""; render(); };
}
function syncInputs(){
document.body.dataset.ux = S.uxMode;
S.ball = clampBall(S.ball);
if (S.uxMode==="simple" && S.mode!=="w") S.mode="w";
applySimpleFermentation();
RANGES.forEach(id=>{ $(id).value=S[id]; });
$("ballsOut").textContent=S.balls;
$("flour").value=S.flour;
["styles","modes","yeastTypes","pfTypes","plans","seqs","staglios"].forEach(h=>{
const key={styles:"style",modes:"mode",yeastTypes:"yeast",pfTypes:"pf",plans:"plan",seqs:"seq",staglios:"staglio"}[h];
const keys=Object.keys({styles:STYLES,modes:MODES,yeastTypes:YEASTS,pfTypes:PFS,plans:PLANS,seqs:SEQ,staglios:STAGLIO}[h]);
["uxModes","styles","modes","yeastTypes","pfTypes","plans","seqs","staglios"].forEach(h=>{
const key={uxModes:"uxMode",styles:"style",modes:"mode",yeastTypes:"yeast",pfTypes:"pf",plans:"plan",seqs:"seq",staglios:"staglio"}[h];
const keys=Object.keys({uxModes:UX_MODES,styles:STYLES,modes:MODES,yeastTypes:YEASTS,pfTypes:PFS,plans:PLANS,seqs:SEQ,staglios:STAGLIO}[h]);
[...$(h).children].forEach((b,i)=>b.setAttribute("aria-pressed", S[key]===keys[i]));
});
$("seqNote").textContent = SEQ[S.seq].note;
@@ -859,7 +970,9 @@ function syncInputs(){
$("v-pfT").innerHTML = n1(S.pfT)+" <small>°C</small>";
const d=new Date(S.bake);
$("bakeAt").value = new Date(d.getTime()-d.getTimezoneOffset()*60e3).toISOString().slice(0,16);
const bakeVal = new Date(d.getTime()-d.getTimezoneOffset()*60e3).toISOString().slice(0,16);
$("bakeAt").value = bakeVal;
$("bakeAtQuick").value = bakeVal;
}
function renderFlour(r){
@@ -880,7 +993,7 @@ function renderWarnings(f,r){
const w=[];
if (S.flour!=="custom"){
if (r.U > f.reife[1]*1.15)
w.push(["bad",`Dein Plan entspricht rund ${Math.round(r.U)} h Reife bei 20 °C. ${f.n} trägt etwa ${f.reife[1]} h — der Teig baut ab und wird schlaff. Kürzer führen oder ein backstärkeres Mehl nehmen.`]);
w.push(["bad",`<b>Zu lange Gare für dieses Mehl.</b><br>Dein Plan entspricht rund ${Math.round(r.U)} h Reife bei 20 °C. ${f.n} trägt etwa ${f.reife[1]} h. Empfehlung: kürzer führen, Kühlschrank kälter stellen oder ein backstärkeres Mehl wählen.`]);
else if (r.U < f.reife[0]*0.6)
w.push(["",`Nur rund ${Math.round(r.U)} h Reifeäquivalent. ${f.n} kann deutlich mehr — mit längerer Gare wird der Teig aromatischer und bekömmlicher.`]);
if (S.hyd > f.hyd[1]+3)
@@ -890,6 +1003,8 @@ function renderWarnings(f,r){
if (r.hasPf && !f.vt)
w.push(["",`${f.n} ist für Vorteige eher schwach. Poolish und Biga verlangen mehr Backstärke.`]);
}
if ((S.style==="napo" || S.style==="contemp") && S.seq==="room" && S.roomH<4)
w.push(["",`<b>Schnellteig:</b> Unter 4 h braucht neapolitanischer Teig vergleichsweise viel Hefe und entwickelt weniger Aroma. Wenn möglich, mindestens 48 h einplanen.`]);
if (S.fridgeT<4) w.push(["",`Unter 4 °C stellt die Hefe die Arbeit fast ein. 56 °C sind für die Kühlschrankgare der bessere Bereich.`]);
if (S.seq==="warmcold" && S.staglio==="after" && S.fridgeH>0 && r.tcCold > S.fridgeH*0.4)
w.push(["",`Die Masse braucht rund ${Math.round(r.tcCold)} h zum Durchkühlen — bei ${S.fridgeH} h Kaltgare gärt der Kern also die halbe Zeit deutlich wärmer als ${n1(S.fridgeT)} °C. Teig flach drücken oder auf mehrere Behälter aufteilen, sonst wird das Ergebnis schwer wiederholbar.`]);
@@ -897,7 +1012,8 @@ function renderWarnings(f,r){
w.push(["",`Bei der Lehmann-Methode ist die Gare im Kühlschrank abgeschlossen. ${n1(S.roomH)} h warm danach sind viel — der Teig übergärt leicht. 0,5 bis 2 h reichen, um ihn dehnbar zu bekommen.`]);
if (S.seq==="coldwarm" && S.roomH<0.5)
w.push(["",`Ohne Aufwärmzeit ist der Teig sehr elastisch und wehrt sich beim Öffnen. Ab etwa 10 °C Kerntemperatur wird er dehnbar.`]);
if (!r.isSour && r.yPct<0.03) w.push(["",`Die Hefemenge ist rechnerisch winzig. Ohne Feinwaage mit 0,01 g Auflösung wird das Raten — kürzere Gare oder Vorteig sind praktikabler.`]);
const yeastG = r.hasPf ? r.pf.yeast+r.main.yeast : r.main.yeast;
if (!r.isSour && yeastG<0.5) w.push(["",`Nur ${yeastG.toFixed(2).replace(".",",")} g Hefe: Für reproduzierbare Ergebnisse eine Feinwaage mit 0,01 g Auflösung verwenden oder die Hefe in Wasser lösen und anteilig dosieren.`]);
if (!r.isSour && r.yPct>3) w.push(["",`Sehr viel Hefe. Der Teig geht schnell, aber flach im Geschmack. Mehr Zeit gibt mehr Aroma.`]);
if (r.hasPf && r.main.water<0)
w.push(["bad",`Der Vorteig enthält mehr Wasser als das ganze Rezept hergibt. Vorteig-Anteil senken oder Hydration erhöhen.`]);
@@ -970,6 +1086,20 @@ function renderTimeline(r){
return tl;
}
function renderQuickSummary(r){
const tl = lastTl || timeline(S,r);
const start = tl.start;
const items = [
`<span><b>${STYLES[S.style].n}</b></span>`,
`<span><b>${S.balls} × ${Math.round(r.ballW)} g</b> Teiglinge</span>`,
`<span>Backen <b>${fmtT(new Date(S.bake))} ${fmtDay(new Date(S.bake))}</b></span>`,
`<span>Start <b>${fmtT(start)} ${fmtDay(start)}</b></span>`
];
if (S.uxMode!=="simple") items.splice(2,0,`<span><b>${Math.round(r.flour)} g</b> Mehl</span>`);
$("quickSummary").innerHTML = items.join("");
}
/* ============================================================
RENDER
============================================================ */
@@ -982,6 +1112,7 @@ function render(){
renderWarnings(f,r);
renderRecipe(r);
lastTl=renderTimeline(r);
renderQuickSummary(r);
renderPrint(r);
$("doughsum").textContent = `${S.balls} × ${Math.round(r.ballW)} g = ${Math.round(r.total)} g`;
@@ -994,13 +1125,14 @@ function render(){
/* ============================================================
URL-SHARING
============================================================ */
const KEYS=["style","flour","balls","mode","ball","dia","panL","panW","hyd","salt","oil","sugar","yeast","plan","seq","staglio","roomH","roomT","fridgeH","fridgeT","pf","pfPct","pfH","pfT"];
const KEYS=["style","flour","balls","mode","uxMode","ball","dia","panL","panW","hyd","salt","oil","sugar","yeast","plan","seq","staglio","roomH","roomT","fridgeH","fridgeT","pf","pfPct","pfH","pfT"];
let hashLock=false;
function writeHash(){
const p=new URLSearchParams();
KEYS.forEach(k=>{ if (S[k]!==DEFAULT[k]) p.set(k,S[k]); });
hashLock=true;
history.replaceState(null,"", p.toString()? "#"+p.toString() : location.pathname);
const baseUrl = window.location.pathname + window.location.search;
history.replaceState(null,"", p.toString()? "#"+p.toString() : baseUrl);
setTimeout(()=>hashLock=false,0);
}
function readHash(){
@@ -1012,6 +1144,8 @@ function readHash(){
S[k] = (typeof DEFAULT[k]==="number") ? parseFloat(v) : v;
});
if (!STYLES[S.style]) S.style="napo";
if (!UX_MODES[S.uxMode]) S.uxMode="simple";
S.ball = clampBall(S.ball);
if (!PLANS[S.plan]) S.plan="free";
if (p.has("method")){ // alte Links: method=room|cold
S.seq = p.get("method")==="room" ? "room" : "warmcold";
@@ -1102,6 +1236,15 @@ function copyText(){
window.addEventListener("hashchange",()=>{ if(!hashLock){ readHash(); render(); }});
setInterval(()=>{ if(lastR) renderTimeline(lastR); }, 30000);
/* iOS Safari zoomt sonst bei schnellem Doppeltippen auf Buttons/Slider.
Pinch-Zoom bleibt unberührt; verhindert wird nur der Double-Tap-Default. */
let lastTouchEnd = 0;
document.addEventListener("touchend", e=>{
const now = Date.now();
if (now - lastTouchEnd <= 320) e.preventDefault();
lastTouchEnd = now;
}, {passive:false});
/* ---------- Start ---------- */
readHash();
initUI();
+78
View File
@@ -0,0 +1,78 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function between(start, end) {
const from = html.indexOf(start);
const to = html.indexOf(end, from);
assert.ok(from >= 0 && to > from, `Quellbereich fehlt: ${start}${end}`);
return html.slice(from, to);
}
const core = between('const STYLES =', '/* ============================================================\n ZEITPLAN');
const simple = between('function applySimpleFermentation()', 'function initUI()');
const warnings = between('function renderWarnings(', 'function renderRecipe(');
const fixedNow = 1_700_000_000_000;
class FixedDate extends Date {
static now() { return fixedNow; }
}
const warningHost = { innerHTML: '' };
const sandbox = {
Date: FixedDate,
console,
$: id => id === 'warns' ? warningHost : {},
pct: x => `${x} %`,
n1: x => String(x),
};
vm.createContext(sandbox);
vm.runInContext(`${core}\n${simple}\n${warnings}\nglobalThis.api={STYLES,DEFAULT,calc,applySimpleFermentation,renderWarnings,setState:s=>S=s,getState:()=>S};`, sandbox);
const { STYLES, DEFAULT, calc, applySimpleFermentation, renderWarnings, setState, getState } = sandbox.api;
function state(overrides = {}) {
return { ...DEFAULT, pf: 'none', yeast: 'fresh', balls: 4, ball: 250, mode: 'w', ...overrides };
}
// Napoletana und Contemporanea werden bewusst niedriger kalibriert.
assert.equal(STYLES.napo.yeastK, 1.5);
assert.equal(STYLES.contemp.yeastK, 1.7);
// 8 h warme Napoletana bei 20 °C: rund 0,19 % Frischhefe statt bisher 0,76 %.
const napo8 = calc(state({ style: 'napo', seq: 'room', roomH: 8, roomT: 20, fridgeH: 0 }));
assert.ok(Math.abs(napo8.yPct - 0.190875) < 1e-9, `unerwartet: ${napo8.yPct}%`);
assert.ok(napo8.main.yeast > 1.1 && napo8.main.yeast < 1.2, `unerwartet: ${napo8.main.yeast} g`);
// Nicht neu kalibrierte Stile behalten den bisherigen Referenzfaktor 6.
const ny8 = calc(state({ style: 'ny', salt: 2, seq: 'room', roomH: 8, roomT: 20, fridgeH: 0 }));
assert.equal(ny8.yPct, 0.75);
// Lange Führung im Einfach-Modus endet mit 4 h warmer Stückgare.
setState(state({ uxMode: 'simple', bake: fixedNow + 24.75 * 3600e3 }));
applySimpleFermentation();
assert.equal(getState().seq, 'coldwarm');
assert.equal(getState().roomH, 4);
assert.equal(getState().fridgeH, 20);
setState(state({ uxMode: 'simple', bake: fixedNow + 48.75 * 3600e3 }));
applySimpleFermentation();
assert.equal(getState().roomH, 4);
assert.equal(getState().fridgeH, 44);
// Unter 4 h wird Napoletana ausdrücklich als Schnellteig gekennzeichnet.
const napo3State = state({ style: 'napo', seq: 'room', roomH: 3, roomT: 20, fridgeH: 0 });
setState(napo3State);
const napo3 = calc(napo3State);
renderWarnings({ n: 'Testmehl', reife: [0, 120], hyd: [50, 90], vt: true }, napo3);
assert.match(warningHost.innerHTML, /Schnellteig/);
// Kleine absolute Hefemengen nennen die Feinwaage, auch wenn der Prozentwert über 0,03 % liegt.
const napo24State = state({ style: 'napo', seq: 'room', roomH: 24, roomT: 20, fridgeH: 0 });
setState(napo24State);
const napo24 = calc(napo24State);
assert.ok(napo24.main.yeast < 0.5 && napo24.yPct > 0.03);
renderWarnings({ n: 'Testmehl', reife: [0, 120], hyd: [50, 90], vt: true }, napo24);
assert.match(warningHost.innerHTML, /Feinwaage/);
console.log('yeast calibration regression tests: OK');