diff --git a/server/pruef-arten.mjs b/server/pruef-arten.mjs new file mode 100644 index 00000000..4cbb8880 --- /dev/null +++ b/server/pruef-arten.mjs @@ -0,0 +1,408 @@ +/* DIE TERMINARTEN — und der Umbau, der sie freischaltet. + + Wunsch Filipe (08.09.2026): *"da sollen auch noch kategorien wie + bigmatch, turniere, special-live ... informier dich, was man da alles + noch gebrauchen koennte."* + + --------------------------------------------------------------------- + WARUM DAS EINE EIGENE PRUEFUNG BRAUCHT + + Eine Terminart ist ein erlaubter Wert in einer Spalte, und der steckt + in einem CHECK. Ein CHECK laesst sich in SQLite nicht aendern -- die + Tabelle muss neu gebaut werden, und zwar ZWEI davon (termine und + termin_serien). An `termine` haengen Teilnehmer, Wecker, Protokolle + und Serien. + + Der gefaehrliche Ausgang ist nicht der Absturz, sondern der STILLE + Fehlschlag: Ein `replace`, das nichts findet, gibt den Text + unveraendert zurueck. Die Umstellung liefe durch, meldete Erfolg -- + und die Datenbank lehnte beim ersten BigMatch trotzdem ab. Genau + dieser Fehler ist bei der Rolle 'spicy' passiert, gleich zweimal. + + Deshalb wird hier nicht "im Prinzip" geprueft, sondern der Weg + WIRKLICH GEGANGEN: Die Pruefung baut eine Datenbank im alten Stand + nach, fuellt sie mit Terminen samt abhaengigen Zeilen, laesst die + echte Anwendung darueberlaufen und zaehlt danach nach. + + UND DIE ZWEI LISTEN. Die erlaubten Arten stehen an zwei Stellen: im + CHECK (workspace.js) und in der Pruefliste des Servers + (workspace-kalender.js). Zwei Listen fuer dieselbe Frage laufen + auseinander -- hier werden sie gegeneinander gehalten. + + --------------------------------------------------------------------- + ES WAREN VIER LISTEN, NICHT ZWEI (08.09.2026, nachgetragen) + + Die erste Fassung dieser Pruefung endete beim "HTTP 201": Der Server + nimmt die neue Art an, also ist sie da. Sie war grün. Und sie hat den + schwersten Fehler des Tages nicht gesehen. + + In kalender.js standen ZWEI WEITERE Aufzaehlungen derselben Arten: + `zeigen = { call, termin, review, frist }` und die Schalterleiste + `['call','termin','review','frist']`. Beide blieben stehen. Gefiltert + wurde mit `zeigen[e.art]` -- fuer 'bigmatch' ist das `undefined`. + + Also: anlegen ging, speichern ging, der Server meldete Erfolg, die + Zeile stand in der Datenbank -- und im Kalender war der Eintrag in + KEINER Ansicht zu sehen. Kein Fehler, keine Meldung, nichts. Filipe + haette sein erstes BigMatch eingetragen und eine leere Seite gesehen. + + Gefunden hat es kein Test, sondern ein BILDSCHIRMFOTO: In der + Schalterleiste standen vier Arten statt zehn. + + Deshalb hoert diese Pruefung jetzt nicht mehr beim Serverstatus auf. + Sie legt je Art einen Termin an, oeffnet den Kalender in einem echten + Browser und ZAEHLT NACH, ob er dort steht. Ein Server-OK ist keine + Sichtbarkeit. + + Aufruf: node server/pruef-arten.mjs */ + +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { DatabaseSync } from "node:sqlite"; +import { scryptSync, randomBytes } from "node:crypto"; +import { notbremse } from "./helfer-notbremse.mjs"; + +notbremse(300_000, "die Artenpruefung"); + +const HIER = dirname(fileURLToPath(import.meta.url)); +const { portMussFreiSein } = await import("./helfer-port.mjs"); +const PORT = 4359; +await portMussFreiSein(PORT, "die Artenpruefung"); +const BASIS = `http://127.0.0.1:${PORT}`; + +const ordner = mkdtempSync(join(tmpdir(), "ws-arten-")); +const DB = join(ordner, "workspace.db"); + +let fehler = 0, gemacht = 0; +const ok = (b, t) => { gemacht++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +let kind = null; +async function starten() { + kind = spawn(process.execPath, [join(HIER, "index.js")], { + env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT), + SITE_ACCESS_SECRET: "lokaler-test", + SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" }, + stdio: ["ignore", "pipe", "pipe"] }); + let aus = ""; + kind.stdout.on("data", (d) => { aus += d; }); + kind.stderr.on("data", (d) => { aus += d; }); + for (let i = 0; i < 100; i++) { + await new Promise((r) => setTimeout(r, 250)); + if (kind.exitCode !== null) break; + try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" }); + if (a.status < 500) return () => aus; } catch { /* noch nicht oben */ } + } + throw new Error("Server startete nicht:\n" + aus.slice(-1500)); +} +async function stoppen() { + if (!kind) return; + kind.kill(); kind = null; + await new Promise((r) => setTimeout(r, 700)); +} + +/* ======================================================================= + 1. DIE BEIDEN LISTEN MUESSEN DIESELBEN SEIN + ======================================================================= */ +console.log("=== Zwei Listen, eine Wahrheit ==="); +const NEUE = ["bigmatch", "turnier", "special", "collab", "raid", "charity"]; +{ + const schema = readFileSync(join(HIER, "workspace.js"), "utf8"); + const kalender = readFileSync(join(HIER, "workspace-kalender.js"), "utf8"); + + /* NICHT DER ERSTE TREFFER DER DATEI (08.09.2026). + + Hier stand schlicht `schema.match(/CHECK \(art IN \(([^)]*)\)\)/)`. + Der erste Treffer ist aber der CHAT -- `art IN ('direkt','gruppe')`, + rund 80 Zeilen vor den Terminen. Die Pruefung verglich also die + Chatraumarten mit den Terminarten und meldete rot, obwohl am Code + nichts falsch war. Ein Fehlalarm kostet dasselbe Vertrauen wie ein + uebersehener Fehler. + + Deshalb wird ab dem Bauplan der jeweiligen Tabelle gesucht -- und + BEIDE Termintabellen werden geprueft, nicht eine stellvertretend. */ + const ausTabelle = (name) => { + const ab = schema.indexOf(`CREATE TABLE IF NOT EXISTS ${name} (`); + if (ab < 0) return []; + const treffer = schema.slice(ab, ab + 4000).match(/CHECK \(art IN \(([^)]*)\)\)/); + return (treffer?.[1] || "").split(",") + .map((x) => x.trim().replace(/'/g, "")).filter(Boolean); + }; + const ausCheck = ausTabelle("termine"); + const ausSerien = ausTabelle("termin_serien"); + ok(JSON.stringify(ausCheck) === JSON.stringify(ausSerien) && ausCheck.length > 0, + `termine und termin_serien tragen dieselbe Liste (${ausCheck.length}/${ausSerien.length})`); + + const liste = kalender.match(/const ARTEN = \[([^\]]*)\]/); + const ausServer = (liste?.[1] || "").split(",") + .map((x) => x.trim().replace(/["']/g, "")).filter(Boolean); + + ok(ausCheck.length >= 9, `der CHECK kennt ${ausCheck.length} Arten`); + ok(ausServer.length >= 9, `die Pruefliste kennt ${ausServer.length} Arten`); + ok(JSON.stringify([...ausCheck].sort()) === JSON.stringify([...ausServer].sort()), + `beide Listen sind gleich (CHECK: ${ausCheck.join(",")} | Server: ${ausServer.join(",")})`); + for (const a of NEUE) { + ok(ausCheck.includes(a) && ausServer.includes(a), ` "${a}" steht in beiden`); + } +} + +/* ======================================================================= + 2. DER ALTE STAND -- und der Umbau darueber hinweg + ======================================================================= */ +console.log("\n=== Der Umbau einer bestehenden Datenbank ==="); +await starten(); +await stoppen(); + +const jetzt = new Date().toISOString(); +let idDogi, idLuna, terminVorher, serienVorher; +{ + const d = new DatabaseSync(DB); + d.exec("PRAGMA foreign_keys = OFF"); + + /* Beide Tabellen auf den alten CHECK zuruecksetzen -- woertlich so, + wie sie vor heute aussahen, aber mit allen Spalten, die inzwischen + dazugekommen sind. Genau das ist der Fall, der gefaehrlich ist. */ + for (const t of ["termine", "termin_serien"]) { + const alt = d.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?").get(t).sql; + const zurueck = alt + .replace(new RegExp( + "CREATE TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?[\"'`]?" + t + "[\"'`]?", "i"), + `CREATE TABLE ${t}_alt`) + .replace(/art\s+IN\s*\([^)]*\)/i, "art IN ('termin','call','review')"); + /* Greift der Rueckbau nicht, ist alles danach wertlos -- dann + prueft diese Datei den heutigen Stand gegen sich selbst. */ + if (!zurueck.includes(`${t}_alt`) || zurueck.includes("'bigmatch'")) { + console.log(` FEHL der alte Stand von ${t} liess sich nicht nachbauen.`); + process.exit(1); + } + const sp = d.prepare(`PRAGMA table_info(${t})`).all().map((z) => `"${z.name}"`).join(", "); + d.exec("BEGIN"); + d.exec(zurueck); + d.exec(`INSERT INTO ${t}_alt (${sp}) SELECT ${sp} FROM ${t};`); + d.exec(`DROP TABLE ${t};`); + d.exec(`ALTER TABLE ${t}_alt RENAME TO ${t};`); + d.exec("COMMIT"); + } + d.exec("PRAGMA foreign_keys = ON"); + + const anlegen = (name, rolle, code) => { + const salz = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salz, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salz, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); + idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); + + const heute = jetzt.slice(0, 10); + const rein = d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?)`); + rein.run("Alter Call", "call", heute + "T10:00", 30, idLuna, jetzt, idDogi); + rein.run("Alter Termin", "termin", heute + "T12:00", 30, idLuna, jetzt, idDogi); + rein.run("Altes Review", "review", heute + "T14:00", 45, idLuna, jetzt, idDogi); + /* Abhaengige Zeilen -- ohne sie pruefte der Umbau eine Tabelle statt + eines Netzes. */ + const tid = d.prepare("SELECT id FROM termine WHERE titel = 'Alter Call'").get().id; + d.prepare("INSERT INTO termin_teilnehmer (termin_id, person_id) VALUES (?,?)").run(tid, idLuna); + d.prepare("INSERT INTO termin_wecker (termin_id, person_id, minuten_vorher, gesetzt) VALUES (?,?,?,?)") + .run(tid, idDogi, 1440, jetzt); + d.prepare(`INSERT INTO termin_serien (titel, art, takt, start_tag, uhrzeit, dauer_min, aktiv, erstellt, erstellt_von) + VALUES ('Wochen-Call','call','woechentlich',?,'18:00',30,1,?,?)`) + .run(heute, jetzt, idDogi); + + terminVorher = d.prepare("SELECT COUNT(*) AS n FROM termine").get().n; + serienVorher = d.prepare("SELECT COUNT(*) AS n FROM termin_serien").get().n; + + /* GEGENPROBE: Der alte Stand muss 'bigmatch' WIRKLICH ablehnen -- + sonst gaebe es nichts umzustellen, und alles danach bewiese nichts. */ + let abgelehnt = false; + try { + d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, erstellt, erstellt_von) + VALUES ('Darf noch nicht','bigmatch',?,30,?,?)`).run(heute + "T20:00", jetzt, idDogi); + } catch { abgelehnt = true; } + d.close(); + ok(abgelehnt, "der alte Stand lehnt die Art 'bigmatch' wirklich ab"); + ok(terminVorher === 3 && serienVorher === 1, + `${terminVorher} Termine und ${serienVorher} Serie angelegt`); +} + +/* Jetzt die echte Anwendung darueberlaufen lassen. */ +await starten(); + +{ + const d = new DatabaseSync(DB); + /* NICHT DIE ANZAHL VERGLEICHEN (08.09.2026). + + Erst stand hier `nachher === terminVorher`. Das schlug fehl -- 28 + statt 3 --, und zwar zu Recht: Der Serverstart breitet die Serie + aus und legt dabei echte Termine an. Die Pruefung mass also den + Umbau UND die Serienausbreitung in einer Zahl und konnte nicht + sagen, welches von beiden gemeint war. + + Der Umbau wird deshalb namentlich geprueft: Genau diese drei + Zeilen muessen den Tabellentausch ueberlebt haben. */ + const alte = d.prepare( + "SELECT titel FROM termine WHERE titel LIKE 'Alte%' ORDER BY titel").all().map((z) => z.titel); + ok(alte.length === 3 && alte.join("|") === "Alter Call|Alter Termin|Altes Review", + `die drei alten Termine haben den Tabellentausch ueberlebt (${alte.join(", ") || "keiner"})`); + const serienNachher = d.prepare("SELECT COUNT(*) AS n FROM termin_serien").get().n; + ok(serienNachher === serienVorher, `die Serie ebenfalls (${serienNachher})`); + + /* UND WEIL DIE ZAHL AUFGEFALLEN IST, wird sie gemessen -- aber gegen + die Regel, die WIRKLICH gilt. + + Erst stand hier "kein Serientermin ausserhalb des laufenden + Monats", nach Filipes Satz vom 07.09.2026: *"nicht monate im + vorraus immer nur fuer den monat selbst"*. Die Pruefung wurde rot + (25 Termine bis in den Maerz) -- und lag falsch. Der Satz gehoerte + zum CALLS-BRETT (er zeigte dessen Bildschirmfoto); dort ist er + umgesetzt und wird von pruef-call-kategorien gemessen. Der KALENDER + legt bewusst 180 Tage im Voraus an, begruendet in workspace-serien.js: + der 90-Tage-Plan aus dem Konzept mit Reserve. + + Beinahe haette ich den Code an eine Regel angepasst, die es an + dieser Stelle nie gab. Eine Pruefung, die eine erfundene Regel + misst, ist schlimmer als keine: Sie wird rot, jemand "repariert" + etwas Heiles, und beim naechsten Mal glaubt ihr niemand mehr. */ + const HORIZONT = 180; + const grenze = new Date(Date.now() + (HORIZONT + 1) * 86400000).toISOString().slice(0, 10); + const ausSerie = d.prepare( + "SELECT beginn FROM termine WHERE serie_id IS NOT NULL ORDER BY beginn").all(); + const zuWeit = ausSerie.filter((z) => z.beginn.slice(0, 10) > grenze); + ok(ausSerie.length > 0, `die Serie hat Termine erzeugt (${ausSerie.length})`); + ok(zuWeit.length === 0, + `keiner reicht ueber den ${HORIZONT}-Tage-Horizont hinaus (${zuWeit.length} zu weit: ` + + `${zuWeit.slice(0, 3).map((z) => z.beginn).join(", ") || "–"})`); + + /* Die abhaengigen Zeilen -- der eigentliche Prueffall des Umbaus. */ + const teiln = d.prepare("SELECT COUNT(*) AS n FROM termin_teilnehmer").get().n; + const wecker = d.prepare("SELECT COUNT(*) AS n FROM termin_wecker").get().n; + ok(teiln === 1, `der Teilnehmer haengt noch am Termin (${teiln})`); + ok(wecker === 1, `der Wecker ebenfalls (${wecker})`); + const kaputt = d.prepare("PRAGMA foreign_key_check").all(); + ok(kaputt.length === 0, `keine verwaisten Verweise (${kaputt.length})`); + + /* Die CHECK-Regel selbst: in BEIDEN Tabellen. */ + for (const t of ["termine", "termin_serien"]) { + const plan = d.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?").get(t).sql; + const regel = plan.match(/art\s+IN\s*\(([^)]*)\)/i)?.[1] || ""; + ok(regel.includes("'bigmatch'") && regel.includes("'charity'"), + `${t} kennt die neuen Arten (${regel.slice(0, 60)}…)`); + } + + /* UND DIE SCHRANKE BEISST NOCH. Eine Umstellung, die den CHECK + nebenbei entfernt, sieht aus wie ein Erfolg und ist der schlimmere + Ausgang. */ + let erfunden = false; + try { + d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, erstellt, erstellt_von) + VALUES ('Quatsch','quatsch',?,30,?,?)`).run(jetzt.slice(0, 10) + "T21:00", jetzt, idDogi); + } catch { erfunden = true; } + ok(erfunden, "eine erfundene Art wird weiterhin abgelehnt"); + d.close(); +} + +/* ======================================================================= + 3. UEBER DIE SCHNITTSTELLE + ======================================================================= */ +console.log("\n=== Anlegen mit den neuen Arten ==="); +{ + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }) }); + const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); + ok(!!keks, "als DogFather angemeldet"); + + const morgen = new Date(Date.now() + 86400000).toISOString().slice(0, 10); + let angelegt = 0; + for (const art of NEUE) { + const r = await fetch(BASIS + "/workspace/api/termine", { + method: "POST", + headers: { "Content-Type": "application/json", cookie: keks, origin: BASIS }, + body: JSON.stringify({ titel: `Probe ${art}`, art, beginn: morgen + "T20:00", dauer_min: 60 }), + }); + if (r.status === 201) angelegt++; + } + ok(angelegt === NEUE.length, + `alle ${NEUE.length} neuen Arten lassen sich anlegen (${angelegt})`); + + /* GEGENPROBE ueber die Schnittstelle: eine erfundene Art muss + abgelehnt werden, und zwar mit 400 -- nicht mit einem Serverfehler. */ + const quatsch = await fetch(BASIS + "/workspace/api/termine", { + method: "POST", + headers: { "Content-Type": "application/json", cookie: keks, origin: BASIS }, + body: JSON.stringify({ titel: "Quatsch", art: "quatsch", beginn: morgen + "T21:00" }), + }); + ok(quatsch.status === 400, `eine erfundene Art wird abgelehnt (HTTP ${quatsch.status})`); + + /* ===================================================================== + 4. UND JETZT DER TEIL, DER GEFEHLT HAT: IST ES AUCH ZU SEHEN? + ===================================================================== */ + console.log("\n=== Im echten Kalender sichtbar ==="); + const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await chromium.launch(); + try { + const sitzung = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const kekse = keks.split("; ").map((z) => { + const [name, ...wert] = z.split("="); + return { name, value: wert.join("="), domain: "127.0.0.1", path: "/" }; + }); + await sitzung.addCookies(kekse); + const seite = await sitzung.newPage(); + await seite.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1500); + + /* In die Listenansicht -- dort steht jeder Eintrag mit Titel da, + unabhaengig davon, welcher Monat gerade angezeigt wird. */ + const liste = await seite.$("button:has-text('Liste')"); + if (liste) { await liste.click(); await seite.waitForTimeout(900); } + + const text = await seite.evaluate(() => document.body.innerText); + let sichtbar = 0; + for (const art of NEUE) { + const da = text.includes(`Probe ${art}`); + if (da) sichtbar++; + else console.log(` FEHL "Probe ${art}" steht in der Datenbank, aber NICHT im Kalender`); + } + ok(sichtbar === NEUE.length, + `alle ${NEUE.length} neuen Arten sind im Kalender zu sehen (${sichtbar})`); + + /* Und die Schalterleiste muss sie kennen -- sonst laesst sich eine + Art zwar sehen, aber nicht mehr ausblenden. */ + const schalter = await seite.$$eval(".k-chip", (ns) => ns.map((n) => n.textContent)); + const fehlend = NEUE.filter((a) => !schalter.some((s) => s.toLowerCase().includes(a.slice(0, 5)))); + ok(fehlend.length === 0, + `die Schalterleiste kennt die neuen Arten (${schalter.length} Schalter, ` + + `fehlt: ${fehlend.join(",") || "keine"})`); + + /* GEGENPROBE: Ein Schalter muss auch WIRKEN. Sonst waere die + Sichtbarkeit oben nur deshalb gruen, weil gar nicht gefiltert + wird -- eine Pruefung, die immer bestaetigt. */ + const vorher = (await seite.evaluate(() => document.body.innerText)).includes("Probe bigmatch"); + const knopf = (await seite.$$(".k-chip")).find(async () => true); + const bmSchalter = await seite.$(".k-chip:has-text('BigMatch')"); + let wirkt = false; + if (bmSchalter) { + await bmSchalter.click(); + await seite.waitForTimeout(600); + const nachher = (await seite.evaluate(() => document.body.innerText)).includes("Probe bigmatch"); + wirkt = vorher && !nachher; + } + ok(wirkt, `der Schalter blendet die Art auch wirklich aus (vorher da: ${vorher})`); + void knopf; + } finally { + await browser.close(); + } +} + +await stoppen(); +console.log(`\n${gemacht} Pruefungen`); +console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-kalender.js b/server/workspace-kalender.js index f7fe5729..bd6ca411 100644 --- a/server/workspace-kalender.js +++ b/server/workspace-kalender.js @@ -22,7 +22,31 @@ import { export const kalenderRouter = express.Router(); -const ARTEN = ["termin", "call", "review"]; +/* DIE TERMINARTEN (08.09.2026 erweitert). + + Wunsch Filipe: "da sollen auch noch kategorien wie bigmatch, + turniere, special-live ... informier dich, was man da alles noch + gebrauchen koennte." + + Recherchiert (Streams Charts, Kick, StreamerCollabs): Was ein + Creator-Team im Kalender wirklich unterscheidet, sind neben den + internen Terminen die AUFTRITTE -- Wettkaempfe, gemeinsame Formate + und die grossen Ausnahmen. Daraus die sechs neuen: + + bigmatch ein angesetztes Duell gegen einen bestimmten Gegner + turnier ein mehrrundiges Format ueber laengere Zeit + special ein angekuendigter Sonder-Livestream + collab ein gemeinsamer Stream mit anderen Creators + raid eine Raid-Kette, bei der Zuschauer weitergereicht werden + charity ein Spendenformat + + Die Liste steht HIER und nicht in der Datenbank: Sie ist eine + Entscheidung ueber das Format, keine Nutzdaten. Der CHECK in + workspace.js fuehrt dieselben Werte -- wer hier einen hinzufuegt, + muss ihn dort ebenfalls eintragen, sonst lehnt die Datenbank ab. + pruef-arten haelt beide gegeneinander. */ +const ARTEN = ["termin", "call", "review", + "bigmatch", "turnier", "special", "collab", "raid", "charity"]; const TITEL_MAX = 160; const TEXT_MAX = 4000; const ORT_MAX = 400; diff --git a/server/workspace.js b/server/workspace.js index ac22c006..e7a99996 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -764,6 +764,121 @@ function umstellungen(d) { geprueft hat. Deshalb wird danach nachgesehen, ob 'spicy' wirklich drinsteht. ===================================================================== */ + /* ===================================================================== + MEHR TERMINARTEN (08.09.2026) + + Wunsch Filipe: "da sollen auch noch kategorien wie bigmatch, + turniere, special-live ... informier dich, was man da alles noch + gebrauchen koennte." + + Recherchiert (Streams Charts, Kick, StreamerCollabs): Was ein + Creator-Team im Kalender wirklich unterscheidet, sind neben den + internen Terminen die AUFTRITTE -- Wettkaempfe (BigMatch, Turnier), + gemeinsame Formate (Collab, Raid-Train) und die grossen Ausnahmen + (Special-Live, Charity). Genau diese sechs kommen dazu. + + WARUM EIN TABELLENUMBAU: Die erlaubten Werte stecken in einem + CHECK, und ein CHECK laesst sich in SQLite nicht aendern. Dieselbe + Lage wie bei der Rolle 'spicy' -- und deshalb hier dasselbe, + bewaehrte Verfahren: Bauplan aus sqlite_master lesen, NUR die Liste + ersetzen, das Ergebnis pruefen, Spalten aus PRAGMA holen, Zeilen + INNERHALB der Transaktion zaehlen, Sicherung vorher. + + Zwei Tabellen statt einer (termine und termin_serien), deshalb + eine Schleife -- zweimal derselbe Text waere zweimal dieselbe + Gelegenheit, eine Stelle zu vergessen. */ + const ARTEN_NEU = "'termin','call','review','bigmatch','turnier','special','collab','raid','charity'"; + for (const tabelle of ["termine", "termin_serien"]) { + const plan = d.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(tabelle)?.sql || ""; + /* Geprueft wird die REGEL, nicht der ganze Bauplan: SQLite hebt ihn + woertlich auf, samt Kommentaren -- ein erklaerender Satz mit dem + Wort 'bigmatch' wuerde sonst genuegen, damit die Umstellung sich + fuer erledigt haelt. Genau dieser Fehler ist bei 'spicy' passiert. */ + const artRegel = plan.match(/art\s+IN\s*\(([^)]*)\)/i)?.[1] || ""; + if (!plan || !artRegel || artRegel.includes("'bigmatch'")) continue; + + /* DER TABELLENNAME GEHOERT IN DEN DATEINAMEN (08.09.2026). + + Hier stand `.vor-arten-${jetztStempel}` -- ohne die Tabelle. Der + Stempel wird EINMAL pro Serverstart gebildet, diese Schleife + laeuft aber ZWEIMAL. Der zweite Durchlauf wollte also dieselbe + Datei anlegen, `VACUUM INTO` weigert sich (die Datei ist schon + da), und das `break` unten beendete daraufhin die ganze Schleife. + + Ergebnis: `termine` war umgestellt, `termin_serien` NICHT. Und + zwar still -- die Meldung ging in die Serverausgabe, die niemand + liest. Aufgefallen waere es erst, wenn jemand eine wiederkehrende + BigMatch-Reihe anlegt und die Datenbank sie ohne erkennbaren + Grund ablehnt. + + Das `break` bleibt richtig: Wenn sich keine Sicherung anlegen + laesst, wird nicht umgebaut. Falsch war nur der Name. */ + const sicherung = `${DB_PFAD}.vor-arten-${tabelle}-${jetztStempel}`; + try { + d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`); + console.log("[workspace] Sicherung vor der Artenumstellung:", sicherung); + } catch (fehler) { + console.error("[workspace] Sicherung fehlgeschlagen, Artenumstellung abgebrochen:", + fehler?.message); + break; + } + + const neuerPlan = plan + /* DAS MUSTER WIRD ZUSAMMENGESETZT, NICHT IN EINEN TEMPLATE-STRING + GESCHRIEBEN. Dort wird \s beim Einlesen zu einem blossen "s" -- + aus "CREATE TABLE\s+" wuerde "CREATE TABLEs+", das Muster + passte auf nichts, und die Umstellung waere STILL ausgeblieben. + Genau so stand es hier beim ersten Anlauf; nachgemessen mit + einem Einzeiler, der beide Schreibweisen gegen den echten + Bauplan haelt. */ + .replace(new RegExp( + "CREATE TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?[\"'`]?" + tabelle + "[\"'`]?", "i"), + `CREATE TABLE ${tabelle}_neu`) + .replace(/art\s+IN\s*\([^)]*\)/i, `art IN (${ARTEN_NEU})`); + if (!neuerPlan.includes("'bigmatch'") || !neuerPlan.includes(`${tabelle}_neu`)) { + console.error(`[workspace] Artenumstellung abgebrochen: Bauplan von ${tabelle} ` + + "liess sich nicht umschreiben."); + continue; + } + + const spalten = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((z) => z.name); + if (!spalten.length) continue; + const liste = spalten.map((n) => `"${n}"`).join(", "); + + d.exec("PRAGMA foreign_keys = OFF"); + try { + const vorher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n; + d.exec("BEGIN"); + d.exec(neuerPlan); + d.exec(`INSERT INTO ${tabelle}_neu (${liste}) SELECT ${liste} FROM ${tabelle};`); + const nachher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}_neu`).get().n; + if (nachher !== vorher) { + d.exec("ROLLBACK"); + console.error(`[workspace] Artenumstellung abgebrochen: ${vorher} Zeilen vorher, ` + + `${nachher} nachher. Sicherung: ${sicherung}`); + } else { + d.exec(`DROP TABLE ${tabelle};`); + d.exec(`ALTER TABLE ${tabelle}_neu RENAME TO ${tabelle};`); + d.exec("COMMIT"); + const kaputt = d.prepare("PRAGMA foreign_key_check").all(); + if (kaputt.length) { + console.error("[workspace] ACHTUNG: nach der Artenumstellung", kaputt.length, + "verwaiste Verweise. Sicherung:", sicherung); + } else { + console.log(`[workspace] Terminarten erweitert in ${tabelle}: ${nachher} Zeilen, ` + + `${spalten.length} Spalten uebernommen, Verweise geprueft.`); + } + } + } catch (fehler) { + try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ } + console.error("[workspace] Artenumstellung fehlgeschlagen:", fehler?.message, + "-- Sicherung:", sicherung); + } finally { + d.exec("PRAGMA foreign_keys = ON"); + } + } + const rollenPlan = d.prepare( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || ""; /* GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026). @@ -1135,7 +1250,7 @@ export function db() { titel TEXT NOT NULL, beschreibung TEXT, art TEXT NOT NULL DEFAULT 'termin' - CHECK (art IN ('termin','call','review')), + CHECK (art IN ('termin','call','review','bigmatch','turnier','special','collab','raid','charity')), beginn TEXT NOT NULL, dauer_min INTEGER NOT NULL DEFAULT 30, ort TEXT, @@ -1261,7 +1376,7 @@ export function db() { titel TEXT NOT NULL, beschreibung TEXT, art TEXT NOT NULL DEFAULT 'termin' - CHECK (art IN ('termin','call','review')), + CHECK (art IN ('termin','call','review','bigmatch','turnier','special','collab','raid','charity')), takt TEXT NOT NULL CHECK (takt IN ('taeglich','werktags','woechentlich', 'zweiwoechentlich','monatlich_datum', diff --git a/workspace/assets/js/kalender.js b/workspace/assets/js/kalender.js index 9fd6a469..9aa5c17d 100644 --- a/workspace/assets/js/kalender.js +++ b/workspace/assets/js/kalender.js @@ -53,14 +53,45 @@ return k; }; - const ARTNAME = { call: 'Call', termin: 'Termin', review: 'Review', frist: 'Frist' }; + /* Die Namen der Arten. `frist` steht mit drin, obwohl es keine + Terminart ist: Aufgabenfristen erscheinen im Kalender als + Eintraege und brauchen dort auch eine Beschriftung. */ + const ARTNAME = { + call: 'Call', termin: 'Termin', review: 'Review', frist: 'Frist', + bigmatch: 'BigMatch', turnier: 'Turnier', special: 'Special-Live', + collab: 'Collab', raid: 'Raid-Train', charity: 'Charity', + }; /* Jede Art trägt ihre eigene Farbe -- dieselbe Idee wie bei den Kacheln auf der Startseite: Die Farbe gehört zur Sache, nicht zum Rang. */ + /* EINE FARBE JE ART (08.09.2026 erweitert). + + Die drei internen Arten behalten ihre Farben -- wer sie kennt, soll + sie wiedererkennen. Die sechs Auftritte bekommen WAERMERE, kraeftigere + Toene: Im Monatsraster sieht man dadurch auf einen Blick, ob eine + Woche voller Arbeitstermine steckt oder voller Streams, ohne ein + Wort zu lesen. + + Innerhalb der Auftritte ist die Zuordnung nicht beliebig: + bigmatch Rot -- ein Duell, das schaerfste Format + turnier Bernstein -- mehrere Runden, laenger, ruhiger als ein Duell + special Magenta -- die Ausnahme, die auffallen soll + collab Tuerkis -- gemeinsam, freundlich, kein Wettkampf + raid Orange -- Bewegung, eine Kette + charity Gruengold -- Spenden, warm und nicht schreiend + + Alle neun sind gegen den dunklen Grund auf mindestens 4,5:1 + geprueft (pruef-buehne misst die Texte, die sie faerben). */ const ARTFARBE = { - call: '#3fbdf5', - termin: '#8a76ff', - review: '#50a752', - frist: '#d17717', + call: '#3fbdf5', + termin: '#8a76ff', + review: '#50a752', + frist: '#d17717', + bigmatch: '#ff5f6d', + turnier: '#e3b341', + special: '#f065c8', + collab: '#2fd6c4', + raid: '#ff9040', + charity: '#a7d64a', }; const FEIERTAG_FARBE = '#ff9aa2'; const ANLASS_FARBE = '#d8a13a'; @@ -211,7 +242,27 @@ } let ansicht = 'monat'; let anker = heuteText(); // Datum, um das herum angezeigt wird - const zeigen = { call: true, termin: true, review: true, frist: true }; + /* WAS NICHT AUSDRUECKLICH ABGEWAEHLT IST, IST SICHTBAR (08.09.2026). + + Hier stand `{ call: true, termin: true, review: true, frist: true }` + -- eine zweite, von Hand gepflegte Liste der Arten neben ARTNAME. + Als die sechs Auftritts-Arten dazukamen, blieb sie stehen. Unten + filtert `sichtbare()` mit `zeigen[e.art]`, und fuer 'bigmatch' ist + das `undefined`. Ergebnis: Ein BigMatch liess sich anlegen, der + Server meldete 201, er stand in der Datenbank -- und war im + Kalender in KEINER Ansicht zu sehen. Ohne Fehler, ohne Hinweis. + Filipe haette einen Eintrag gemacht und nichts waere passiert. + + Zwei Lehren, beide hier umgesetzt: + + 1. Die Liste wird ABGELEITET, nicht gefuehrt. ARTNAME ist die eine + Quelle; wer dort eine Art ergaenzt, hat sie ueberall. + 2. Der Vorgabewert einer Sichtbarkeitsfrage muss SICHTBAR sein. + `sichtbare()` prueft deshalb `!== false` statt auf Wahrheit -- + eine unbekannte Art faellt dann auf, statt zu verschwinden. + Ein Eintrag zu viel im Kalender ist ein Schoenheitsfehler, ein + fehlender ist ein verpasster Termin. */ + const zeigen = Object.fromEntries(Object.keys(ARTNAME).map((a) => [a, true])); /* ---------- Datumsrechnung ---------------------------------------------- Alles läuft in UTC-Mittag. Grund: Wer mit lokalen Zeiten rechnet, @@ -449,7 +500,10 @@ a.tag.localeCompare(b.tag) || (a.uhr || '99:99').localeCompare(b.uhr || '99:99')); } - const sichtbare = () => (daten ? daten.eintraege.filter((e) => zeigen[e.art]) : []); + /* `!== false`, nicht auf Wahrheit pruefen -- Begruendung oben bei + `zeigen`. Eine Art, die niemand eingetragen hat, wird angezeigt + statt verschluckt. */ + const sichtbare = () => (daten ? daten.eintraege.filter((e) => zeigen[e.art] !== false) : []); function nachTag() { const karte = new Map(); @@ -798,8 +852,22 @@ function baueFilter() { const ziel = $('filter'); ziel.replaceChildren(); - for (const art of ['call', 'termin', 'review', 'frist']) { - const n = daten.eintraege.filter((e) => e.art === art).length; + /* DIE LISTE WIRD ABGELEITET (08.09.2026) -- vorher stand hier + `['call','termin','review','frist']` fest, die dritte Fassung + derselben Aufzaehlung in einer Datei. + + Die vier Grundarten bekommen immer einen Schalter, auch bei null + Eintraegen: Sie sind der Alltag, und ein Schalter, der auftaucht + und verschwindet, verwirrt mehr, als er hilft. Die sechs + Auftritts-Arten erscheinen nur, wenn es sie im Zeitraum wirklich + gibt -- zehn Schalter, davon sechs mit "(0)", waeren eine Leiste, + die niemand mehr liest. */ + const GRUND = ['call', 'termin', 'review', 'frist']; + const anzahl = (art) => daten.eintraege.filter((e) => e.art === art).length; + const arten = [...GRUND, + ...Object.keys(ARTNAME).filter((a) => !GRUND.includes(a) && anzahl(a) > 0)]; + for (const art of arten) { + const n = anzahl(art); const b = el('button', 'k-chip'); b.type = 'button'; b.setAttribute('aria-pressed', String(zeigen[art])); @@ -1380,6 +1448,78 @@ } } + /* ===================================================================== + DAS FORMULAR PASST SICH DER ART AN (08.09.2026) + + Wunsch Filipe: "auch so, dass wenn man die sachen aussucht, die + ganze kachel und die sachen, die man eintippen muss, auch zu der + jeweiligen kategorie passen." + + WAS SICH AENDERT, SIND DIE FRAGEN -- nicht die Datenbank. Ein + BigMatch fragt nach dem GEGNER, ein Turnier nach dem FORMAT, ein + Collab nach den LEUTEN. Es ist jedes Mal dasselbe Feld "Titel"; + was fehlt, ist die richtige Frage davor. Eigene Spalten je Art + waeren neun halbleere Felder in einer Tabelle und beim naechsten + Format wieder eines mehr. + + Dasselbe fuer Ort und Notiz: "Ort oder Link" ist bei einem + Raid-Train die falsche Frage -- dort geht es um die KETTE, also um + die Reihenfolge der Kanaele. + + Und die Dauer bekommt einen Vorschlag: Ein Call dauert 30 Minuten, + ein Turnier selten unter zwei Stunden. Vorgeschlagen, nicht + erzwungen -- und nur bei einem NEUEN Eintrag, nie beim Bearbeiten: + Wer eine Dauer eingetragen hat, will sie nicht durch einen + Artwechsel verlieren. */ + const ART_FELDER = { + call: { titel: 'Worum geht es?', titelHilfe: 'z. B. Wochen-Call mit Luna', + ort: 'Ort oder Link', ortHilfe: 'Discord, Meet-Link, Adresse …', dauer: 30 }, + termin: { titel: 'Worum geht es?', titelHilfe: 'z. B. Fotoshooting', + ort: 'Ort oder Link', ortHilfe: 'Adresse, Discord, Link …', dauer: 30 }, + review: { titel: 'Was wird durchgesehen?', titelHilfe: 'z. B. Monatsrückblick Luna', + ort: 'Ort oder Link', ortHilfe: 'Discord, Meet-Link …', dauer: 45 }, + bigmatch: { titel: 'Gegen wen?', titelHilfe: 'z. B. BigMatch vs. Beanii', + ort: 'Wo läuft es?', ortHilfe: 'TikTok, Twitch, Kanal-Link …', dauer: 60, + notiz: 'Regeln, Einsatz, Uhrzeit des Matches' }, + turnier: { titel: 'Welches Turnier?', titelHilfe: 'z. B. Herbst-Cup, Runde 2', + ort: 'Wo läuft es?', ortHilfe: 'Plattform oder Turnier-Link', dauer: 120, + notiz: 'Modus, Runde, Gegner, Preisgeld' }, + special: { titel: 'Was ist das Besondere?', titelHilfe: 'z. B. 24-Stunden-Stream', + ort: 'Wo läuft es?', ortHilfe: 'TikTok, Twitch, YouTube …', dauer: 180, + notiz: 'Ablauf, Gäste, was angekündigt wurde' }, + collab: { titel: 'Mit wem?', titelHilfe: 'z. B. Collab mit Miesmuschel', + ort: 'Wessen Kanal?', ortHilfe: 'Kanal oder Link', dauer: 90, + notiz: 'Thema, wer moderiert, Absprachen' }, + raid: { titel: 'Welche Kette?', titelHilfe: 'z. B. Raid-Train Freitagabend', + ort: 'Reihenfolge der Kanäle', ortHilfe: 'wer nach wem, mit Links', dauer: 60, + notiz: 'Startzeit je Kanal, Übergabe-Absprache' }, + charity: { titel: 'Für wen wird gesammelt?', titelHilfe: 'z. B. Spendenstream Tierheim', + ort: 'Wo läuft es?', ortHilfe: 'Plattform, Spendenlink', dauer: 180, + notiz: 'Spendenziel, Link, Ablauf' }, + }; + + function artFelderSetzen(neuerEintrag) { + const art = $('f-art').value; + const f = ART_FELDER[art] || ART_FELDER.termin; + const schild = (feld, text, zusatz) => { + const l = document.querySelector(`label[for="${feld}"]`); + if (!l) return; + l.textContent = text; + if (zusatz) { + const s2 = el('span', 'leise', ' (optional)'); + l.append(s2); + } + }; + schild('f-titel', f.titel, false); + $('f-titel').placeholder = f.titelHilfe || ''; + schild('f-ort', f.ort, true); + $('f-ort').placeholder = f.ortHilfe || ''; + schild('f-text', f.notiz ? 'Notiz' : 'Notiz', true); + $('f-text').placeholder = f.notiz || ''; + /* Die Dauer nur bei einem NEUEN Eintrag vorschlagen -- siehe oben. */ + if (neuerEintrag) $('f-dauer').value = f.dauer || 30; + } + function formularOeffnen(tag, termin) { /* Vorbelegung: der angeklickte Tag um 18:00 -- ein realistischer Zeitpunkt für einen Call, spart in der Regel zwei Klicks. Ohne Tag @@ -1397,6 +1537,7 @@ baueDabei([]); bearbeiteId = 0; $('neu-speichern').textContent = 'Anlegen'; + artFelderSetzen(true); /* BEARBEITEN: dieselben Felder, gefuellt. Der Rhythmus bleibt aussen vor -- eine Wiederholung ist eine REGEL und wird unter @@ -1414,6 +1555,8 @@ baueDabei(termin.teilnehmer_ids || []); $('f-takt').value = ''; wdhVorschau(); + /* `false`: Beim Bearbeiten bleibt die eingetragene Dauer stehen. */ + artFelderSetzen(false); $('neu-speichern').textContent = 'Speichern'; } umschalten(true); @@ -1437,6 +1580,12 @@ umschalten(false); }); + /* Beim Wechsel der Art die Fragen umstellen. `true` heisst: Die + Dauer darf vorgeschlagen werden -- wer die Art wechselt, hat den + Eintrag noch nicht fertig. Beim Bearbeiten wird der Wechsel + ebenfalls hier ausgeloest, aber `formularOeffnen` hat die echte + Dauer danach schon gesetzt. */ + $('f-art').addEventListener('change', () => artFelderSetzen(true)); $('f-beginn').addEventListener('change', taktAuswahlFuellen); $('f-takt').addEventListener('change', wdhVorschau); $('f-ende').addEventListener('change', wdhVorschau); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 09ddf131..a6fb40b2 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -212,10 +212,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 7450c170..667d8274 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -135,10 +135,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 4dae9b23..6bdb6739 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -144,14 +144,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index bba5d2be..92dfbd0d 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -77,11 +77,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 228348cc..fb86bf63 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -9,17 +9,17 @@ - - + + - - - + + + @@ -155,10 +155,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 0fefc28a..d991e863 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -167,12 +167,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index d799ccd7..4e70222f 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -96,10 +96,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 2ff84319..3f6a8d59 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -27,7 +27,7 @@ - + @@ -219,6 +219,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 3fc2b0d3..d6c1c0b2 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -78,10 +78,27 @@
+
@@ -288,11 +305,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 2bc861fd..3c180a47 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -212,10 +212,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 180ec93b..0c206eb1 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -157,10 +157,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index d4b936dc..940edbf8 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -202,11 +202,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index f402433f..25e53e2f 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -94,11 +94,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index eb6489b3..f5012122 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -127,10 +127,10 @@
- - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 6aca796e..4d1d5b86 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -9,12 +9,12 @@ - - + + - - + + @@ -320,10 +320,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 9d722116..68603a26 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -73,11 +73,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index cdd552a6..d3d5f981 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -114,10 +114,10 @@ - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 509f0e14..92e9f132 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -9,13 +9,13 @@ - - + + - - - + + + @@ -137,11 +137,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 9f31ea7f..3242cb48 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -178,10 +178,10 @@ - - - - - + + + + +