15 Commits
Author SHA1 Message Date
Hermes Agent bf67396f94 style: use responsive desktop workspace
CI and Deploy / test-build-deploy (push) Successful in 4s
2026-08-03 15:00:13 +00:00
Hermes Agent 3e6314523d style: enlarge PDF dashboard typography
CI and Deploy / test-build-deploy (push) Successful in 4s
2026-08-03 14:47:57 +00:00
Hermes Agent 07e7bf25c6 style: redesign PDF as full-page cyberpunk dashboard
CI and Deploy / test-build-deploy (push) Successful in 4s
2026-08-03 14:44:34 +00:00
Hermes Agent 9f8e130d41 style: render PDF in cyberpunk A4 layout
CI and Deploy / test-build-deploy (push) Successful in 4s
2026-08-03 14:33:20 +00:00
Hermes Agent 405170a90c feat: add direct PDF recipe download
CI and Deploy / test-build-deploy (push) Successful in 4s
2026-08-03 14:25:38 +00:00
Hermes Agent f5632f4f49 docs: add CI status badge
CI and Deploy / test-build-deploy (push) Successful in 4s
2026-08-03 14:15:22 +00:00
Hermes Agent 390bff74c2 ci: add Gitea Actions deployment pipeline 2026-08-03 14:10:38 +00:00
root 768102473c fix: recalibrate Neapolitan yeast for v1.1 2026-07-26 08:52:58 +00:00
Hermes Agent c0640068fc Auto-select simple fermentation schedule 2026-07-26 07:26:14 +00:00
Hermes Agent 47c295ec10 Limit dough ball weight range to 150-400g 2026-07-25 16:01:17 +00:00
Hermes Agent 1a8a445a78 Prevent iOS double-tap zoom 2026-07-25 15:57:17 +00:00
Hermes Agent 47d7f91604 Make sticky summary two-line on mobile 2026-07-25 15:40:48 +00:00
Hermes Agent 956946658f Simplify beginner mode to essential dough inputs 2026-07-25 14:46:27 +00:00
Hermes Agent 96584c970a Harden URL hash handling for UX mode 2026-07-25 14:39:19 +00:00
Hermes Agent 60d6361ecf Improve readability and add simple/pro UX mode 2026-07-25 14:35:06 +00:00
7 changed files with 771 additions and 49 deletions
+2
View File
@@ -0,0 +1,2 @@
100.127.13.115 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC7xaGuqQN2FIGM/dhnulSiqFVUfsfulIUY1yTYoJrhB
10.10.2.80 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDd7LYWCbMt1hl7f4ttNL3J8Ich3v/SRWXc9X8inn5Sv
+98
View File
@@ -0,0 +1,98 @@
name: CI and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
test-build-deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Repository auschecken
uses: actions/checkout@v4
- name: Regressionstests ausführen
run: |
for test in tests/*.test.js; do
node "$test"
done
- name: JavaScript-Syntax prüfen
run: |
python3 - <<'PY'
from pathlib import Path
html = Path('index.html').read_text(encoding='utf-8')
start = html.index('<script>') + len('<script>')
end = html.index('</script>', start)
Path('/tmp/teig-terminal.js').write_text(html[start:end], encoding='utf-8')
PY
node --check /tmp/teig-terminal.js
- name: Statisches Release bauen
run: |
rm -rf dist
install -d -m 0755 dist
install -m 0644 index.html dist/index.html
test -s dist/index.html
grep -q 'TEIG//TERMINAL' dist/index.html
sha256sum dist/index.html
- name: Deploymentzugang vorbereiten
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
env:
TEIG_DEPLOY_KEY: ${{ secrets.TEIG_DEPLOY_KEY }}
run: |
install -d -m 0700 "$HOME/.ssh"
printf '%s\n' "$TEIG_DEPLOY_KEY" > "$HOME/.ssh/teig_deploy"
chmod 0600 "$HOME/.ssh/teig_deploy"
install -m 0644 .gitea/known_hosts "$HOME/.ssh/known_hosts"
cat > "$HOME/.ssh/config" <<'EOF'
Host deploy-jump
HostName 100.127.13.115
User gitea-jump
IdentityFile ~/.ssh/teig_deploy
IdentitiesOnly yes
StrictHostKeyChecking yes
UserKnownHostsFile ~/.ssh/known_hosts
RequestTTY no
Host teig-production
HostName 10.10.2.80
User teig-deploy
IdentityFile ~/.ssh/teig_deploy
IdentitiesOnly yes
ProxyJump deploy-jump
StrictHostKeyChecking yes
UserKnownHostsFile ~/.ssh/known_hosts
RequestTTY no
EOF
chmod 0600 "$HOME/.ssh/config"
- name: Nach VM220 deployen
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
run: |
set -o pipefail
tar -C dist -czf - index.html | ssh teig-production "deploy $GITHUB_SHA"
- name: Live-Deployment verifizieren
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
run: |
expected="$(sha256sum dist/index.html | cut -d' ' -f1)"
for attempt in $(seq 1 12); do
if curl -fsSL --max-time 20 https://teig.deploybar.de/ -o /tmp/teig-live.html; then
actual="$(sha256sum /tmp/teig-live.html | cut -d' ' -f1)"
if [ "$actual" = "$expected" ]; then
printf 'Live-Deployment verifiziert: %s\n' "$actual"
exit 0
fi
fi
sleep 5
done
printf 'Erwarteter SHA-256: %s\n' "$expected" >&2
printf 'Live SHA-256: %s\n' "${actual:-nicht abrufbar}" >&2
exit 1
+15
View File
@@ -1,11 +1,26 @@
# TEIG//TERMINAL
[![CI](https://git.deploybar.de/philipp/teig-terminal/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.deploybar.de/philipp/teig-terminal/actions?workflow=ci.yml)
Pizzateig-Kalkulator als statische Single-Page-App.
Aktuelle Version: **v1.1**
- Bäckerprozent
- Mehl-Datenbank
- Vorteig
- Rückwärts-Zeitplan
- Rezept-Link über URL-Hash
- Rezept direkt als einseitige DIN-A4-PDF im Cyberpunk-Stil herunterladen
Live: https://teig.deploybar.de/
## CI/CD
Gitea Actions prüft bei jedem Push und Pull Request automatisch:
- Regressionstests der Hefekalibrierung
- JavaScript-Syntax der Single-File-App
- Erstellung des statischen Release-Artefakts
Pushes auf `main` werden nach erfolgreichen Prüfungen automatisch nach VM220 deployt. Der Webserver erhält ausschließlich das geprüfte `dist/index.html`; anschließend vergleicht die Pipeline den SHA-256-Hash der Live-Seite mit dem gebauten Artefakt.
+474 -49
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,15 +255,53 @@ 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)}
/* ---------- Responsive Desktop-Arbeitsbereich ---------- */
.workspace{display:block}
.input-stack,.output-stack{min-width:0}
@media(max-width:1049px){
.workspace{display:block}
}
@media(min-width:1050px){
.wrap{max-width:1320px;padding:0 24px}
header{
display:grid;grid-template-columns:minmax(0,1fr) auto;
grid-template-areas:"brand status" "tag status";
column-gap:32px;align-items:end;padding:30px 0 20px
}
.brand{grid-area:brand;font-size:48px}
.tag{grid-area:tag}
.status{grid-area:status;align-self:center;justify-self:end;margin-top:0}
.quick-summary{
display:grid;grid-template-columns:repeat(5,minmax(0,1fr));
gap:10px;overflow:visible;margin:0 0 18px;padding:10px;
border:1px solid var(--line);background:rgba(7,8,11,.94)
}
body[data-ux="simple"] .quick-summary{grid-template-columns:repeat(4,minmax(0,1fr))}
.quick-summary span{min-width:0;white-space:normal;text-align:center;line-height:1.3}
.workspace{
display:grid;
grid-template-columns:minmax(0,1fr) minmax(430px,.95fr);
gap:18px;align-items:start
}
body[data-ux="simple"] .workspace{
grid-template-columns:minmax(0,.9fr) minmax(480px,1.1fr)
}
.workspace .panel{padding:19px 18px 21px;margin-bottom:18px}
.output-stack .hero{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
.output-stack .actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}
.output-stack .btn{width:100%}
}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
.printonly{display:none}
@media print{
@page{size:A4;margin:15mm}
body{background:#fff;color:#000;background-image:none;font-size:11pt;padding:0}
.wrap{max-width:none;padding:0}
.workspace{display:block}
header,.actions,.noprint,#countdown{display:none!important}
.printonly{display:block}
#printhead{margin:0 0 8mm;border-bottom:2px solid #000;padding-bottom:4mm}
@@ -253,9 +342,28 @@ 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>
<main class="workspace">
<div class="input-stack">
<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 +379,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 +387,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 +413,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 +424,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 +451,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 +493,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>
@@ -406,6 +514,9 @@ footer b{color:var(--txt)}
</div>
</section>
</div>
<div class="output-stack">
<!-- WARNUNGEN -->
<div class="warns" id="warns"></div>
@@ -423,7 +534,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,12 +545,16 @@ 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="downloadPdf">PDF herunterladen</button>
<button type="button" class="btn" id="pdf">Drucken</button>
<button type="button" class="btn" id="copyTxt">Als Text kopieren</button>
<button type="button" class="btn" id="reset">Zurücksetzen</button>
</div>
</div>
</main>
<footer>
<b>Hinweis:</b> Die Hefemenge wird über ein Q10-Modell aus Zeit und Temperatur geschätzt.
Teigtemperatur, Hefevitalität und Schwankungen im Raum lassen sich nicht in eine Formel pressen —
@@ -486,10 +601,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 +630,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 +640,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 +656,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 +718,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 +899,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 +960,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);
@@ -822,17 +977,22 @@ function initUI(){
$("share").onclick=share;
$("copyTxt").onclick=copyText;
$("downloadPdf").onclick=downloadPdf;
$("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 +1019,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 +1042,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 +1052,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 +1061,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 +1135,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 +1161,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 +1174,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 +1193,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";
@@ -1042,6 +1225,236 @@ async function toClipboard(text, btn, label){
}
function share(){ toClipboard(location.href, $("share"), "Link kopiert ✓"); }
/* PDF-DOWNLOAD */
function pdfFilename(styleName, balls, ballWeight, planName){
return `Pizzateig-${styleName}-${balls}x${Math.round(ballWeight)}g-${planName}.pdf`
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g,"")
.replace(/[\/\\:*?"<>|]+/g,"-")
.replace(/\s+/g,"-")
.replace(/-+/g,"-");
}
function winAnsiHex(text){
const special={"€":128,"":130,"ƒ":131,"„":132,"…":133,"†":134,"‡":135,"ˆ":136,"‰":137,"Š":138,"":139,"Œ":140,"Ž":142,"":145,"":146,"“":147,"”":148,"•":149,"":150,"—":151,"˜":152,"™":153,"š":154,"":155,"œ":156,"ž":158,"Ÿ":159};
let out="";
for(const char of String(text)){
const point=char.codePointAt(0);
const byte=special[char] ?? (point<=255 ? point : 63);
out+=byte.toString(16).padStart(2,"0").toUpperCase();
}
return out;
}
function wrapPdfLines(lines, width=92){
const out=[];
lines.forEach(raw=>{
const line=String(raw);
if(!line){ out.push(""); return; }
const indent=(line.match(/^\s*/)||[""])[0];
let rest=line.trim();
while(rest.length>width){
let cut=rest.lastIndexOf(" ",width);
if(cut<20) cut=width;
out.push(indent+rest.slice(0,cut));
rest=rest.slice(cut).trimStart();
}
out.push(indent+rest);
});
return out;
}
function parsePdfRecipe(lines){
const source=lines.map(line=>String(line));
const title=source[0]||"PIZZATEIG";
const overview=[];
const sections=[];
let current=null, link="";
source.slice(1).forEach(raw=>{
const line=raw.trim();
if(!line) return;
if(/^https?:\/\//.test(line)){ link=line; return; }
if(/^(ZUTATEN|HAUPTTEIG|ZEITPLAN|VORTEIG\b)/.test(line)){
current={title:line,items:[]};
sections.push(current);
return;
}
if(current) current.items.push(line);
else overview.push(...line.split(" · ").map(part=>part.trim()).filter(Boolean));
});
if(overview.length>4) sections.unshift({title:"WEITERE DATEN",items:overview.slice(4)});
return {title,overview:overview.slice(0,4),sections,link};
}
function buildPdfBytes(lines){
const encoder=new TextEncoder();
const data=parsePdfRecipe(lines);
const ingredients=data.sections.filter(section=>section.title!=="ZEITPLAN");
const timeline=data.sections.find(section=>section.title==="ZEITPLAN")||{title:"ZEITPLAN",items:[]};
const capItems=(items,max)=>items.length>max?[...items.slice(0,max-1),"... weitere Angaben gekürzt"]:items;
let stream="";
const rect=(x,y,w,h,fill,stroke="",lineWidth=.7)=>{
if(fill) stream+=`${fill} rg ${x} ${y} ${w} ${h} re f\n`;
if(stroke) stream+=`${stroke} RG ${lineWidth} w ${x} ${y} ${w} ${h} re S\n`;
};
const line=(x1,y1,x2,y2,color,width=.7)=>{ stream+=`${color} RG ${width} w ${x1} ${y1} m ${x2} ${y2} l S\n`; };
const text=(value,x,y,size,font="F1",color="0.886 0.925 0.961")=>{
stream+=`BT\n/${font} ${size} Tf\n${color} rg\n${x} ${y} Td\n<${winAnsiHex(value)}> Tj\nET\n`;
};
const wrapped=(value,width)=>wrapPdfLines([String(value)],width);
// Vollflächige Cyberpunk-Bühne mit Raster, Scanlines und Neonrahmen.
rect(0,0,595,842,"0.027 0.031 0.043");
rect(0,704,595,138,"0.039 0.051 0.078");
stream+="0.047 0.071 0.102 RG 0.25 w\n";
for(let x=18;x<595;x+=24) stream+=`${x} 0 m ${x} 842 l S\n`;
for(let y=8;y<842;y+=24) stream+=`0 ${y} m 595 ${y} l S\n`;
stream+="0.075 0.094 0.133 RG 0.18 w\n";
for(let y=3;y<842;y+=6) stream+=`0 ${y} m 595 ${y} l S\n`;
rect(18,18,559,806,"","0.12 0.23 0.30",.6);
line(18,823,577,823,"0 0.941 1",2.2);
line(18,816,170,816,"1 0.176 0.471",4.5);
line(424,26,577,26,"1 0.176 0.471",3);
// Kopf: doppelte Neon-Typografie, Statusblock und technische Marker.
text("TEIG//TERMINAL",35,778,29,"F2","0.23 0.02 0.19");
text("TEIG//TERMINAL",33,780,29,"F2","0 0.941 1");
text("PIZZA FORMULATION SYSTEM",35,765,7.5,"F1","1 0.176 0.471");
rect(414,770,145,28,"0.055 0.071 0.106","1 0.176 0.471",.8);
rect(425,781,5,5,"0.545 1 0.231");
text("RECIPE // READY",438,780,8.2,"F2","0.922 0.965 1");
text("06",507,716,42,"F2","0.10 0.19 0.24");
text(data.title,34,724,16,"F2","0.922 0.965 1");
text("FORMULA EXPORT // DIN A4 // ONE PAGE",35,709,6.8,"F1","0.53 0.64 0.74");
// Vier Dashboard-Kacheln nutzen die gesamte Breite für die Kerndaten.
const cards=[34,167,300,433];
cards.forEach((x,index)=>{
rect(x,634,126,60,index%2?"0.055 0.071 0.106":"0.063 0.082 0.122",index===3?"1 0.176 0.471":"0 0.941 1",.65);
text(`DATA // 0${index+1}`,x+9,678,6.3,"F2",index===3?"1 0.176 0.471":"0 0.941 1");
const value=data.overview[index]||"—";
const parts=wrapped(value,23).slice(0,2);
parts.forEach((part,row)=>text(part,x+9,659-row*11,8.2,"F2","0.922 0.965 1"));
rect(x+113,637,10,3,index===3?"1 0.176 0.471":"0 0.941 1");
});
// Zwei große Module: Zutaten/Formel links, Prozess-Timeline rechts.
rect(34,112,238,500,"0.043 0.059 0.086","0 0.941 1",.75);
rect(286,112,273,500,"0.043 0.059 0.086","1 0.176 0.471",.75);
rect(34,568,238,44,"0.00 0.22 0.26");
rect(286,568,273,44,"0.25 0.035 0.15");
text("01",47,580,17,"F2","0 0.941 1");
text("FORMULA // ZUTATEN",79,584,10.5,"F2","0.922 0.965 1");
text("02",299,580,17,"F2","1 0.176 0.471");
text("PROCESS // ZEITPLAN",332,584,10.5,"F2","0.922 0.965 1");
let leftY=545, itemNo=1;
const ingredientRows=[];
ingredients.forEach(section=>{
ingredientRows.push({heading:section.title});
capItems(section.items,12).forEach(item=>ingredientRows.push({item}));
});
if(!ingredientRows.length) ingredientRows.push({heading:"ZUTATEN"},{item:"Keine Angaben"});
const ingredientCount=Math.max(1,ingredientRows.filter(row=>row.item).length);
const headingCount=ingredientRows.filter(row=>row.heading).length;
const leftStep=Math.max(28,Math.min(62,(390-headingCount*24)/ingredientCount));
ingredientRows.slice(0,15).forEach((row,index)=>{
if(row.heading){
text(row.heading,48,leftY,8.8,"F2","1 0.176 0.471");
line(48,leftY-7,255,leftY-7,"0.18 0.30 0.36",.45);
leftY-=24;
return;
}
const rowH=Math.max(25,leftStep-4);
rect(46,leftY-rowH+7,214,rowH,index%2?"0.055 0.071 0.106":"0.063 0.082 0.122");
rect(46,leftY-rowH+7,4,rowH,itemNo%2?"0 0.941 1":"1 0.176 0.471");
text(String(itemNo).padStart(2,"0"),57,leftY-8,8,"F2","0.35 0.52 0.61");
const amount=row.item.match(/^(.*\S)\s+([0-9][0-9.,]*\s*(?:g|%|ml))$/i);
if(amount){
const labelLines=wrapped(amount[1],17).slice(0,2);
labelLines.forEach((part,i)=>text(part,82,leftY-7-i*12,10.2,"F1","0.922 0.965 1"));
const amountX=Math.max(184,248-amount[2].length*7.2);
text(amount[2],amountX,leftY-8,12,"F2",itemNo%2?"0 0.941 1":"1 0.176 0.471");
}else{
const itemLines=wrapped(row.item,28).slice(0,2);
itemLines.forEach((part,i)=>text(part,78,leftY-6-i*12,9.8,"F1","0.922 0.965 1"));
}
leftY-=leftStep; itemNo++;
});
text("BAKER'S PERCENTAGE // MASS CONTROL",48,130,6.5,"F1","0.36 0.49 0.58");
for(let x=48;x<255;x+=9) rect(x,121,(x%27===0)?4:2,5,"0 0.941 1");
const steps=capItems(timeline.items,10);
const timelineTop=546, timelineBottom=150;
const slot=(timelineTop-timelineBottom)/Math.max(1,steps.length);
line(309,timelineBottom,309,timelineTop,"0 0.941 1",1.1);
steps.forEach((raw,index)=>{
const y=timelineTop-index*slot;
const parts=raw.split(/\s+—\s+/);
const when=parts.length>1?parts.shift():String(index+1).padStart(2,"0");
const action=parts.length?parts.join(" — "):raw;
rect(304,y-2,10,10,"0.043 0.059 0.086","0 0.941 1",1);
rect(307,y+1,4,4,index===steps.length-1?"1 0.176 0.471":"0 0.941 1");
text(when,324,y+1,10.5,"F2",index===steps.length-1?"1 0.176 0.471":"0 0.941 1");
const actionLines=wrapped(action,31).slice(0,2);
actionLines.forEach((part,row)=>text(part,324,y-13-row*12,9.3,"F1","0.922 0.965 1"));
if(index<steps.length-1) line(324,y-slot+9,545,y-slot+9,"0.12 0.19 0.24",.35);
});
if(!steps.length) text("Kein Zeitplan vorhanden",324,530,8.5,"F1","0.53 0.64 0.74");
text("FERMENTATION SEQUENCE // REVERSE SCHEDULE",304,130,6.5,"F1","0.45 0.39 0.57");
// Link-Zeile und Footer schließen die Seite wie ein System-Dashboard ab.
rect(34,61,525,35,"0.039 0.051 0.078","0.17 0.31 0.37",.5);
text("RECIPE LINK",46,82,6.3,"F2","1 0.176 0.471");
const linkLines=wrapped(data.link||"Lokales Rezept ohne Link",102).slice(0,2);
linkLines.forEach((part,index)=>text(part,111,80-index*8,5.7,"F1","0 0.941 1"));
text("SYS.ONLINE",34,38,6.5,"F2","0.545 1 0.231");
text("NO CLOUD // NO TRACKING // GENERATED LOCALLY",112,38,6.5,"F1","0.53 0.64 0.74");
text("A4 // 1 OF 1",484,38,6.5,"F2","1 0.176 0.471");
const objects=[];
objects[1]="<< /Type /Catalog /Pages 2 0 R >>";
objects[2]="<< /Type /Pages /Kids [6 0 R] /Count 1 >>";
objects[3]="<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>";
objects[4]="<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>";
objects[5]=`<< /Length ${encoder.encode(stream).length} >>\nstream\n${stream}endstream`;
objects[6]="<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 5 0 R >>";
const chunks=[encoder.encode("%PDF-1.4\n")], offsets=[0];
let length=chunks[0].length;
for(let id=1;id<objects.length;id++){
offsets[id]=length;
const chunk=encoder.encode(`${id} 0 obj\n${objects[id]}\nendobj\n`);
chunks.push(chunk); length+=chunk.length;
}
const xrefAt=length;
let tail=`xref\n0 ${objects.length}\n0000000000 65535 f \n`;
for(let id=1;id<objects.length;id++) tail+=`${String(offsets[id]).padStart(10,"0")} 00000 n \n`;
tail+=`trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
chunks.push(encoder.encode(tail)); length+=chunks[chunks.length-1].length;
const pdf=new Uint8Array(length);
let at=0;
chunks.forEach(chunk=>{ pdf.set(chunk,at); at+=chunk.length; });
return pdf;
}
function downloadPdf(){
const r=lastR, st=STYLES[S.style], btn=$("downloadPdf");
const blob=new Blob([buildPdfBytes(recipeText().split("\n"))],{type:"application/pdf"});
const url=URL.createObjectURL(blob), link=document.createElement("a");
link.href=url;
link.download=pdfFilename(st.n,S.balls,r.ballW,PLANS[S.plan].n);
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(()=>URL.revokeObjectURL(url),1000);
const old=btn.textContent;
btn.textContent="PDF geladen ✓";
setTimeout(()=>btn.textContent=old,1800);
}
/* PDF = Druckdialog mit sprechendem Dateinamen. Browser und Handys
bieten dort „Als PDF sichern" an — ganz ohne externe Bibliothek. */
const ORIG_TITLE=document.title;
@@ -1071,7 +1484,7 @@ function renderPrint(r){
`Erstellt am ${d.getDate()}.${d.getMonth()+1}.${d.getFullYear()} mit TEIG//TERMINAL · Rezept erneut öffnen: ${location.href}`;
}
function copyText(){
function recipeText(){
const r=lastR, tl=lastTl, st=STYLES[S.style], f=FLOURS.find(x=>x.id===S.flour);
const L=[];
L.push(`PIZZATEIG — ${st.n}`);
@@ -1096,12 +1509,24 @@ function copyText(){
tl.steps.forEach(s=>L.push(` ${fmtT(s.at)} ${fmtDay(s.at)}${s.a}${s.d?` (${dur(s.d)})`:""}`));
L.push("");
L.push(location.href);
toClipboard(L.join("\n"), $("copyTxt"), "Text kopiert ✓");
return L.join("\n");
}
function copyText(){
toClipboard(recipeText(), $("copyTxt"), "Text kopiert ✓");
}
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();
+82
View File
@@ -0,0 +1,82 @@
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');
assert.match(html, /id="downloadPdf"[^>]*>PDF herunterladen<\/button>/, 'PDF-Download-Button fehlt');
const start = html.indexOf('/* PDF-DOWNLOAD */');
const end = html.indexOf('/* PDF = Druckdialog', start);
assert.ok(start >= 0 && end > start, 'PDF-Download-Implementierung fehlt');
const sandbox = { Uint8Array, TextEncoder, Blob };
vm.createContext(sandbox);
vm.runInContext(`${html.slice(start, end)}\nglobalThis.api={pdfFilename,parsePdfRecipe,buildPdfBytes};`, sandbox);
const { pdfFilename, parsePdfRecipe, buildPdfBytes } = sandbox.api;
assert.equal(
pdfFilename('Napoletana', 4, 250, '24 Stunden'),
'Pizzateig-Napoletana-4x250g-24-Stunden.pdf'
);
assert.equal(
pdfFilename('Frei/Test', 2, 199.6, 'Same:Day'),
'Pizzateig-Frei-Test-2x200g-Same-Day.pdf'
);
assert.equal(
pdfFilename('Römisch tonda', 4, 200, 'Über Nacht'),
'Pizzateig-Romisch-tonda-4x200g-Uber-Nacht.pdf'
);
const recipeLines = [
'PIZZATEIG — Napoletana',
'4 Teiglinge à 250 g · 1.000 g Teig',
'Mehl: Caputo Pizzeria · Hydration 62 %',
'',
'ZUTATEN',
' Mehl 605 g',
' Wasser 375 g',
' Frischhefe 2,3 g',
' Salz 17 g',
'',
'ZEITPLAN',
' 19:00 Mo — Teig kneten und ruhen lassen (30 Min.)',
' 19:30 Mo — Ballen formen und bei Raumtemperatur reifen lassen',
' 23:00 Mo — Ballen in den Kühlschrank stellen',
' 15:00 Di — Ballen aus dem Kühlschrank nehmen',
' 19:00 Di — Pizza backen',
'',
'https://teig.deploybar.de/#style=napo&balls=4&ball=250&plan=d24',
];
const parsed = JSON.parse(JSON.stringify(parsePdfRecipe(recipeLines)));
assert.equal(parsed.title, 'PIZZATEIG — Napoletana');
assert.deepEqual(parsed.overview, ['4 Teiglinge à 250 g', '1.000 g Teig', 'Mehl: Caputo Pizzeria', 'Hydration 62 %']);
assert.deepEqual(parsed.sections.map(section => section.title), ['ZUTATEN', 'ZEITPLAN']);
assert.equal(parsed.sections[0].items.length, 4);
assert.equal(parsed.sections[1].items.length, 5);
assert.match(parsed.link, /^https:\/\/teig\.deploybar\.de\//);
const bytes = buildPdfBytes(recipeLines);
assert.ok(bytes instanceof Uint8Array);
const ascii = Buffer.from(bytes).toString('latin1');
assert.ok(ascii.startsWith('%PDF-1.4'));
assert.match(ascii, /\/Type \/Catalog/);
assert.match(ascii, /\/Type \/Page\b/);
assert.match(ascii, /\/Encoding \/WinAnsiEncoding/);
assert.match(ascii, /0\.027 0\.031 0\.043 rg/); // dunkler Cyberpunk-Hintergrund
assert.match(ascii, /0 0\.941 1 rg/); // Cyan-Akzent
assert.match(ascii, /1 0\.176 0\.471 rg/); // Magenta-Akzent
assert.match(ascii, /xref\n0 \d+/);
assert.match(ascii, /%%EOF\n$/);
assert.match(ascii, /\/Count 1\b/);
const longPdf = Buffer.from(buildPdfBytes([
'PIZZATEIG — Belastungstest',
...Array.from({ length: 120 }, (_, i) => `ZEILE ${i + 1} — Rezeptinformation`),
])).toString('latin1');
assert.match(longPdf, /\/Count 1\b/, 'PDF muss auf genau eine Seite begrenzt bleiben');
assert.equal((longPdf.match(/\/Type \/Page\b/g) || []).length, 1, 'PDF enthält mehr als eine A4-Seite');
fs.writeFileSync('/tmp/teig-terminal-download-test.pdf', Buffer.from(bytes));
console.log('pdf download tests: OK');
+22
View File
@@ -0,0 +1,22 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
assert.match(html, /<main class="workspace">[\s\S]*<div class="input-stack">/,
'Desktop-Arbeitsbereich mit Eingabespalte fehlt');
assert.match(html, /<div class="output-stack">[\s\S]*06 \/\/ Rezept[\s\S]*07 \/\/ Zeitplan/,
'Rezept und Zeitplan müssen in der Ausgabespalte liegen');
assert.match(html, /@media\s*\(min-width:\s*1050px\)[\s\S]*?\.wrap\s*\{[^}]*max-width:\s*1320px/,
'Breiter Desktop-Container fehlt');
assert.match(html, /@media\s*\(min-width:\s*1050px\)[\s\S]*?\.workspace\s*\{[^}]*display:\s*grid[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+minmax\(430px,\s*\.95fr\)/,
'Responsive Zweispaltenstruktur für Desktop fehlt');
assert.match(html, /@media\s*\(min-width:\s*1050px\)[\s\S]*?\.quick-summary\s*\{[^}]*grid-template-columns:\s*repeat\(5,\s*minmax\(0,\s*1fr\)\)/,
'Desktop-Profiübersicht muss die Breite in fünf Spalten nutzen');
assert.match(html, /body\[data-ux="simple"\]\s+\.quick-summary\s*\{[^}]*grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\)/,
'Desktop-Einfachübersicht muss ihre vier Werte gleichmäßig verteilen');
assert.match(html, /@media\s*\(max-width:\s*1049px\)[\s\S]*?\.workspace\s*\{[^}]*display:\s*block/,
'Tablet und Mobile müssen einspaltig bleiben');
console.log('responsive desktop layout tests: OK');
+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');