maboto/src/angebote/web_static/index.html
Jeuner 4b833f3785 Branding: App heißt MABOTO (Marktbeobachtungstool) + Icon/Favicon
- Eigenes Icon: Lupe über aufsteigenden Preis-Balken (Beobachtung + Markt)
  in Markengrün, als skalierbares SVG. Favicon via /favicon.svg-Route
  (image/svg+xml, gecached) + Inline-Motiv im App-Bar-Logo.
- App-Bar-Wortmarke „MABOTO / Marktbeobachtung", Seitentitel, Intro-Kopf
  und FastAPI-App-Titel umbenannt; README-H1 + Doku-Screenshots neu.
- Tests: Index prüft jetzt auf MABOTO, neuer favicon.svg-Test (77 grün).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:09:32 +02:00

957 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0e5d42" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#13120d" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="favicon.svg" />
<title>MABOTO — Marktbeobachtungstool</title>
<meta name="description" content="MABOTO — Marktbeobachtungstool: ortskonkrete, händlerübergreifende Übersicht der wöchentlichen Angebote, nach Produktgruppen." />
<style>
/* ============================================================
Farb-Tokens — AAA-verifiziert (jedes Text-Paar >=7:1,
Steuer-Ränder >=3:1). Hell als Default, Dunkel adaptiv.
============================================================ */
:root {
--bg:#f4f2ee; --panel:#ffffff; --panel-2:#f6f4ef; --panel-3:#efece4;
--ink:#161510; --ink-2:#2f2d26; --muted:#504d45;
--brand:#0e5d42; --brand-text:#0c5238; --on-brand:#ffffff;
--brand-weak:#eef5f1; --brand-weak-line:#cfe3d9;
--warn:#684500; --warn-bg:#f8efd6; --warn-line:#e6cf97;
--err:#8a231d; --err-bg:#fbeae8; --err-line:#e6b8b2;
--price:#11221b;
--line:#e3ded5; --line-2:#eceae2; --ctrl:#8a8474;
--focus:#0e5d42;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s6:24px; --s8:32px; --s12:48px;
--radius:16px; --radius-sm:11px; --radius-pill:999px;
--appbar-h:60px;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--shadow-sm:0 1px 2px rgba(20,18,12,.06);
--shadow-md:0 10px 30px -12px rgba(20,18,12,.22);
--shadow-lg:0 20px 50px -20px rgba(20,18,12,.30);
--ring:0 0 0 3px var(--brand-weak), 0 0 0 1.5px var(--brand);
}
@media (prefers-color-scheme: dark) {
:root {
--bg:#13120d; --panel:#1d1b15; --panel-2:#23211a; --panel-3:#2a2820;
--ink:#f2efe7; --ink-2:#d6d2c6; --muted:#cbc6b7;
--brand:#3db188; --brand-text:#6cd2a4; --on-brand:#06130c;
--brand-weak:#18352a; --brand-weak-line:#2c5a47;
--warn:#f4cf86; --warn-bg:#3a2f12; --warn-line:#5c4a1e;
--err:#f0a59c; --err-bg:#3a1f1c; --err-line:#5e2e2a;
--price:#eaf7f0;
--line:#2e2c24; --line-2:#272519; --ctrl:#767161;
--focus:#6cd2a4;
--shadow-sm:0 1px 2px rgba(0,0,0,.4);
--shadow-md:0 12px 32px -14px rgba(0,0,0,.6);
--shadow-lg:0 24px 56px -22px rgba(0,0,0,.7);
--ring:0 0 0 3px rgba(108,210,164,.28), 0 0 0 1.5px var(--brand-text);
}
}
* { box-sizing:border-box; }
html { scroll-padding-top: calc(var(--appbar-h) + var(--s4)); }
body {
margin:0; background:var(--bg); color:var(--ink);
font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
-webkit-font-smoothing:antialiased; letter-spacing:-.003em;
}
@media (prefers-reduced-motion: reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
/* sichtbarer Fokus — überall, kräftig (WCAG 2.4.13/2.4.11) */
:focus { outline:none; }
:focus-visible { outline:none; box-shadow:var(--ring); border-radius:var(--radius-sm); }
a { color:var(--brand-text); }
/* ============ App-Bar (sticky, gibt App-Identität) ============ */
.appbar {
position:sticky; top:0; z-index:50; height:var(--appbar-h);
background:color-mix(in srgb, var(--panel) 86%, transparent);
-webkit-backdrop-filter:saturate(1.4) blur(10px); backdrop-filter:saturate(1.4) blur(10px);
border-bottom:1px solid var(--line);
}
.appbar-inner { max-width:1100px; margin:0 auto; height:100%; padding:0 var(--s6);
display:flex; align-items:center; gap:var(--s3); }
.brand { display:flex; align-items:center; gap:11px; font-weight:800; font-size:17px;
letter-spacing:-.02em; color:var(--ink); text-decoration:none; }
.brand .word { display:flex; flex-direction:column; line-height:1.04; }
.brand .word small { font-size:10px; font-weight:700; letter-spacing:.1em;
text-transform:uppercase; color:var(--muted); margin-top:1px; }
.brand .logo {
width:34px; height:34px; border-radius:10px; flex:none; display:grid; place-items:center;
background:linear-gradient(145deg,var(--brand),color-mix(in srgb,var(--brand) 70%, #000 12%));
color:var(--on-brand); font-size:18px; box-shadow:var(--shadow-sm);
}
.appbar .place {
margin-left:6px; display:inline-flex; align-items:center; gap:6px;
font-size:13px; font-weight:700; color:var(--brand-text);
background:var(--brand-weak); border:1px solid var(--brand-weak-line);
padding:5px 11px; border-radius:var(--radius-pill); font-variant-numeric:tabular-nums;
}
.appbar .place.leer { color:var(--muted); background:var(--panel-2); border-color:var(--line); }
.appbar .spacer { flex:1; }
.appbar .iconbtn {
width:40px; height:40px; border-radius:11px; border:1px solid var(--line);
background:var(--panel); color:var(--ink-2); display:grid; place-items:center;
cursor:pointer; font-size:17px; padding:0; min-height:0;
}
.appbar .iconbtn:hover { background:var(--panel-2); border-color:var(--ctrl); transform:none; box-shadow:none; }
/* ============ Intro ============ */
.intro { max-width:1100px; margin:0 auto; padding:var(--s8) var(--s6) 0; }
.intro .eyebrow {
font-size:12px; text-transform:uppercase; letter-spacing:.14em;
color:var(--brand-text); font-weight:800; margin:0 0 var(--s2);
}
.intro h1 { margin:0; font-size:clamp(1.8rem,3vw,2.5rem); line-height:1.05;
letter-spacing:-.03em; font-weight:800; }
.intro p { margin:var(--s3) 0 0; color:var(--muted); font-size:15.5px; max-width:64ch; text-wrap:pretty; }
.wrap { max-width:1100px; margin:0 auto; padding:var(--s6) var(--s6) var(--s12); }
.stages { display:grid; grid-template-columns:1fr; gap:var(--s4); }
/* ============ Stufen-Karten ============ */
.stage {
background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
box-shadow:var(--shadow-sm); overflow:hidden;
transition:opacity .2s, box-shadow .2s;
}
.stage.locked { opacity:.6; }
.stage-head {
display:flex; align-items:center; gap:var(--s4);
padding:var(--s4) var(--s6); border-bottom:1px solid var(--line-2);
background:linear-gradient(var(--panel),var(--panel-2));
}
.stage-num {
flex:none; width:32px; height:32px; border-radius:10px;
display:grid; place-items:center; font-weight:800; font-size:15px;
background:var(--brand); color:var(--on-brand); font-variant-numeric:tabular-nums;
box-shadow:var(--shadow-sm);
}
.stage.locked .stage-num { background:var(--ctrl); color:var(--panel); }
.stage-titles { flex:1; min-width:0; }
.stage-titles h2 { margin:0; font-size:16.5px; font-weight:800; letter-spacing:-.015em; }
.stage-titles .sub { margin:3px 0 0; font-size:13px; color:var(--muted); }
.stage-flag {
flex:none; font-size:11px; font-weight:700; letter-spacing:.03em;
text-transform:uppercase; padding:4px 10px; border-radius:var(--radius-pill);
background:var(--brand-weak); color:var(--brand-text); border:1px solid var(--brand-weak-line);
}
.stage-flag.llm { background:color-mix(in srgb, var(--brand-weak) 60%, var(--panel)); }
.stage-body { padding:var(--s6); }
.row { display:flex; flex-wrap:wrap; gap:var(--s4) var(--s6); align-items:end; }
.field { display:flex; flex-direction:column; gap:6px; }
.field label { font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); font-weight:700; }
input, select, button { font:inherit; }
input[type=text], input[type=password], select {
padding:11px 13px; border:1.5px solid var(--ctrl); border-radius:var(--radius-sm);
background:var(--panel); color:var(--ink); transition:border-color .15s, box-shadow .15s;
}
input::placeholder { color:var(--muted); opacity:1; }
input:focus-visible, select:focus-visible { box-shadow:var(--ring); border-color:var(--brand); }
input#plz { width:120px; font-variant-numeric:tabular-nums; font-size:19px; font-weight:800; letter-spacing:.06em; }
select#modell { min-width:280px; }
input#modellsuche { width:160px; }
button {
padding:12px 22px; border:1.5px solid var(--brand); border-radius:var(--radius-sm);
background:var(--brand); color:var(--on-brand); cursor:pointer; font-weight:700; min-height:46px;
transition:transform .12s ease, filter .15s, box-shadow .15s; white-space:nowrap;
}
button:hover:not(:disabled) { filter:brightness(1.06); transform:translateY(-1px); box-shadow:var(--shadow-md); }
button:active:not(:disabled) { transform:translateY(0); }
button.ghost { background:var(--panel); color:var(--brand-text); border-color:var(--ctrl); }
button.ghost:hover:not(:disabled) { background:var(--brand-weak); border-color:var(--brand); filter:none; }
button:disabled { opacity:.5; cursor:not-allowed; }
button.icon { padding:11px 15px; }
.hint { font-size:13px; color:var(--muted); margin-top:var(--s3); max-width:70ch; }
.hint.lock { color:var(--warn); display:flex; align-items:center; gap:6px; font-weight:600; }
/* Roh-Stand */
.rohstand { margin-top:var(--s4); padding:var(--s4); border:1px solid var(--line-2);
border-radius:var(--radius-sm); background:var(--panel-2); }
.rohstand.ok { border-color:var(--brand-weak-line); background:var(--brand-weak); }
.rohstand .line1 { display:flex; flex-wrap:wrap; gap:6px 16px; align-items:baseline; }
.rohstand .ok-dot { color:var(--brand-text); font-weight:800; }
.rohstand b { color:var(--ink); }
.rohstand .meta { color:var(--muted); font-size:13px; }
.rohstand .haendler-tags { margin-top:var(--s3); display:flex; flex-wrap:wrap; gap:6px; }
.tag { font-size:12px; background:var(--panel); color:var(--ink-2); padding:3px 10px;
border-radius:var(--radius-pill); border:1px solid var(--line); font-weight:600; }
details.rohliste { margin-top:var(--s3); }
details.rohliste > summary { cursor:pointer; font-size:13.5px; color:var(--brand-text); font-weight:700;
user-select:none; padding:var(--s2) var(--s2); border-radius:var(--radius-sm); display:inline-block; }
details.rohliste > summary:focus-visible { box-shadow:var(--ring); }
.rohtable { width:100%; border-collapse:collapse; margin-top:var(--s2); font-size:13.5px; }
.rohtable th { text-align:left; font-weight:700; color:var(--muted); font-size:11px; text-transform:uppercase;
letter-spacing:.05em; padding:6px 10px; border-bottom:1px solid var(--line); }
.rohtable td { padding:8px 10px; border-bottom:1px solid var(--line-2); vertical-align:top; }
.rohtable tr:last-child td { border-bottom:none; }
.rohtable .t-preis { text-align:right; font-variant-numeric:tabular-nums; font-weight:700; white-space:nowrap; color:var(--price); }
.rohtable .t-h { color:var(--muted); }
.rohtable .marke { color:var(--brand-text); font-weight:700; }
/* LLM-Konfig */
.config { border:1px solid var(--line); border-radius:var(--radius); background:var(--panel);
box-shadow:var(--shadow-sm); overflow:hidden; }
.config > summary { cursor:pointer; user-select:none; list-style:none;
display:flex; align-items:center; gap:var(--s3); padding:var(--s4) var(--s6); font-weight:800; font-size:15px; }
.config > summary::-webkit-details-marker { display:none; }
.config > summary:focus-visible { box-shadow:var(--ring); border-radius:var(--radius); }
.config > summary .chev { transition:transform .2s; color:var(--muted); }
.config[open] > summary .chev { transform:rotate(90deg); }
.config > summary .ico { font-size:15px; }
.config > summary .gewaehlt-chip { margin-left:auto; font-size:12px; font-weight:700; color:var(--brand-text);
background:var(--brand-weak); border:1px solid var(--brand-weak-line); border-radius:var(--radius-pill);
padding:4px 12px; max-width:46ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.config > summary .gilt { margin-left:var(--s3); font-weight:600; font-size:12px; color:var(--muted); }
.config-body { padding:0 var(--s6) var(--s6); border-top:1px solid var(--line-2); padding-top:var(--s4); }
.keyrow { width:100%; max-width:560px; }
.keyrow input { width:100%; }
/* Status / Fortschritt */
#status, #rohstatus { margin:var(--s4) 0 0; font-size:14.5px; }
#status:empty, #rohstatus:empty { margin:0; }
.err { background:var(--err-bg); border:1px solid var(--err-line); color:var(--err);
padding:var(--s4); border-radius:var(--radius-sm); white-space:pre-wrap;
font-family:var(--mono); font-size:13px; line-height:1.5; }
.pending { color:var(--muted); }
/* LLM-Arbeit SICHTBAR (Grundregel) */
@keyframes llm-spin { to { transform:rotate(360deg); } }
@keyframes llm-shimmer { 0%{background-position:-220px 0} 100%{background-position:220px 0} }
.llm-busy { display:flex; align-items:center; gap:var(--s4); margin-top:var(--s3);
padding:var(--s4); border-radius:var(--radius); background:var(--brand-weak); border:1px solid var(--brand-weak-line); }
.llm-busy .spin { flex:none; width:22px; height:22px; border:2.5px solid var(--brand-weak-line);
border-top-color:var(--brand); border-radius:50%; animation:llm-spin .8s linear infinite; }
.llm-busy .txt { flex:1; min-width:0; }
.llm-busy .txt .hl { font-weight:800; color:var(--brand-text); }
.llm-busy .txt small { display:block; color:var(--muted); font-size:12.5px; margin-top:2px; }
.llm-busy .bar2 { height:8px; border-radius:5px; background:var(--brand-weak-line); overflow:hidden; margin-top:8px; }
.llm-busy .bar2 > i { display:block; height:100%; background:var(--brand); border-radius:5px; transition:width .3s; }
.llm-busy.indet .bar2 > i { width:40%; background:linear-gradient(90deg,var(--brand-weak-line),var(--brand),var(--brand-weak-line));
background-size:220px 100%; animation:llm-shimmer 1.1s linear infinite; }
@media (prefers-reduced-motion: reduce){ .llm-busy .spin{ animation:none; } .llm-busy.indet .bar2 > i{ animation:none; width:100%; } }
/* ============ Ergebnis ============ */
#result { margin-top:var(--s8); }
header.ergebnis-header { position:sticky; top:var(--appbar-h); z-index:20; background:var(--bg);
padding-top:var(--s3); padding-bottom:var(--s3); margin-bottom:var(--s4); border-bottom:1px solid var(--line); }
.summary { display:flex; flex-wrap:wrap; gap:6px var(--s4); align-items:baseline; color:var(--muted);
font-size:14px; margin-bottom:var(--s3); }
.summary b { color:var(--ink); font-weight:700; }
.filters { display:flex; flex-wrap:wrap; gap:var(--s4) var(--s6); align-items:end;
padding:var(--s4) var(--s6); background:var(--panel); border:1px solid var(--line);
border-radius:var(--radius); margin-bottom:var(--s3); box-shadow:var(--shadow-sm); }
.check { flex-direction:row; align-items:center; gap:9px; }
.check input { width:20px; height:20px; accent-color:var(--brand); }
.check label { text-transform:none; letter-spacing:0; font-size:14px; color:var(--ink); font-weight:600; }
/* Gruppen-Band */
nav.gruppenband { display:flex; gap:8px; overflow-x:auto; padding:4px 0; scrollbar-width:thin; }
.navitem { flex:none; display:inline-flex; align-items:center; gap:8px; white-space:nowrap;
padding:9px 15px; border-radius:var(--radius-pill); min-height:40px; cursor:pointer;
background:var(--panel); color:var(--ink-2); border:1px solid var(--line);
font-weight:700; font-size:13.5px; transition:background .15s, color .15s, border-color .15s; }
.navitem:hover { background:var(--panel-2); transform:none; box-shadow:none; color:var(--ink); border-color:var(--ctrl); filter:none; }
.navitem.aktiv { background:var(--brand-weak); color:var(--brand-text); border-color:var(--brand-weak-line); }
.navitem .nav-cnt { font-variant-numeric:tabular-nums; color:var(--muted); font-size:12.5px; font-weight:600; }
.navitem.aktiv .nav-cnt { color:var(--brand-text); }
.navitem .nav-uns { font-size:10.5px; color:var(--warn); border:1px solid var(--warn-line);
background:var(--warn-bg); border-radius:6px; padding:1px 6px; font-weight:800; }
.kfeedback { font-size:13.5px; color:var(--brand-text); font-weight:700; height:0; overflow:hidden; transition:height .2s; }
.kfeedback.an { height:24px; margin-top:var(--s2); }
/* Gruppen-Sektionen */
section.grp { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
margin-bottom:var(--s4); overflow:hidden; box-shadow:var(--shadow-sm); }
.grp-h { width:100%; margin:0; padding:0; border:none; background:transparent; cursor:pointer; }
.grp-h-btn { width:100%; display:flex; justify-content:space-between; align-items:center; gap:var(--s3);
padding:var(--s4) var(--s6); background:linear-gradient(var(--panel),var(--panel-2));
border:none; border-bottom:1px solid var(--line-2); border-radius:0; cursor:pointer;
font-size:15.5px; font-weight:800; letter-spacing:-.01em; color:var(--ink); min-height:0; }
.grp-h-btn:hover { filter:none; transform:none; box-shadow:none; background:var(--panel-2); }
.grp-h-btn:focus-visible { box-shadow:var(--ring); }
.grp-h-btn .left { display:flex; align-items:center; gap:10px; }
.grp-h-btn .dot { width:10px; height:10px; border-radius:50%; background:var(--brand); flex:none; }
.grp-h-btn .cnt { color:var(--muted); font-weight:700; font-variant-numeric:tabular-nums; font-size:13px;
display:inline-flex; align-items:center; gap:8px; }
.grp-h-btn .chev { color:var(--muted); transition:transform .2s; font-size:13px; }
section.grp.zu .grp-h-btn { border-bottom:none; }
section.grp.zu .grp-h-btn .chev { transform:rotate(-90deg); }
section.grp.zu ul { display:none; }
section.grp ul { list-style:none; margin:0; padding:var(--s1) 0; }
li.ang { display:grid; grid-template-columns:1fr auto; gap:3px var(--s4); padding:13px var(--s6);
border-bottom:1px solid var(--line-2); align-items:baseline; }
li.ang:last-child { border-bottom:none; }
li.ang:hover { background:var(--panel-2); }
.ang .name { font-weight:700; font-size:15px; color:var(--ink); }
.ang .name .marke { color:var(--brand-text); }
.ang .meta { grid-column:1; color:var(--muted); font-size:13px; display:flex; flex-wrap:wrap; align-items:center; gap:5px 9px; }
.ang .meta .haendler { color:var(--ink-2); background:var(--brand-weak); border:1px solid var(--brand-weak-line);
padding:2px 9px; border-radius:var(--radius-pill); font-weight:700; font-size:12px; }
.ang .preis { grid-column:2; grid-row:1; text-align:right; font-weight:800; font-size:17px;
color:var(--price); font-variant-numeric:tabular-nums; white-space:nowrap; }
.ang .grund { grid-column:2; grid-row:2; text-align:right; color:var(--muted); font-size:12px; white-space:nowrap; }
.ang.unsicher { background:var(--warn-bg); }
.ang.unsicher:hover { background:var(--warn-bg); filter:brightness(.99); }
.badge { display:inline-block; font-size:11px; background:var(--warn-bg); color:var(--warn);
border:1px solid var(--warn-line); border-radius:6px; padding:1px 8px; margin-left:8px; font-weight:700; }
.leer { padding:var(--s4) var(--s6); color:var(--muted); font-style:italic; font-size:14px; }
/* Quelle: sichtbar lassen, nur schöner (kein Roh-String mehr) */
.quelle { display:inline-flex; align-items:center; gap:4px; font-size:11.5px; color:var(--muted);
background:var(--panel-3); border:1px solid var(--line); border-radius:6px; padding:1px 7px; font-weight:600; }
/* Kategorie-Chip / Korrektur-Anker — jetzt ein echter Button */
.gchip { display:inline-flex; align-items:center; font-size:11.5px; color:var(--muted);
background:var(--panel-2); border:1px solid var(--line); border-radius:6px; padding:2px 9px; margin-left:8px;
cursor:pointer; vertical-align:middle; font-weight:600; min-height:0; }
.gchip:hover { border-color:var(--brand); color:var(--brand-text); background:var(--brand-weak); filter:none; transform:none; box-shadow:none; }
.gchip:focus-visible { box-shadow:var(--ring); }
.ang.unsicher .gchip { border-color:var(--warn-line); background:var(--warn-bg); color:var(--warn); }
.ksel { font-size:13px; padding:6px 8px; border:1.5px solid var(--brand); border-radius:8px; margin-left:8px;
vertical-align:middle; background:var(--panel); color:var(--ink); min-height:0; }
footer.note { margin-top:var(--s6); padding-top:var(--s4); border-top:1px solid var(--line);
color:var(--muted); font-size:13px; }
footer.note .haendler { color:var(--ink-2); }
footer.note .hinweis { display:block; margin-top:6px; font-style:italic; }
.sep { color:var(--muted); }
/* sr-only für reine Screenreader-Hinweise */
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
clip:rect(0,0,0,0); white-space:nowrap; border:0; }
@media (max-width:640px) {
.intro { padding-top:var(--s6); }
.wrap { padding-left:var(--s4); padding-right:var(--s4); }
.appbar-inner { padding:0 var(--s4); }
.appbar .brand span.word { display:none; } /* nur Logo auf schmalem Schirm */
li.ang { padding-left:var(--s4); padding-right:var(--s4); }
.grp-h-btn { padding-left:var(--s4); padding-right:var(--s4); }
}
</style>
</head>
<body>
<a class="sr-only" href="#stage1">Zum Inhalt springen</a>
<header class="appbar">
<div class="appbar-inner">
<a class="brand" href="#" aria-label="MABOTO — Marktbeobachtungstool, zum Anfang">
<span class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" width="21" height="21" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6.2" y="9.0" width="1.7" height="3.6" rx="0.5" fill="#fff"/>
<rect x="8.1" y="7.2" width="1.7" height="5.4" rx="0.5" fill="#fff"/>
<rect x="10.0" y="5.4" width="1.7" height="7.2" rx="0.5" fill="#fff"/>
<circle cx="9.2" cy="9.2" r="6.1" fill="none" stroke="#fff" stroke-width="1.9"/>
<path d="M13.7 13.7 L18.6 18.6" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/>
</svg>
</span>
<span class="word">MABOTO<small>Marktbeobachtung</small></span>
</a>
<span class="place leer" id="appbarPlz" aria-live="polite">PLZ …</span>
<span class="spacer"></span>
<button class="iconbtn" id="appbarSettings" type="button" title="LLM-Konfiguration" aria-label="LLM-Konfiguration öffnen"></button>
</div>
</header>
<div class="intro">
<p class="eyebrow">MABOTO · ortskonkret · händlerübergreifend · belegt</p>
<h1>MABOTO</h1>
<p><b>Marktbeobachtungstool.</b> Zwei strikt getrennte Stufen: zuerst die Rohdaten
<b>deterministisch</b> holen und speichern (kein LLM, kein Key), danach erst per
LLM in Produktgruppen einordnen. Jedes Angebot ist belegt — kein Auffüllen,
Unsicheres ist markiert.</p>
</div>
<div class="wrap">
<div class="stages">
<!-- ============ STUFE 1 ============ -->
<section class="stage" id="stage1">
<div class="stage-head">
<div class="stage-num" aria-hidden="true">1</div>
<div class="stage-titles">
<h2>Rohdaten holen &amp; speichern</h2>
<p class="sub">Deterministischer Abruf für eine PLZ. Wird pro PLZ/Woche auf Platte gespeichert.</p>
</div>
<div class="stage-flag">deterministisch · kein LLM</div>
</div>
<div class="stage-body">
<div class="row">
<div class="field">
<label for="plz">PLZ</label>
<input id="plz" type="text" value="60487" inputmode="numeric" maxlength="5" autocomplete="postal-code" />
</div>
<div class="field">
<label for="rohholen">&nbsp;</label>
<button id="rohholen" type="button">Rohdaten holen ▶</button>
</div>
</div>
<div id="rohstatus" role="status" aria-live="polite"></div>
<div id="rohstand"></div>
</div>
</section>
<!-- ============ LLM-KONFIG ============ -->
<details class="config" id="config">
<summary>
<span class="chev" aria-hidden="true"></span>
<span class="ico" aria-hidden="true"></span>
<span>LLM-Konfiguration</span>
<span class="gewaehlt-chip" id="gewaehltChip" title="aktuell gewähltes Modell"></span>
<span class="gilt">gilt für Stufe&nbsp;2</span>
</summary>
<div class="config-body">
<div class="row">
<div class="field">
<label for="anbieter">Anbieter</label>
<select id="anbieter">
<option value="openrouter">OpenRouter (Cloud)</option>
<option value="ollama">Ollama (lokal)</option>
</select>
</div>
<div class="field">
<label for="modell">Modell</label>
<select id="modell"><option>lädt…</option></select>
</div>
<div class="field" id="suchfeld">
<label for="modellsuche">Modell suchen</label>
<input id="modellsuche" type="text" placeholder="z. B. qwen" />
</div>
<div class="field">
<label for="refresh">&nbsp;</label>
<button id="refresh" class="ghost icon" type="button" title="Modell-Liste aktualisieren">↻ aktualisieren</button>
</div>
</div>
<div class="row" style="margin-top:var(--s4)" id="keyzeile">
<div class="field keyrow">
<label for="key">API-Key (bleibt lokal, nur an deinen Server)</label>
<input id="key" type="password" placeholder="OPENROUTER_API_KEY — optional, falls nicht in der Server-Umgebung" autocomplete="off" />
</div>
</div>
<p class="hint" id="konfighint">Der Key wird ausschließlich für Stufe&nbsp;2 verwendet und nie in Stufe&nbsp;1 (Datenabruf) eingesetzt.</p>
</div>
</details>
<!-- ============ STUFE 2 ============ -->
<section class="stage locked" id="stage2">
<div class="stage-head">
<div class="stage-num" aria-hidden="true">2</div>
<div class="stage-titles">
<h2>Kategorisieren</h2>
<p class="sub">Ordnet die gespeicherten Rohdaten per LLM in Produktgruppen. Verändert keine Angebotsdaten.</p>
</div>
<div class="stage-flag llm">LLM-Schritt</div>
</div>
<div class="stage-body">
<div class="row">
<div class="field">
<label for="kategorisieren">&nbsp;</label>
<button id="kategorisieren" type="button" disabled>Kategorisieren ▶</button>
</div>
</div>
<div class="hint lock" id="lockhint">🔒 Erst aktiv, sobald für die PLZ Rohdaten gespeichert sind (Stufe&nbsp;1).</div>
<div id="status" role="status" aria-live="polite"></div>
</div>
</section>
</div>
<!-- ============ ERGEBNIS ============ -->
<div id="result" hidden>
<header class="ergebnis-header">
<div class="summary" id="summary"></div>
<div class="filters">
<div class="field"><label for="fhaendler">Händler</label><select id="fhaendler"><option value="">alle</option></select></div>
<div class="field"><label for="ftext">Angebot suchen</label><input id="ftext" type="text" placeholder="Titel…" /></div>
<div class="field check"><input id="fsicher" type="checkbox" /><label for="fsicher">nur sichere</label></div>
</div>
<nav id="gruppennav" class="gruppenband" aria-label="Produktgruppen — zum Abschnitt springen"></nav>
<div id="korrektur-feedback" class="kfeedback" role="status" aria-live="polite"></div>
</header>
<div id="gruppen"></div>
<footer class="note" id="footer"></footer>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
let DATEN = null; // kategorisiertes Ergebnis (Stufe 2)
let ROHDA = false; // ob für die aktuelle PLZ Rohdaten vorliegen
// Pfad-Basis: lokal "/", hinter einem Reverse-Proxy z.B. "/angebote/".
const BASE = location.pathname.endsWith("/")
? location.pathname
: location.pathname.replace(/[^/]*$/, "");
const api = p => BASE + String(p).replace(/^\//, "");
function esc(s){ return (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
function preisFmt(p){ return p!=null ? p.toLocaleString("de-DE",{minimumFractionDigits:2,maximumFractionDigits:2})+" €" : "Preis fehlt"; }
function setRohStatus(html){ $("#rohstatus").innerHTML = html; }
function setStatus(html){ $("#status").innerHTML = html; }
// App-Bar: aktuelle PLZ spiegeln (gibt App-Gefühl + Orientierung)
function setAppbarPlz(){
const v = ($("#plz").value || "").trim();
const el = $("#appbarPlz");
if (/^\d{5}$/.test(v)){ el.textContent = "📍 " + v; el.classList.remove("leer"); }
else { el.textContent = "PLZ wählen"; el.classList.add("leer"); }
}
// ----- Modelle (LLM-Konfig: OpenRouter ODER Ollama) -------------------------
function anbieter(){ return $("#anbieter").value; }
function merkeWahl(){
try {
localStorage.setItem("ang_anbieter", anbieter());
const m = $("#modell").value;
if (m) localStorage.setItem("ang_modell_" + anbieter(), m);
} catch(e){}
}
function gemerkterAnbieter(){ try { return localStorage.getItem("ang_anbieter"); } catch(e){ return null; } }
function gemerktesModell(ab){ try { return localStorage.getItem("ang_modell_" + ab); } catch(e){ return null; } }
function setGewaehlt(){
const sel = $("#modell");
const opt = sel.options[sel.selectedIndex];
const m = (sel.value && opt && !opt.disabled) ? sel.value : null;
const ab = anbieter() === "ollama" ? "Ollama (lokal)" : "OpenRouter";
$("#gewaehltChip").textContent = m ? `${m} · ${ab}` : `kein Modell · ${ab}`;
merkeWahl();
}
async function ladeModelle(q=""){
const sel = $("#modell");
const ab = anbieter();
$("#keyzeile").style.display = ab === "ollama" ? "none" : "";
$("#suchfeld").style.display = ab === "ollama" ? "none" : "";
$("#konfighint").innerHTML = ab === "ollama"
? "Lokale Modelle über Ollama (localhost:11434) — kein Key, kein Netz. <b>Achtung:</b> kleine Modelle (39 B) liefern für diese Batch-Aufgabe oft kein zuverlässiges Tool-Calling — dann wird ehrlich alles als „Sonstiges/unsicher“ markiert statt geraten. Für brauchbare Ergebnisse ein starkes tool-Modell oder OpenRouter nutzen."
: "Der Key wird ausschließlich für Stufe 2 verwendet und nie in Stufe 1 (Datenabruf) eingesetzt.";
const url = ab === "ollama" ? "/api/ollama-modelle" : ("/api/modelle?q=" + encodeURIComponent(q));
sel.innerHTML = "<option>lädt…</option>";
try {
const r = await fetch(api(url));
if (!r.ok) throw new Error((await r.json()).detail || r.status);
const liste = await r.json();
sel.innerHTML = "";
if (!liste.length){
sel.innerHTML = ab === "ollama"
? "<option value=''>(kein lokales Modell — Ollama gestartet? 'ollama serve')</option>"
: "<option value=''>(keine Treffer)</option>";
setGewaehlt(); return;
}
for (const m of liste){
const o = document.createElement("option");
o.value = m.id;
o.dataset.frei = m.frei ? "1" : "0";
o.dataset.empf = m.empfohlen ? "1" : "0";
let tag;
if (ab === "ollama") tag = "lokal";
else if (m.empfohlen) tag = "★ empfohlen";
else tag = m.frei ? "FREE · oft gedrosselt" : "paid";
const tools = m.tools ? "" : " ⚠ kein tool-calling";
o.textContent = `${m.id} [${tag}]${tools}`;
if (!m.tools) o.disabled = true;
sel.appendChild(o);
}
const opts = [...sel.options];
const gemerkt = gemerktesModell(ab);
const gemerktOpt = gemerkt ? opts.find(o => o.value === gemerkt && !o.disabled) : null;
const empfOpt = opts.find(o => o.dataset.empf === "1" && !o.disabled);
if (gemerktOpt && !(ab !== "ollama" && gemerktOpt.dataset.frei === "1")){
sel.value = gemerktOpt.value;
} else if (empfOpt){
sel.value = empfOpt.value;
} else {
const ok = opts.find(o => !o.disabled && o.value);
if (ok) sel.value = ok.value;
}
setGewaehlt();
} catch (e){
sel.innerHTML = `<option value=''>Modelle nicht abrufbar</option>`;
setGewaehlt();
}
}
// ----- Stufe 1: Rohdaten holen / prüfen -------------------------------------
function lockStage2(grund){
ROHDA = false;
$("#kategorisieren").disabled = true;
$("#stage2").classList.add("locked");
$("#lockhint").style.display = "flex";
if (grund) $("#lockhint").innerHTML = "🔒 " + grund;
}
function unlockStage2(){
ROHDA = true;
$("#kategorisieren").disabled = false;
$("#stage2").classList.remove("locked");
$("#lockhint").style.display = "none";
}
function zeigeRohstand(d, frisch){
const dt = d.abgerufen_am ? new Date(d.abgerufen_am).toLocaleString("de-DE") : "—";
const tags = (d.haendler||[]).map(h => `<span class="tag">${esc(h)}</span>`).join("");
const zeilen = (d.angebote||[]).map(a => `
<tr>
<td>${a.marke ? `<span class="marke">${esc(a.marke)}</span> ` : ""}${esc(a.titel)}${a.menge?` <span class="t-h">· ${esc(a.menge)}</span>`:""}</td>
<td class="t-h">${esc(a.haendler)}</td>
<td class="t-preis">${preisFmt(a.preis)}</td>
</tr>`).join("");
$("#rohstand").innerHTML = `
<div class="rohstand ok">
<div class="line1">
<span class="ok-dot">✓ Rohdaten vorhanden</span>
<span>für PLZ <b>${esc(d.plz)}</b>${d.ort_name?` (${esc(d.ort_name)})`:""}</span>
<span class="meta">— <b>${d.anzahl}</b> Angebote · <b>${(d.haendler||[]).length}</b> Händler · geholt am ${esc(dt)}${frisch?" (gerade aktualisiert)":""}</span>
</div>
<div class="haendler-tags">${tags}</div>
${(d.angebote||[]).length ? `
<details class="rohliste">
<summary>Belegte Rohliste anzeigen (${d.angebote.length}) — ungruppiert</summary>
<table class="rohtable">
<thead><tr><th>Artikel</th><th>Händler</th><th style="text-align:right">Preis</th></tr></thead>
<tbody>${zeilen}</tbody>
</table>
</details>` : `<div class="hint">Quellen liefen, lieferten aber 0 Angebote — kein Auffüllen.</div>`}
</div>`;
unlockStage2();
}
async function pruefeRohstand(plz){
$("#rohstand").innerHTML = "";
lockStage2();
if (!/^\d{5}$/.test(plz)) return;
try {
const r = await fetch(api("/api/rohdaten/" + encodeURIComponent(plz)));
if ($("#plz").value.trim() !== plz) return;
if (r.status === 404) { lockStage2("Noch keine Rohdaten für diese PLZ — Stufe 1 ausführen."); return; }
if (!r.ok) return;
const d = await r.json();
if ($("#plz").value.trim() !== plz) return;
zeigeRohstand(d, false);
} catch {}
}
async function holeRohdaten(){
const plz = $("#plz").value.trim();
if (!/^\d{5}$/.test(plz)) { setRohStatus(`<div class="err">Bitte eine 5-stellige PLZ eingeben.</div>`); return; }
$("#rohholen").disabled = true;
$("#result").hidden = true; DATEN = null;
setRohStatus(`<div class="pending">Hole Angebote für <b>${esc(plz)}</b> … (deterministisch, ohne LLM)</div>`);
try {
const r = await fetch(api("/api/rohdaten"), {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ plz })
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.status);
setRohStatus("");
zeigeRohstand(d, true);
} catch (e){
setRohStatus(`<div class="err">${esc(e.message)}</div>`);
lockStage2();
} finally {
$("#rohholen").disabled = false;
}
}
// ----- Stufe 2: Kategorisieren ----------------------------------------------
function llmBusy(titel, done, total, modell){
const indet = !total;
const pct = indet ? 0 : Math.round(100 * done / total);
return `<div class="llm-busy ${indet ? "indet" : ""}" aria-busy="true">
<div class="spin" role="status" aria-label="Das Sprachmodell arbeitet"></div>
<div class="txt">
<span class="hl">🤖 ${esc(titel)}</span>${total ? ` · <b>Batch ${done}/${total}</b>` : ""}
<small>${modell ? "Modell: " + esc(modell) : "das Sprachmodell ordnet die Angebote ein …"}</small>
<div class="bar2"><i style="width:${pct}%"></i></div>
</div>
</div>`;
}
async function kategorisiere(){
if (!ROHDA) return;
const plz = $("#plz").value.trim();
const btn = $("#kategorisieren");
const btnText = btn.dataset.label || btn.textContent;
btn.dataset.label = btnText;
const modellName = $("#modell").value;
btn.disabled = true; btn.textContent = "⏳ LLM arbeitet …";
$("#result").hidden = true;
setStatus(llmBusy("LLM startet die Kategorisierung …", 0, 0, modellName));
let job;
try {
const r = await fetch(api("/api/kategorisieren"), {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({
plz, modell: modellName, anbieter: anbieter(),
key: anbieter()==="openrouter" ? ($("#key").value || undefined) : undefined
})
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.status);
job = d.job_id;
} catch (e){
setStatus(`<div class="err">${esc(e.message)}</div>`);
btn.disabled = false; btn.textContent = btnText; return;
}
const poll = setInterval(async () => {
let s;
try { s = await (await fetch(api("/api/lauf/" + job))).json(); }
catch { return; }
if (s.status === "laufend"){
setStatus(llmBusy("LLM kategorisiert …", s.done || 0, s.total || 0, modellName));
return;
}
clearInterval(poll);
btn.disabled = false; btn.textContent = btnText;
if (s.status === "fehler"){ setStatus(`<div class="err">${esc(s.fehler)}</div>`); return; }
setStatus("");
DATEN = s.ergebnis;
render();
$("#result").scrollIntoView({behavior:"smooth", block:"start"});
}, 1000);
}
// ----- Ergebnis-Rendering ----------------------------------------------------
function render(){
const d = DATEN;
$("#result").hidden = false;
const modellTxt = d.modell
? ` <span class="sep">·</span> kategorisiert mit <b>${esc(d.modell)}</b>${d.anbieter?` <span class="t-h">(${esc(d.anbieter)})</span>`:""}`
: "";
const cacheTxt = (d.aus_cache != null)
? ` <span class="sep">·</span> <b>${d.aus_cache}</b> aus Cache · <b>${d.neu}</b> neu` : "";
$("#summary").innerHTML =
`Ort <b>${esc(d.ort_name || d.ort_plz)}</b> (PLZ ${esc(d.ort_plz)}) <span class="sep">·</span> <b>${d.anzahl}</b> Angebote <span class="sep">·</span>
Quellen: ${d.quellen.map(esc).join(", ") || "—"} <span class="sep">·</span> <b>${d.unsicher}</b> unsicher${cacheTxt}${modellTxt}`;
const fh = $("#fhaendler");
const aktuell = fh.value;
fh.innerHTML = "<option value=''>alle</option>" + d.haendler.map(h => `<option>${esc(h)}</option>`).join("");
fh.value = aktuell;
zeichneGruppen();
$("#footer").innerHTML =
`<span class="haendler"><b>Beobachtete Händler (belegt):</b> ${d.haendler.map(esc).join(", ")}.</span>` +
d.hinweise.map(h => `<span class="hinweis">${esc(h)}</span>`).join("");
}
function passt(a, fH, fT, nurSicher){
return (!fH || a.haendler === fH) &&
(!nurSicher || !a.unsicher) &&
(!fT || (a.titel + " " + (a.marke||"")).toLowerCase().includes(fT));
}
function zeichneGruppen(){
const d = DATEN;
const fH = $("#fhaendler").value;
const fT = $("#ftext").value.trim().toLowerCase();
const nurSicher = $("#fsicher").checked;
const box = $("#gruppen");
box.innerHTML = "";
const navDaten = [];
for (const g of d.gruppen){
const items = g.angebote.filter(a => passt(a, fH, fT, nurSicher));
const sec = document.createElement("section");
sec.className = "grp";
sec.dataset.grp = g.name;
const secId = "grp-" + g.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
sec.id = secId;
const h = document.createElement("h3");
h.className = "grp-h";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "grp-h-btn";
btn.setAttribute("aria-expanded", "true");
btn.innerHTML =
`<span class="left"><span class="dot" aria-hidden="true"></span>${esc(g.name)}</span>` +
`<span class="cnt">${items.length}<span class="chev" aria-hidden="true">▾</span></span>`;
btn.onclick = () => {
const zu = sec.classList.toggle("zu");
btn.setAttribute("aria-expanded", zu ? "false" : "true");
};
h.appendChild(btn);
sec.appendChild(h);
if (!items.length){
const p = document.createElement("div"); p.className = "leer";
p.textContent = (fH || fT || nurSicher) ? "keine Treffer im Filter" : "keine Angebote";
sec.appendChild(p);
} else {
const ul = document.createElement("ul");
for (const a of items) ul.appendChild(zeile(a, g.name));
sec.appendChild(ul);
navDaten.push({ name:g.name, count:items.length, uns:items.filter(a=>a.unsicher).length });
}
box.appendChild(sec);
}
baueGruppenband(navDaten);
}
function baueGruppenband(navDaten){
const nav = $("#gruppennav");
nav.innerHTML = "";
if (!navDaten.length){ nav.innerHTML = `<span class="navitem" style="cursor:default;color:var(--muted)">keine Treffer</span>`; return; }
for (const g of navDaten){
const b = document.createElement("button");
b.type = "button";
b.className = "navitem"; b.dataset.grp = g.name;
const uns = g.uns ? ` <span class="nav-uns" title="${g.uns} unsicher">${g.uns}</span>` : "";
b.innerHTML = `<span>${esc(g.name)}</span><span class="nav-cnt">${g.count}</span>${uns}`;
b.onclick = () => springeZu(g.name);
nav.appendChild(b);
}
}
function springeZu(name){
let ziel = null;
for (const sec of document.querySelectorAll("#gruppen section.grp")){
if (sec.dataset.grp === name){ ziel = sec; break; }
}
if (ziel){
ziel.classList.remove("zu");
const btn = ziel.querySelector(".grp-h-btn");
if (btn) btn.setAttribute("aria-expanded", "true");
ziel.scrollIntoView({behavior:"smooth", block:"start"});
}
for (const b of document.querySelectorAll(".navitem")){
const aktiv = b.dataset.grp === name;
b.classList.toggle("aktiv", aktiv);
if (aktiv) b.setAttribute("aria-current", "true"); else b.removeAttribute("aria-current");
}
}
function zeile(a, gruppe){
const li = document.createElement("li");
li.className = "ang" + (a.unsicher ? " unsicher" : "");
const marke = a.marke ? `<span class="marke">${esc(a.marke)}</span> ` : "";
const menge = a.menge ? ` <span class="sep">·</span> ${esc(a.menge)}` : "";
const gueltig = (a.gueltig_von || a.gueltig_bis)
? ` <span class="sep">·</span> gültig ${a.gueltig_von||"?"}${a.gueltig_bis||"?"}` : "";
const badge = a.unsicher ? `<span class="badge">unsicher</span>` : "";
// Quelle: sichtbar lassen, aber als ruhiges Badge mit Quellen-Namen (statt Roh-URL)
const qName = (a.quelle||"").split(/[:/?#]/)[0] || "Quelle";
const quelleBadge = a.quelle
? `<span class="quelle" title="${esc(a.quelle)}">🔗 ${esc(qName)}</span>` : "";
li.innerHTML =
`<div class="name">${marke}${esc(a.titel)}${badge}` +
`<button type="button" class="gchip" title="Produktgruppe ändern" aria-label="Produktgruppe von „${esc(a.titel)}“ ändern (aktuell ${esc(gruppe)})">${esc(gruppe)}</button></div>` +
`<div class="preis">${preisFmt(a.preis)}</div>` +
`<div class="meta"><span class="haendler">${esc(a.haendler)}</span>${menge}${gueltig} ${quelleBadge}</div>` +
`<div class="grund">${a.grundpreis ? esc(a.grundpreis) : ""}</div>`;
const chip = li.querySelector(".gchip");
chip.onclick = () => oeffneKorrektur(chip, a, gruppe);
return li;
}
// ----- Korrektur: Produktgruppe manuell setzen -> Produkt-Cache --------------
function oeffneKorrektur(chip, a, vonGruppe){
const sel = document.createElement("select");
sel.className = "ksel";
sel.setAttribute("aria-label", "Neue Produktgruppe wählen");
for (const g of DATEN.gruppen.map(x => x.name)){
const o = document.createElement("option");
o.value = g; o.textContent = g;
if (g === vonGruppe) o.selected = true;
sel.appendChild(o);
}
chip.replaceWith(sel);
sel.focus();
let fertig = false;
sel.onchange = () => { fertig = true; korrigiere(a, vonGruppe, sel.value); };
sel.onblur = () => { setTimeout(() => { if (!fertig && DATEN) render(); }, 150); };
}
async function korrigiere(a, vonGruppe, zielGruppe){
if (zielGruppe === vonGruppe){ render(); return; }
try {
const r = await fetch(api("/api/korrektur"), {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ titel:a.titel, marke:a.marke, gruppe:zielGruppe, plz:$("#plz").value.trim() })
});
if (!r.ok){ throw new Error((await r.json()).detail || r.status); }
const von = DATEN.gruppen.find(g => g.name === vonGruppe);
const ziel = DATEN.gruppen.find(g => g.name === zielGruppe);
if (von){ von.angebote = von.angebote.filter(x => x !== a); von.anzahl = von.angebote.length; }
a.unsicher = false;
if (ziel){ ziel.angebote.push(a); ziel.anzahl = ziel.angebote.length; }
DATEN.unsicher = DATEN.gruppen.reduce((s,g) => s + g.angebote.filter(x=>x.unsicher).length, 0);
render();
zeigeFeedback(`✓ „${a.titel}" → ${zielGruppe} gemerkt (auch im Cache)`);
} catch(e){
render();
zeigeFeedback(`Korrektur fehlgeschlagen: ${e.message}`, true);
}
}
function zeigeFeedback(txt, fehler){
const el = $("#korrektur-feedback");
el.textContent = txt;
el.style.color = fehler ? "var(--err)" : "var(--brand-text)";
el.classList.add("an");
clearTimeout(zeigeFeedback._t);
zeigeFeedback._t = setTimeout(() => el.classList.remove("an"), 2800);
}
// ----- Events ----------------------------------------------------------------
$("#rohholen").onclick = holeRohdaten;
$("#kategorisieren").onclick = kategorisiere;
$("#refresh").onclick = () => ladeModelle($("#modellsuche").value.trim());
$("#modellsuche").addEventListener("keydown", e => { if (e.key==="Enter") ladeModelle(e.target.value.trim()); });
$("#anbieter").addEventListener("change", () => ladeModelle());
$("#modell").addEventListener("change", setGewaehlt);
$("#plz").addEventListener("keydown", e => { if (e.key==="Enter") holeRohdaten(); });
$("#appbarSettings").onclick = () => {
const c = $("#config"); c.open = true;
c.scrollIntoView({behavior:"smooth", block:"center"});
c.querySelector("summary").focus();
};
let _plzTimer;
$("#plz").addEventListener("input", () => {
setAppbarPlz();
clearTimeout(_plzTimer);
const plz = $("#plz").value.trim();
_plzTimer = setTimeout(() => pruefeRohstand(plz), 350);
});
for (const id of ["#fhaendler","#ftext","#fsicher"])
$(id).addEventListener("input", () => DATEN && zeichneGruppen());
// ----- Init ------------------------------------------------------------------
(async function init(){
const _p = new URLSearchParams(location.search);
const ab = _p.get("anbieter") || gemerkterAnbieter();
if (ab) $("#anbieter").value = ab;
await ladeModelle();
const m = _p.get("modell");
if (m){
const sel = $("#modell");
if (![...sel.options].some(o => o.value === m)){
const o = document.createElement("option"); o.value = o.textContent = m;
sel.insertBefore(o, sel.firstChild);
}
sel.value = m; setGewaehlt();
}
if (_p.get("plz")) $("#plz").value = _p.get("plz").trim();
setAppbarPlz();
await pruefeRohstand($("#plz").value.trim());
if (_p.get("auto") && ROHDA) kategorisiere();
})();
</script>
</body>
</html>