diff --git a/.gitignore b/.gitignore index b41f3f2c..2cea558c 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ pruef-*-lauf.txt server/pruef-*-lauf.txt # Ergebnis des Sammellaufs (tools/alles-pruefen.mjs) gesamtlauf.txt + +# Bilder zum Ansehen (server/bild-*.mjs) -- entstehen bei jedem Lauf neu +tagesblick-*.png diff --git a/server/bild-tagesblick.mjs b/server/bild-tagesblick.mjs new file mode 100644 index 00000000..a3cc1a30 --- /dev/null +++ b/server/bild-tagesblick.mjs @@ -0,0 +1,99 @@ +/* Macht ein Bild der fertigen Kachel -- zum Ansehen, nicht zum Pruefen. + Die Pruefung dazu ist pruef-tagesblick.mjs. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-bild-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4307, "das Bildwerkzeug"); +process.env.PORT = "4307"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = "http://127.0.0.1:4307"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetztIso = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 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, salt, 32768, jetztIso); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +const idNora = anlegen("Nora", "creator", "CODE-CREA-0002"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idScout, jetztIso); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idNora, idScout, jetztIso); + +const n = new Date(); +const HEUTE = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, "0")}-${String(n.getDate()).padStart(2, "0")}`; +/* Rund um die aktuelle Uhrzeit, damit das Bild alle drei Zustaende + zeigt: vorbei, als Naechstes dran, spaeter. */ +const stunde = n.getHours(); +const zeit = (h) => `${String(Math.max(0, Math.min(23, h))).padStart(2, "0")}:00`; +function termin(titel, h, art, dauer, wer) { + d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, teilnehmer_id, + ort, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?)`) + .run(titel, art, `${HEUTE}T${zeit(h)}`, dauer, wer, wer, null, jetztIso, idDogi); +} +termin("Wochen-Call mit Luna", stunde - 3, "call", 30, idLuna); +termin("Content-Freigabe Nora", stunde - 1, "review", 45, idNora); +termin("Strategie mit Patrick", stunde + 1, "termin", 60, idLuna); +termin("Abschluss-Call", stunde + 3, "call", 30, idNora); + +d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, frist, creator_id, + verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,'hoch',?,?,?,?,?)`) + .run("Thumbnails prüfen", "offen", "2020-01-01", idLuna, idLuna, jetztIso, idDogi); +d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, frist, creator_id, + verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,'hoch',?,?,?,?,?)`) + .run("Review Freigabe", "review", HEUTE, idNora, idNora, jetztIso, idDogi); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +for (const [breite, name] of [[1280, "computer"], [390, "handy"]]) { + const ctx = await browser.newContext({ + viewport: { width: breite, height: 1000 }, deviceScaleFactor: 2, + }); + const seite = await ctx.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.waitForTimeout(1400); + const kachel = await seite.$("#dran-block"); + if (kachel) { + await kachel.screenshot({ path: `tagesblick-${name}.png` }); + console.log(`tagesblick-${name}.png geschrieben`); + } else { + console.log(`${name}: Kachel nicht gefunden`); + } + await ctx.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(0); diff --git a/server/pruef-push-weg.mjs b/server/pruef-push-weg.mjs index 6415e0d3..d5878934 100644 --- a/server/pruef-push-weg.mjs +++ b/server/pruef-push-weg.mjs @@ -29,6 +29,33 @@ import { verschluesseln, schluesselErzeugen, schicken, b64u, } from "./workspace-push-krypto.js"; +/* EINEN TESTDIENST SAUBER SCHLIESSEN -- und warum das eine eigene + Funktion wert ist. + + `server.close()` ist ASYNCHRON: Es bittet den Server, keine neuen + Verbindungen mehr anzunehmen, und meldet sich, wenn er wirklich zu + ist. Wer den Rueckruf nicht abwartet und danach `process.exit()` + ruft, reisst libuv das Handle mitten im Schliessen weg. Unter + Windows endet das nicht in einer Fehlermeldung, sondern in einem + Absturz: + + Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), + file src\win\async.c, line 94 + + Genau das ist am 05.09.2026 im Gesamtlauf passiert -- NACH dem + "ALLES IN ORDNUNG". Alle zwanzig Pruefungen waren gruen, der + Rueckgabewert war 3221226505, und der Sammellaeufer meldete zu Recht + FEHL. Eine Pruefung, die inhaltlich besteht und trotzdem rot ist, ist + das Schlimmste von beidem: Man gewoehnt sich an, das Rot zu + uebersehen. + + `unref()` allein genuegt nicht -- es nimmt dem Handle nur das Recht, + den Prozess am Leben zu halten; im Schliessen ist es trotzdem. */ +const schliessen = (dienst) => new Promise((fertig) => { + if (!dienst.listening) return fertig(); + dienst.close(() => fertig()); +}); + let fehler = 0; const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; @@ -128,7 +155,7 @@ let empfangen = null, kopfzeilen = null; "die VAPID-Signatur ist dabei"); ok(kopfzeilen?.ttl === "3600", `TTL: ${kopfzeilen?.ttl}`); ok(entschluesseln(empfangen) === TEXT, "und der Inhalt kommt unveraendert an"); - dienst.close(); + await schliessen(dienst); } /* ======================================================================= @@ -160,8 +187,7 @@ console.log("\n=== Ein Geraet, das es nicht mehr gibt ==="); ok(e.weg === erwartetWeg, `${code} -> weg=${e.weg} (erwartet ${erwartetWeg})` + (erwartetWeg ? " — die Anmeldung wird geloescht" : " — sie bleibt, es kann voruebergehend sein")); - d.close(); - await new Promise((r) => setTimeout(r, 60)); + await schliessen(d); } } @@ -206,4 +232,27 @@ console.log("\n=== Gegenprobe ==="); } console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); -process.exit(fehler ? 1 : 0); + +/* KEIN process.exit() HIER (05.09.2026). + + Diese Pruefung benutzt `fetch`, und zwar auch gegen eine Adresse, die + niemand bedient (Abschnitt 6, "wenn der Push-Dienst schweigt"). Node + haelt dafuer im Hintergrund einen Verbindungspool. `process.exit()` + beendet den Prozess SOFORT -- mitten im Schliessen dieser + Verbindungen. Unter Windows ist das kein sauberer Abbruch, sondern + ein Absturz in libuv: + + Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), + file src\win\async.c, line 94 + + Im Gesamtlauf stand deshalb "ALLES IN ORDNUNG" und danach + Rueckgabewert 3221226505 -- der Sammellaeufer meldete FEHL, obwohl + alle zwanzig Pruefungen bestanden hatten. Eine Pruefung, die + inhaltlich gruen und trotzdem rot ist, gewoehnt einem an, das Rot zu + uebersehen. Das ist schlimmer als ein echter Fehler. + + `process.exitCode` setzt nur den Wert und laesst Node zu Ende + raeumen. Bleibt der Prozess dann haengen, ist das ein echter Befund + (ein Handle, das niemand schliesst) und faellt sofort auf -- besser + als ein Absturz, den man wegerklaert. */ +process.exitCode = fehler ? 1 : 0; diff --git a/server/pruef-tagesblick.mjs b/server/pruef-tagesblick.mjs new file mode 100644 index 00000000..cc066430 --- /dev/null +++ b/server/pruef-tagesblick.mjs @@ -0,0 +1,375 @@ +/* DER TAGESBLICK — die geteilte Kachel auf der Startseite. + + Wunsch Filipe, 05.09.2026: *"eine ganze kachel wo links die sachen + sind die du da schon siehst und rechts in der kachel die termine vom + tag. in der mitte gesplittet. mach das richtig geil und hochwertig."* + + GEMESSEN WIRD AN DEN ECHTEN PIXELN, nicht am Stylesheet. Ein Wert, + der im CSS steht, kann von einer anderen Regel überschrieben sein -- + was zählt, ist, was am Ende auf dem Schirm ankommt. Diese Lehre kam + am selben Tag von der Teilnehmerwahl, wo vierzig grüne Prüfungen + nicht gemerkt hatten, dass die Namen aneinanderkleben. + + DIE VIER FRAGEN, die den Wunsch ausmachen: + 1. Ist es EINE Kachel (ein Rahmen), nicht zwei nebeneinander? + 2. Liegen die Hälften wirklich NEBENeinander, gleich breit? + 3. Sitzt der Trenner in der MITTE? + 4. Stehen rechts die Termine von HEUTE -- und nur die? + + Dazu die Fälle, die im Betrieb auftreten und leicht vergessen werden: + keine Termine, keine Hinweise, beides leer, und das Handy. + + GEGENPROBEN durchgehend. */ + +import { mkdtempSync, rmSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-tagesblick-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4303, "die Tagesblickpruefung"); + +process.env.PORT = "4303"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = "http://127.0.0.1:4303"; + +const PROTOKOLL = join(process.cwd(), "pruef-tagesblick-lauf.txt"); +try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } +const melde = (z) => { + console.log(z); + try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } +}; +let fehler = 0; +const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Bestand ------------------------------------------------------------ */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetztIso = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 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, salt, 32768, jetztIso); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idScout, jetztIso); + +/* ORTSZEIT, nicht UTC. Die Anwendung rechnet den Tag lokal -- ein mit + toISOString() gebildetes Datum wäre abends um zehn schon der nächste + Tag, und die Prüfung suchte Termine, die sie selbst auf morgen gelegt + hat. Genau diese Verwechslung steht als Warnung in start.js. */ +const n = new Date(); +const HEUTE = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, "0")}` + + `-${String(n.getDate()).padStart(2, "0")}`; +const MORGEN = (() => { + const m = new Date(n.getTime() + 86400000); + return `${m.getFullYear()}-${String(m.getMonth() + 1).padStart(2, "0")}` + + `-${String(m.getDate()).padStart(2, "0")}`; +})(); + +function termin(titel, tag, zeit, art = "call", dauer = 30) { + d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, + teilnehmer_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?)`) + .run(titel, art, `${tag}T${zeit}`, dauer, idLuna, idLuna, jetztIso, idDogi); +} +/* Drei heute, einer morgen. Der von morgen ist der eigentliche Prüfstein: + Er darf NICHT auftauchen -- und ohne ihn würde man nie merken, wenn + die Abgrenzung fehlt. */ +termin("Früh-Call mit Luna", HEUTE, "07:30", "call", 30); +termin("Content-Review", HEUTE, "14:00", "review", 60); +termin("Abstimmung Technik", HEUTE, "23:30", "termin", 45); +termin("Das ist morgen", MORGEN, "09:00", "call", 30); + +/* Etwas für die linke Hälfte -- ein Termin heute erzeugt ohnehin einen + Hinweis, aber eine überfällige Aufgabe ist der deutlichere Fall. */ +d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, frist, creator_id, + verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,'hoch',?,?,?,?,?)`) + .run("Längst fällig", "offen", "2020-01-01", idLuna, idLuna, jetztIso, idDogi); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +async function alsRolle(rolle, code, breite = 1280) { + const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 } }); + const seite = await ctx.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.waitForTimeout(1200); + return { ctx, seite }; +} + +/* ======================================================================= + 1. Eine Kachel, in der Mitte geteilt + ======================================================================= */ +melde("\n=== Eine Kachel, mittig geteilt ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + + const da = await seite.isVisible("#dran-block"); + ok(da, da ? "die Kachel ist da" : "die Kachel fehlt"); + + /* EIN Rahmen um beides -- nicht zwei Kacheln nebeneinander. */ + const rahmen = await seite.$eval("#dran-block", (e) => { + const s = getComputedStyle(e); + return { breite: s.borderTopWidth, radius: s.borderTopLeftRadius, anzeige: s.display }; + }); + ok(rahmen.anzeige === "grid" && parseFloat(rahmen.breite) >= 1 + && parseFloat(rahmen.radius) > 4, + `ein Rahmen um beides (${rahmen.anzeige}, ${rahmen.breite}, Radius ${rahmen.radius})`); + + /* NEBENEINANDER und gleich breit -- das ist der Kern des Wunsches. */ + const seiten = await seite.evaluate(() => { + const l = document.querySelector("#dran-seite").getBoundingClientRect(); + const r = document.querySelector("#heute-seite").getBoundingClientRect(); + return { + linksOben: Math.round(l.top), rechtsOben: Math.round(r.top), + linksBreit: Math.round(l.width), rechtsBreit: Math.round(r.width), + linksRechterRand: Math.round(l.right), rechtsLinkerRand: Math.round(r.left), + }; + }); + ok(Math.abs(seiten.linksOben - seiten.rechtsOben) <= 2, + `beide Hälften stehen nebeneinander (oben ${seiten.linksOben} / ${seiten.rechtsOben})`); + /* "In der Mitte gesplittet": höchstens 2 px Unterschied. */ + const unterschied = Math.abs(seiten.linksBreit - seiten.rechtsBreit); + ok(unterschied <= 2, + `gleich breit: ${seiten.linksBreit} px und ${seiten.rechtsBreit} px (${unterschied} px Unterschied)`); + + /* Der Trenner sitzt dazwischen, nicht irgendwo. */ + const trenner = await seite.evaluate(() => { + const e = document.querySelector("#heute-seite"); + const s = getComputedStyle(e, "::before"); + return { inhalt: s.content, breite: s.width, hintergrund: s.backgroundImage.slice(0, 30) }; + }); + ok(trenner.inhalt !== "none" && trenner.hintergrund.includes("gradient"), + `der Trenner ist da und läuft aus (${trenner.hintergrund}…)`); + + await ctx.close(); +} + +/* ======================================================================= + 2. Rechts: die Termine von HEUTE + ======================================================================= */ +melde("\n=== Die Termine des Tages ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + + const zeilen = await seite.$$eval(".tagesliste__punkt", (p) => p.map((x) => ({ + zeit: x.querySelector(".tagesliste__stunde")?.textContent, + titel: x.querySelector(".tagesliste__titel")?.textContent, + art: x.dataset.art, + naechster: x.dataset.naechster === "ja", + vorbei: x.dataset.vorbei === "ja", + }))); + + /* DIE ANZAHL GEHÖRT IN DIE BEDINGUNG: Bei null Zeilen liefe jede + folgende Messung über eine leere Menge und meldete grün. */ + ok(zeilen.length === 3, `${zeilen.length} Termine (erwartet 3 von heute)`); + ok(!zeilen.some((z) => /morgen/i.test(z.titel || "")), + `der Termin von morgen steht NICHT dabei: ${zeilen.map((z) => z.titel).join(" | ")}`); + + /* Aufsteigend nach Uhrzeit -- eine Tagesliste in falscher Reihenfolge + ist schlimmer als keine. */ + const zeiten = zeilen.map((z) => z.zeit); + ok(JSON.stringify(zeiten) === JSON.stringify([...zeiten].sort()), + `nach Uhrzeit sortiert: ${zeiten.join(" → ")}`); + + ok(zeilen[0]?.zeit === "07:30" && zeilen[1]?.zeit === "14:00", + `die Uhrzeiten stimmen (${zeiten.join(", ")}) — keine Zeitzonen-Verschiebung`); + + /* Die Art trägt ihre Farbe, dieselbe wie im Kalender. */ + const arten = zeilen.map((z) => z.art); + ok(arten.includes("call") && arten.includes("review") && arten.includes("termin"), + `die Arten kommen durch: ${arten.join(", ")}`); + + /* DIE FARBZUORDNUNG, zeitunabhängig gemessen. + + Der erste Entwurf las den RAND der Review-Marke und erwartete + Grün. Er bekam Grau -- und das war völlig richtig: Der Termin + stand auf 14:00, der Prüflauf war um 19:31, also ist er vorbei + und wird absichtlich gedämpft. Die Regel + `[data-vorbei="ja"] .tagesliste__marke { border-color: … }` hat + genau das getan, wofür sie da ist. + + Eine Prüfung, deren Ergebnis von der Uhrzeit des Laufs abhängt, + ist keine Prüfung -- sie ist morgens grün und abends rot, und + irgendwann glaubt ihr niemand mehr. Deshalb wird hier --ton + gelesen: die Zuordnung Art → Farbe, die immer gilt. Der Rand + folgt darunter, aber nur an einem Termin, der nachweislich noch + nicht vorbei ist. */ + const toene = await seite.$$eval(".tagesliste__punkt", (punkte) => punkte.map((p) => ({ + art: p.dataset.art, + ton: getComputedStyle(p).getPropertyValue("--ton").trim().toLowerCase(), + vorbei: p.dataset.vorbei === "ja", + rand: getComputedStyle(p.querySelector(".tagesliste__marke")).borderTopColor, + }))); + + const SOLL = { call: "#3fbdf5", termin: "#8a76ff", review: "#50a752", frist: "#d17717" }; + const falsch = toene.filter((t) => SOLL[t.art] && t.ton !== SOLL[t.art]); + ok(falsch.length === 0 && toene.length === 3, + falsch.length + ? `falsche Farbe: ${falsch.map((t) => `${t.art}=${t.ton}`).join(", ")}` + : `alle ${toene.length} Termine tragen die Kalenderfarbe ihrer Art ` + + `(${toene.map((t) => `${t.art} ${t.ton}`).join(", ")})`); + + /* Und der Rand nimmt sie auch wirklich an -- gemessen an einem + Termin, der noch aussteht. Gibt es keinen (ein Lauf kurz vor + Mitternacht), wird das gesagt statt behauptet: der dritte Ausgang. */ + const offen = toene.find((t) => !t.vorbei); + if (offen) { + const soll = SOLL[offen.art]; + const r = parseInt(soll.slice(1, 3), 16), g = parseInt(soll.slice(3, 5), 16), b = parseInt(soll.slice(5, 7), 16); + ok(offen.rand.replace(/\s/g, "") === `rgb(${r},${g},${b})`, + `der Ring des nächsten Termins (${offen.art}) trägt sie sichtbar: ${offen.rand}`); + } else { + melde(` -- kein ausstehender Termin zur Zeit des Laufs — der Ring ` + + `wurde nicht am Bild geprüft (die Zuordnung darüber schon)`); + } + + /* GEGENPROBE zur Dämpfung: Ein vorbeigezogener Termin MUSS anders + aussehen. Ohne diesen Nachweis wäre "vorbei tritt zurück" nur + eine Behauptung im Stylesheet. */ + const vorbei = toene.find((t) => t.vorbei); + if (vorbei) { + ok(vorbei.rand.replace(/\s/g, "") !== `rgb(80,167,82)`.replace(/\s/g, "") + || vorbei.art !== "review", + `ein vorbeigezogener Termin ist gedämpft (Ring ${vorbei.rand} statt der Artfarbe)`); + } + + /* GENAU EINER ist "als Nächstes dran" -- nicht null, nicht zwei. */ + const naechste = zeilen.filter((z) => z.naechster).length; + ok(naechste === 1, `genau ${naechste} Termin ist als Nächstes hervorgehoben`); + + /* Der um 23:30 kann nicht vorbei sein, der um 07:30 (meist) schon. + Geprüft wird die Regel, nicht die Uhrzeit des Prüflaufs: Was als + Nächstes dran ist, darf nie zugleich "vorbei" sein. */ + ok(!zeilen.some((z) => z.naechster && z.vorbei), + "kein Termin ist gleichzeitig 'als Nächstes' und 'vorbei'"); + + await ctx.close(); +} + +/* ======================================================================= + 3. Die leeren Fälle + ======================================================================= */ +melde("\n=== Wenn eine Hälfte nichts zu zeigen hat ==="); +{ + /* Ein Creator ohne Termine und ohne offene Punkte: Die Kachel darf + dann nicht als leerer Rahmen dastehen. */ + const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001"); + const sichtbar = await seite.isVisible("#dran-block"); + const zeilen = (await seite.$$(".tagesliste__punkt")).length; + const hinweise = (await seite.$$(".dran__punkt")).length; + + if (zeilen === 0 && hinweise === 0) { + ok(!sichtbar, "beide Hälften leer → die Kachel bleibt ganz weg"); + } else { + /* Hat er etwas, muss die Kachel stehen -- und die leere Hälfte + einen Satz tragen statt einer Lücke. */ + ok(sichtbar, `er hat ${hinweise} Hinweise und ${zeilen} Termine → Kachel ist da`); + if (!zeilen) { + const satz = await seite.isVisible("#heute-leer"); + ok(satz, "die leere Terminseite sagt 'Heute steht nichts an'"); + } + if (!hinweise) { + const satz = await seite.isVisible("#dran-leer"); + ok(satz, "die leere Hinweisseite sagt, dass nichts offen ist"); + } + } + await ctx.close(); +} + +/* ======================================================================= + 4. Am Handy: untereinander + ======================================================================= */ +melde("\n=== Am Handy (390 px) ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 390); + + const lage = await seite.evaluate(() => { + const l = document.querySelector("#dran-seite").getBoundingClientRect(); + const r = document.querySelector("#heute-seite").getBoundingClientRect(); + return { linksUnten: Math.round(l.bottom), rechtsOben: Math.round(r.top) }; + }); + ok(lage.rechtsOben >= lage.linksUnten - 2, + `untereinander statt nebeneinander (links endet ${lage.linksUnten}, rechts beginnt ${lage.rechtsOben})`); + + const ueber = await seite.evaluate(() => + Math.round(document.documentElement.scrollWidth - document.documentElement.clientWidth)); + ok(ueber <= 0, `nichts steht seitlich über (${ueber} px)`); + + /* Ein langer Titel darf die Kachel nicht sprengen -- deshalb die + Ellipsis. Geprüft am gerechneten Stil, nicht an der CSS-Datei. */ + const schnitt = await seite.$eval(".tagesliste__titel", + (e) => getComputedStyle(e).textOverflow); + ok(schnitt === "ellipsis", `lange Titel werden abgeschnitten (${schnitt})`); + + await ctx.close(); +} + +/* ======================================================================= + GEGENPROBE — misst die Prüfung überhaupt etwas? + ======================================================================= */ +melde("\n=== Gegenprobe ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + + /* 1. Trifft der Selektor wirklich? */ + const vorher = (await seite.$$(".tagesliste__punkt")).length; + await seite.evaluate(() => { + for (const p of document.querySelectorAll(".tagesliste__punkt")) p.remove(); + }); + const nachher = (await seite.$$(".tagesliste__punkt")).length; + ok(vorher === 3 && nachher === 0, + `der Selektor trifft (${vorher} gefunden, nach dem Entfernen ${nachher})`); + + /* 2. Erkennt die Breitenmessung eine UNgleiche Teilung? Ohne diesen + Nachweis wäre "gleich breit" oben nur eine Behauptung. */ + await seite.reload({ waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + await seite.addStyleTag({ content: + "#dran-block { grid-template-columns: 3fr 1px 1fr !important; }" }); + await seite.waitForTimeout(150); + const schief = await seite.evaluate(() => { + const l = document.querySelector("#dran-seite").getBoundingClientRect(); + const r = document.querySelector("#heute-seite").getBoundingClientRect(); + return Math.abs(Math.round(l.width) - Math.round(r.width)); + }); + ok(schief > 2, + `mit absichtlich schiefer Teilung misst die Prüfung ${schief} px Unterschied ` + + `— sie erkennt eine ungleiche Kachel also`); + + await ctx.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +melde(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-tempo-workspace.mjs b/server/pruef-tempo-workspace.mjs index f2a0c7b5..6846764d 100644 --- a/server/pruef-tempo-workspace.mjs +++ b/server/pruef-tempo-workspace.mjs @@ -150,6 +150,7 @@ let seitenGesamt = 0, anfragenGesamt = 0, bytesGesamt = 0; const doppelte = []; const langsame = []; const springende = []; +const schwankend = []; /* Ausschlag da, aber beim Nachmessen weg */ const zuGross = []; for (const datei of SEITEN) { @@ -211,7 +212,53 @@ for (const datei of SEITEN) { } } if (dauer > GRENZE_MS) langsame.push(`${datei} ${dauer} ms`); - if (sprung > GRENZE_SPRUNG) springende.push(`${datei} ${sprung}`); + + /* EIN AUSSCHLAG WIRD BESTAETIGT, BEVOR ER GEMELDET WIRD (05.09.2026). + + Der Layout-Sprung ist ein SCHWANKENDER Messwert. Er haengt davon + ab, wie schnell der Server in dieser Sekunde antwortet: Kommen die + Daten, waehrend das Geruest noch aufgebaut wird, springt nichts -- + kommen sie danach, springt die Liste auf ihre volle Hoehe. + Nachgemessen am 05.09.2026, dieselbe Seite, drei Laeufe + hintereinander: 0,478 / 0,262 / 0,262. Und bereich.html meldete + einmal 0,703 und beim naechsten Lauf 0,347. + + Eine Pruefung, die zufaellig rot wird, ist genauso wertlos wie + eine, die nie anschlaegt -- man klickt sie weg, und mit ihr die + echte Warnung. (Dieselbe Lehre wie bei der Loeschwarnung, die + immer kam.) + + Deshalb wird beim Ueberschreiten NOCH EINMAL gemessen und nur + gemeldet, wenn es sich wiederholt. Das kostet Zeit ausschliesslich + im Verdachtsfall. Ein ECHTER Layout-Sprung -- ein Bild ohne + Groessenangabe, eine Schrift, die nachlaedt -- kommt bei jedem + Lauf, der ueberlebt diese zweite Messung muehelos. + + WICHTIG: Gemeldet wird der ZWEITE Wert, nicht der kleinere von + beiden. Sonst waere es keine Bestaetigung, sondern eine + Beschoenigung. */ + if (sprung > GRENZE_SPRUNG) { + await seite.goto(BASIS + "/workspace/" + datei, { waitUntil: "networkidle" }); + await seite.waitForTimeout(400); + const zweitens = await seite.evaluate(() => new Promise((fertig) => { + let summe = 0; + try { + const b = new PerformanceObserver((liste) => { + for (const e of liste.getEntries()) if (!e.hadRecentInput) summe += e.value; + }); + b.observe({ type: "layout-shift", buffered: true }); + setTimeout(() => { b.disconnect(); fertig(Math.round(summe * 1000) / 1000); }, 900); + } catch { fertig(0); } + })); + if (zweitens > GRENZE_SPRUNG) { + springende.push(`${datei} ${sprung} und erneut ${zweitens}`); + } else { + /* Nicht verschweigen: Der Ausschlag war da, er hat sich nur nicht + bestaetigt. Wer die Zahl haeufig sieht, weiss, dass die Seite + nahe an der Grenze liegt. */ + schwankend.push(`${datei} ${sprung} → beim Nachmessen ${zweitens}`); + } + } if (anfragen > GRENZE_ANFRAGEN || bytes > GRENZE_BYTES) { zuGross.push(`${datei} ${anfragen} Anfragen / ${Math.round(bytes / 1024)} KB`); } @@ -270,7 +317,15 @@ ok(langsame.length === 0, langsame.length ? `ueber ${GRENZE_MS} ms: ${langsame.join(", ")}` : `keine Seite braucht laenger als ${GRENZE_MS} ms`); ok(springende.length === 0, springende.length ? `Layout springt ueber ${GRENZE_SPRUNG}: ${springende.join(", ")}` - : `kein Layout-Sprung ueber der Grundlinie ${GRENZE_SPRUNG} (Ziel bleibt ${ZIEL_SPRUNG})`); + : `kein bestaetigter Layout-Sprung ueber der Grundlinie ${GRENZE_SPRUNG} (Ziel bleibt ${ZIEL_SPRUNG})`); + +/* Kein Fehler, aber es gehoert gesagt: Diese Seiten lagen einmal + darueber und beim zweiten Messen nicht mehr. Wer den Hinweis oft + sieht, weiss, dass die Grundlinie knapp geworden ist. */ +if (schwankend.length) { + console.log(` hinw ${schwankend.length} Ausschlag(e) haben sich beim Nachmessen ` + + `NICHT bestaetigt: ${schwankend.join(" · ")}`); +} /* Der ehrliche Zwischenstand, damit die Grundlinie nicht als "alles gut" missverstanden wird -- er zaehlt nicht als Fehler, steht aber diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 522ed918..05c00ae9 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -3151,3 +3151,302 @@ body[data-fremde-sicht="ja"]::after { color: var(--text-still); max-width: 76ch; } + +/* ===================================================================== + DER TAGESBLICK — eine Kachel, in der Mitte geteilt + ===================================================================== + + Wunsch Filipe, 05.09.2026: eine ganze Kachel, links die Hinweise, + rechts die Termine des Tages, in der Mitte geteilt -- "richtig geil + und hochwertig". + + WAS "HOCHWERTIG" HIER HEISST — und was nicht: + + Nicht mehr Effekte. Der Eindruck entsteht aus drei Dingen, die man + einzeln kaum bemerkt und zusammen sofort: + + 1. EINE Kante um beides statt zwei Kacheln nebeneinander. Zwei + Rahmen lesen sich als zwei Themen; einer sagt "zwei Seiten + derselben Sache". + 2. Ein Trenner, der oben und unten AUSLÄUFT statt durchzuschneiden. + Eine harte Linie zerteilt die Kachel, ein Verlauf gliedert sie. + 3. Ruhe. Der Hintergrund bleibt fast schwarz, die Farbe steckt in + wenigen kleinen Flächen -- den Punkten auf dem Zeitstrahl. Wo + alles leuchtet, leuchtet nichts. + + Augenschonend ist Hausregel: keine harten Weißflächen, keine + Neonränder, Bewegung nur als kurze Blende. + + AUFBAU: ein Raster mit drei Spalten -- Inhalt, Trenner, Inhalt. Der + Trenner ist eine eigene Spalte und kein Rand, damit er unabhängig von + der Höhe beider Seiten auf voller Länge sitzt und auslaufen kann. */ + +.tagesblick { + display: grid; + grid-template-columns: 1fr 1px 1fr; + gap: 0; + margin-bottom: 30px; + border: 1px solid var(--rand); + border-radius: var(--radius); + background: + /* Ein sehr flacher Schein von links oben -- er gibt der Fläche + Tiefe, ohne als Farbe aufzufallen. */ + radial-gradient(120% 90% at 0% 0%, + rgba(255, 255, 255, .035), transparent 58%), + var(--flaeche); + overflow: hidden; +} + +/* Die Überschrift der ganzen Kachel ist für Vorleseprogramme da -- + auf dem Schirm sagen die beiden Schilder schon alles, und eine + dritte Überschrift darüber wäre eine Zeile ohne Inhalt. */ +.tagesblick__titel { + position: absolute; + width: 1px; height: 1px; margin: -1px; padding: 0; + overflow: hidden; clip-path: inset(50%); white-space: nowrap; +} + +/* DIE SPALTEN WERDEN AUSDRÜCKLICH ZUGEWIESEN (05.09.2026). + + Ohne das rutschte die rechte Hälfte in die 1-Pixel-Spalte des + Trenners und war 38 statt 579 Pixel breit. Grund: Die Kachel hat drei + Kinder -- die versteckte Überschrift, links, rechts -- und ein Raster + verteilt sie der Reihe nach. `position: absolute` an der Überschrift + nimmt sie zwar aus dem Fluss, aber verlassen sollte man sich darauf + nicht: Wer später ein viertes Kind einfügt (einen Ladebalken, einen + Hinweis), verschiebt sonst wieder alles um eine Spalte, ohne dass + irgendwo ein Fehler erscheint. + + Mit `grid-column` ist die Zuordnung an die Sache gebunden statt an + die Reihenfolge im Dokument. */ +.tagesblick__seite--dran { grid-column: 1; } +.tagesblick__seite--heute { grid-column: 3; } + +.tagesblick__seite { + display: flex; flex-direction: column; + padding: 17px 19px 19px; + min-width: 0; /* sonst sprengt ein langer Titel die Spalte */ +} + +/* DER TRENNER. Oben und unten durchsichtig: Eine Linie, die die Kachel + von Kante zu Kante durchschneidet, macht aus einer Kachel zwei. Eine, + die ausläuft, gliedert sie nur. */ +.tagesblick__seite--heute { position: relative; } +.tagesblick__seite--heute::before { + content: ""; + position: absolute; left: -1px; top: 14px; bottom: 14px; width: 1px; + background: linear-gradient(180deg, + transparent, + var(--rand-hell) 18%, + var(--rand-hell) 82%, + transparent); +} + +.tagesblick__kopf { margin-bottom: 12px; } + +/* Der Kalender-Verweis rechts oben. Still, bis man ihn braucht. */ +.tagesblick__mehr { + display: inline-flex; align-items: center; gap: 5px; + font-size: .78rem; + text-decoration: none; +} +.tagesblick__mehr-pfeil { width: 14px; height: 14px; } +.tagesblick__mehr:hover .tagesblick__mehr-pfeil { transform: translateX(2px); } + +/* Der Satz, wenn eine Hälfte nichts zu zeigen hat. Er steht in der + Fläche, die sonst die Liste füllt -- ohne ihn sähe die Seite aus, + als hätte sie nicht geladen. */ +.tagesblick__leer { + flex: 1; + display: flex; align-items: center; + margin: 0; padding: 14px 0; + font-size: .84rem; color: var(--text-still); +} + +/* ---------- Die Termine als Zeitstrahl ---------------------------------- */ + +.tagesliste { + display: grid; gap: 2px; + margin: 0; padding: 0; list-style: none; + position: relative; +} + +/* Die senkrechte Linie hinter den Punkten. Sie beginnt am ersten und + endet am letzten Punkt -- eine Linie, die über den letzten Termin + hinausläuft, verspricht etwas, das nicht kommt. */ +.tagesliste::before { + content: ""; + position: absolute; left: 61px; top: 18px; bottom: 18px; width: 1px; + background: linear-gradient(180deg, + transparent, var(--rand-hell) 12%, var(--rand-hell) 88%, transparent); +} + +.tagesliste__punkt { + --ton: #8a76ff; + position: relative; + display: grid; + grid-template-columns: 46px 16px 1fr auto; + align-items: center; + gap: 10px; + padding: 9px 10px 9px 0; + border-radius: var(--radius-klein); + transition: background .18s ease; +} +/* Dieselben vier Farben wie im Kalender (ARTFARBE in kalender.js). + Eine Farbe, die hier etwas anderes bedeutete als dort, müsste man + zweimal lernen. */ +.tagesliste__punkt[data-art="call"] { --ton: #3fbdf5; } +.tagesliste__punkt[data-art="termin"] { --ton: #8a76ff; } +.tagesliste__punkt[data-art="review"] { --ton: #50a752; } +.tagesliste__punkt[data-art="frist"] { --ton: #d17717; } + +.tagesliste__zeit { + display: flex; flex-direction: column; align-items: flex-end; + gap: 1px; + /* Tabellenziffern: Sonst tanzen die Uhrzeiten untereinander, weil + die 1 schmaler ist als die 8. */ + font-variant-numeric: tabular-nums; +} +.tagesliste__stunde { + font-size: .88rem; font-weight: 650; color: var(--text-leise); + letter-spacing: .01em; +} +/* 0,74 rem = 11,84 px. NICHT kleiner: Unter 11,5 px meldet die + Geräteprüfung zu Recht "zu kleine Schrift" -- und was auf dem Handy + niemand liest, ist auf dem Rechner nur Zierde. Der erste Entwurf + hatte 0,68 rem (10,88 px). */ +.tagesliste__dauer { font-size: .74rem; color: var(--text-still); } + +/* Der Punkt auf der Linie. Ein Ring, kein Klecks -- er soll die Linie + markieren, nicht überdecken. */ +.tagesliste__marke { + justify-self: center; + width: 9px; height: 9px; border-radius: 50%; + background: #0b0f16; + border: 2px solid var(--ton); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ton) 12%, transparent); +} + +.tagesliste__text { min-width: 0; } +.tagesliste__titel { + margin: 0; + font-size: .89rem; font-weight: 600; color: var(--text-leise); + /* Ein langer Titel darf die Kachel nicht breiter machen. */ + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.tagesliste__unter { + margin: 2px 0 0; + font-size: .75rem; color: var(--text-still); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.tagesliste__art { + padding: 2px 9px; border-radius: 999px; + /* 11,84 px statt der ursprünglichen 10,56. Gemeldet von pruef-handy + auf allen drei Geräteklassen: "zu kleine Schrift: Call 10.56px". + Großbuchstaben mit Sperrung wirken kleiner als sie sind -- gerade + deshalb ist die Grenze hier nicht verhandelbar. */ + font-size: .74rem; font-weight: 700; + letter-spacing: .05em; text-transform: uppercase; + color: color-mix(in srgb, var(--ton) 72%, #ffffff); + background: color-mix(in srgb, var(--ton) 11%, transparent); + border: 1px solid color-mix(in srgb, var(--ton) 26%, transparent); + white-space: nowrap; +} + +/* Der ganze Eintrag ist anklickbar, ohne dass ein um alles liegt: + Ein Link, der Überschrift, Zeit und Marke umschließt, liest sich für + ein Vorleseprogramm als ein einziger langer Name. So bleibt die + Struktur erhalten, und das aria-label sagt den ganzen Satz. */ +.tagesliste__link { + position: absolute; inset: 0; + border-radius: var(--radius-klein); +} +.tagesliste__link:focus-visible { + outline: 2px solid var(--ton); outline-offset: -2px; +} +.tagesliste__punkt:hover { background: rgba(255, 255, 255, .035); } + +/* WAS ALS NÄCHSTES DRAN IST. Beim Überfliegen ist das die eigentliche + Auskunft -- nicht "der erste Termin des Tages". */ +.tagesliste__punkt[data-naechster="ja"] { + background: color-mix(in srgb, var(--ton) 7%, transparent); +} +.tagesliste__punkt[data-naechster="ja"] .tagesliste__titel { color: var(--text); } +.tagesliste__punkt[data-naechster="ja"] .tagesliste__stunde { color: var(--text); } +.tagesliste__punkt[data-naechster="ja"] .tagesliste__marke { + background: var(--ton); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--ton) 16%, transparent); +} + +/* VORBEI heißt nicht weg. Man will sehen, was man heute schon hinter + sich hat -- es tritt nur zurück. */ +.tagesliste__punkt[data-vorbei="ja"] { opacity: .45; } +.tagesliste__punkt[data-vorbei="ja"]:hover { opacity: .75; } +.tagesliste__punkt[data-vorbei="ja"] .tagesliste__marke { + border-color: var(--text-still); + box-shadow: none; +} + +/* ---------- Schmale Schirme -------------------------------------------- + + Untereinander statt nebeneinander -- und der Trenner wird waagerecht. + Zwei Spalten auf einem Handy hieße zwei halbe Zeilen, in denen jeder + Titel abgeschnitten ist. */ +@media (max-width: 900px) { + .tagesblick { grid-template-columns: 1fr; } + /* Die feste Spaltenzuweisung von oben muss hier zurückgenommen + werden -- "Spalte 3" gibt es in einem einspaltigen Raster nicht, + und der Browser legt dann stillschweigend zwei leere Spalten an. */ + .tagesblick__seite--dran, + .tagesblick__seite--heute { grid-column: 1; } + .tagesblick__seite--heute::before { + left: 14px; right: 14px; top: -1px; bottom: auto; + width: auto; height: 1px; + background: linear-gradient(90deg, + transparent, var(--rand-hell) 18%, var(--rand-hell) 82%, transparent); + } + .tagesblick__seite { padding: 15px 16px 17px; } + .tagesblick__seite--heute { padding-top: 17px; } +} + +@media (max-width: 460px) { + /* Die Dauer entfällt zuerst -- sie ist die Angabe, die man am + seltensten braucht, und schafft Platz für den Titel. */ + .tagesliste__dauer { display: none; } + .tagesliste__punkt { + grid-template-columns: 40px 14px 1fr; + gap: 8px; + /* OBEN AUSRICHTEN, nicht mittig. Sobald die Plakette unter den + Titel rutscht, wird die Zeile hoch -- eine mittig gesetzte + Uhrzeit steht dann auf Höhe der zweiten Zeile und fluchtet mit + gar nichts. Am Rechner bleibt es mittig, dort ist die Zeile + einzeilig. */ + align-items: start; + } + /* Die Zeit und der Punkt auf der Linie rücken auf die Höhe des + Titels -- die drei gehören optisch zusammen. */ + .tagesliste__zeit { padding-top: 1px; } + .tagesliste__marke { margin-top: 6px; } + /* Die Art steht dann unter dem Titel statt daneben. */ + .tagesliste__art { + grid-column: 3; justify-self: start; + margin-top: 6px; + } + .tagesliste::before { left: 54px; } +} + +@media (prefers-reduced-motion: reduce) { + .tagesliste__punkt, .tagesblick__mehr-pfeil { transition: none; } +} + +/* Ohne Farbverläufe tragen Kante und Ring allein. Der Trenner wird zur + echten Linie -- ein Verlauf, den es nicht gibt, wäre sonst gar nichts. */ +@media (forced-colors: active) { + .tagesblick { border: 1px solid CanvasText; } + .tagesblick__seite--heute::before { background: CanvasText; } + .tagesliste::before { background: CanvasText; } + .tagesliste__marke { border-color: CanvasText; } + .tagesliste__art { border: 1px solid CanvasText; } + .tagesliste__punkt[data-vorbei="ja"] { opacity: 1; } +} diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index f317fe94..d6919a90 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -392,8 +392,13 @@ window.werZeigen?.(ich); grussZeigen(); - /* Die Hinweise zuerst -- die Kacheln brauchen ihre Zahlen. */ + /* Die Hinweise zuerst -- die Kacheln brauchen ihre Zahlen. + Die Tagestermine laufen daneben her: Sie sind die rechte Hälfte + derselben Kachel, aber niemand soll auf sie warten. Scheitern + sie, steht die linke Hälfte trotzdem. */ + const termineLaeuft = tagestermineLaden(); await hinweiseLaden(); + await termineLaeuft; /* Erst jetzt steht fest, wie die Lage ist. */ lageZeigen(); bereicheBauen(); @@ -442,7 +447,18 @@ hinweise = daten.hinweise || []; const liste = $('dran'); liste.textContent = ''; - if (!hinweise.length) { $('dran-block').hidden = true; return; } + + /* DIE KACHEL HAT ZWEI HÄLFTEN (05.09.2026), und keine darf die + andere mitreißen. + + Hier stand `if (!hinweise.length) { block.hidden = true; return; }` + -- solange die Kachel nur die Hinweise enthielt, war das richtig. + Jetzt stehen rechts die Termine des Tages: Wer nichts Offenes + hat, aber drei Calls, hätte seinen Tagesplan nicht mehr gesehen. + Über das Verstecken entscheidet deshalb tagesblickPruefen(), + wenn BEIDE Seiten geladen sind. */ + $('dran-leer').hidden = hinweise.length > 0; + if (!hinweise.length) { tagesblickPruefen(); return; } for (const h of hinweise) { const li = el('li', 'dran__punkt'); @@ -490,7 +506,134 @@ Sichtbar bleiben vier, der Rest ist einen Klick entfernt. Derselbe Helfer wie im Protokoll und im Verlauf. */ window.klappe?.(liste, $('dran-schalter'), $('dran-schalter-text')); - $('dran-block').hidden = false; + tagesblickPruefen(); + } + + /* ---------- Die rechte Hälfte: was heute ansteht ------------------------ + + Wunsch 05.09.2026: *"rechts in der kachel die termine vom tag."* + + WARUM NEBEN DEN HINWEISEN: Links steht, was zu TUN ist, rechts, + was schon FESTSTEHT. Zusammen beantworten sie die Frage, die man + morgens hat -- und die man vorher nur durch Scrollen beantworten + konnte. + + Die Daten kommen aus derselben Schnittstelle wie der Kalender, mit + `tage=1`. Keine zweite Abfrage, keine zweite Sichtbarkeitsregel: + Was jemand im Kalender nicht sehen darf, kommt hier gar nicht erst + an. */ + + let tagesterminGeladen = false; + + /** Entscheidet, ob die Kachel überhaupt etwas zu zeigen hat. + * + * Läuft nach BEIDEN Hälften. Eine Kachel mit zwei leeren Seiten wäre + * ein Rahmen um nichts -- eine mit einer vollen Seite dagegen ist + * genau richtig, und welche das ist, steht vorher nicht fest. */ + function tagesblickPruefen() { + const etwasLinks = hinweise.length > 0; + const etwasRechts = $('tagestermine').childElementCount > 0; + /* Solange die Termine noch unterwegs sind, wird nicht versteckt -- + sonst blitzt die Kachel auf und verschwindet wieder. */ + if (!tagesterminGeladen) { + if (etwasLinks) $('dran-block').hidden = false; + return; + } + $('dran-block').hidden = !etwasLinks && !etwasRechts; + } + + /** hh:mm aus einem Zeitstempel, ohne Zeitzonen-Rechnerei. + * Die Werte kommen als "2026-09-05T14:30" vom Server -- schon in + * Ortszeit. Ein `new Date()` darauf würde sie ein zweites Mal + * umrechnen und Termine um Stunden verschieben. */ + const uhrzeit = (beginn) => String(beginn || '').slice(11, 16); + + const ART_NAME = { call: 'Call', termin: 'Termin', review: 'Review', frist: 'Frist' }; + + async function tagestermineLaden() { + const liste = $('tagestermine'); + let termine = []; + try { + const heute = window.heuteLokal(); + const antwort = await fetch( + `/workspace/api/termine?von=${heute}&tage=1`, { credentials: 'same-origin' }); + if (antwort.ok) { + const daten = await antwort.json(); + /* `tage=1` liefert den Zeitraum bis morgen früh -- auf den + heutigen Tag wird hier noch einmal genau geschnitten. */ + termine = (daten.termine || []).filter((t) => String(t.beginn).slice(0, 10) === heute); + } + } catch { /* die linke Hälfte steht trotzdem */ } + + liste.textContent = ''; + tagesterminGeladen = true; + + if (!termine.length) { + $('heute-leer').hidden = false; + tagesblickPruefen(); + return; + } + $('heute-leer').hidden = true; + + /* Was ist gerade dran? Der erste Termin, der noch nicht vorbei ist. + Er wird hervorgehoben -- beim Überfliegen ist genau das die + Auskunft, die man sucht, und nicht "der erste des Tages". */ + const jetzt = new Date(); + const jetztText = `${String(jetzt.getHours()).padStart(2, '0')}:` + + `${String(jetzt.getMinutes()).padStart(2, '0')}`; + const naechster = termine.find((t) => { + const ende = new Date(Date.parse(t.beginn.replace(' ', 'T')) + + (Number(t.dauer_min) || 30) * 60000); + return !Number.isNaN(ende.getTime()) ? ende >= jetzt : uhrzeit(t.beginn) >= jetztText; + }); + + for (const t of termine) { + const li = el('li', 'tagesliste__punkt'); + li.dataset.art = t.art || 'termin'; + + const beginn = uhrzeit(t.beginn); + if (t === naechster) li.dataset.naechster = 'ja'; + /* Vorbei heißt nicht weg: Man will sehen, was man heute schon + hinter sich hat. Es tritt nur zurück. */ + else if (beginn < jetztText) li.dataset.vorbei = 'ja'; + + const zeit = el('span', 'tagesliste__zeit'); + zeit.append(el('span', 'tagesliste__stunde', beginn)); + if (t.dauer_min) { + zeit.append(el('span', 'tagesliste__dauer', `${t.dauer_min} min`)); + } + li.append(zeit); + + /* Der Punkt auf der Linie. Er trägt die Farbe der Terminart -- + dieselbe wie im Kalender, damit man sie nicht zweimal lernt. */ + const marke = el('span', 'tagesliste__marke'); + marke.setAttribute('aria-hidden', 'true'); + li.append(marke); + + const text = el('div', 'tagesliste__text'); + text.append(el('p', 'tagesliste__titel', t.titel)); + + const unten = []; + if (t.teilnehmer_name) unten.push(t.teilnehmer_name); + else if (t.creator_name) unten.push(t.creator_name); + if (t.ort) unten.push(t.ort); + if (unten.length) text.append(el('p', 'tagesliste__unter', unten.join(' · '))); + li.append(text); + + const art = el('span', 'tagesliste__art', ART_NAME[t.art] || t.art || ''); + li.append(art); + + /* Der ganze Eintrag führt in den Kalender, auf den richtigen Tag. + Ein Termin, den man nur anschauen kann, ist eine Sackgasse. */ + const link = el('a', 'tagesliste__link'); + link.href = `kalender.html?tag=${String(t.beginn).slice(0, 10)}`; + link.setAttribute('aria-label', + `${beginn} Uhr, ${t.titel}${unten.length ? ', ' + unten.join(', ') : ''}`); + li.append(link); + + liste.append(li); + } + tagesblickPruefen(); } /* Die Zahlen kommen aus derselben Quelle wie das Brett und sind bereits diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 12fbd347..cc7e6cb8 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -8,9 +8,9 @@ - - - + + + @@ -210,10 +210,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index b2fabb40..2354c497 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -133,10 +133,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index eeb982b5..c6e8b82c 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -116,14 +116,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 8448afbe..371df985 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -75,11 +75,11 @@ - - - - - - + + + + + + diff --git a/workspace/content.html b/workspace/content.html index c11f3691..75fc1d22 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -165,12 +165,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index d8263d4a..47ed1cf4 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -94,10 +94,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 9ca88b1b..2893af09 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -26,7 +26,7 @@ - + @@ -144,6 +144,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 670d1019..fa9707c5 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -205,10 +205,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index fbb61a66..dbaa4742 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -122,10 +122,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 827c5cec..1643c9c6 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -200,11 +200,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index 087994b9..595e1f22 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -92,11 +92,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 23173857..65c127f3 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -125,10 +125,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index d85652c8..5c727495 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -8,8 +8,8 @@ - - + + @@ -56,18 +56,61 @@ -