diff --git a/server/pruef-countdown.mjs b/server/pruef-countdown.mjs new file mode 100644 index 00000000..6f8d9613 --- /dev/null +++ b/server/pruef-countdown.mjs @@ -0,0 +1,286 @@ +/* ===================================================================== + „WANN IST DER NÄCHSTE STREAM?" (17.09.2026) + + Aus dem Plan im Vault, Stufe 2. Bei "Was ansteht" gibt es genau EINE + Frage, die ein Zuschauer hat -- und sie stand bisher in Zeile vier + einer Liste wie jede andere Angabe auch. + + --------------------------------------------------------------------- + DIE LUECKE, DIE ERST BEIM BAUEN AUFFIEL + + Der Plan versprach "Nächster Stream in 3 Std 12 Min". Die Daten + gaben das nicht her: `eintraege.datum` ist ein TAG, die Pruefung im + Server lehnte eine Uhrzeit ausdruecklich ab + (`^\d{4}-\d{2}-\d{2}$`). Ein Stundencountdown war unmoeglich. + + Das ist die uebliche Sorte Planungsluecke -- sie steht nicht im Plan, + sie steht in der Datenbank. Die Spalte `uhrzeit` ist deshalb heute + dazugekommen, OPTIONAL: Viele Termine haben keine Uhrzeit ("diese + Woche"), und ein Pflichtfeld haette dafuer eine erfundene erzwungen. + Ohne Uhrzeit zaehlt der Countdown in Tagen, und "morgen" ist eine + ehrliche Antwort. + + --------------------------------------------------------------------- + WAS HIER GEPRUEFT WIRD + + 1. Die Uhrzeit kommt an, kommt zurueck -- und laesst sich wieder + ENTFERNEN. (Ein Feld, das `""` als "unveraendert" liest, macht + aus dem Loeschen ein Nichtstun, ohne Fehlermeldung.) + 2. Eine unmoegliche Uhrzeit wird abgelehnt. + 3. Die Antwort steht im ERSTEN Bildschirm -- das war das + Abnahmekriterium aus dem Plan, gemessen in Pixeln bei 390 px. + 4. Ohne Termin steht ein SATZ da, keine leere Flaeche. + 5. Der Zeitstrahl sortiert: Kommendes oben, Vergangenes unten. + + Aufrufen mit: node server/pruef-countdown.mjs + Bild: SCHIRM=1 node server/pruef-countdown.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { heuteLokal, tagLokal } from "./helfer-tag.mjs"; + +const PORT = await portMussFreiSein(4481, "pruef-countdown"); +const ordner = mkdtempSync(join(tmpdir(), "ws-count-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +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 }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(180_000, "pruef-countdown"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = `http://127.0.0.1:${PORT}`; + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const schluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); +const salt = randomBytes(16).toString("hex"); +const hash = scryptSync("CODE-DOGI-0001", 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, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run("Filipe", "admin", hash, salt, 32768, + createHmac("sha256", schluessel).update("CODE-DOGI-0001").digest("hex"), jetzt); +const spalten = d.prepare("PRAGMA table_info(eintraege)").all().map((s) => s.name); +d.close(); + +const an = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), +}); +const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); +const jsonKopf = { "Content-Type": "application/json", cookie: keks }; + +async function anlegen(felder) { + const a = await fetch(BASIS + "/workspace/api/bereich/ansteht", { + method: "POST", headers: jsonKopf, body: JSON.stringify(felder), + }); + return { status: a.status, daten: await a.json().catch(() => null) }; +} +async function liste() { + const a = await fetch(BASIS + "/workspace/api/bereich/ansteht", { headers: { cookie: keks } }); + return (await a.json().catch(() => null))?.eintraege || []; +} + +/* ======================================================================= + 0. DER PLATZ DAFUER + ======================================================================= */ +melde(""); +melde("=== 0. Die Spalte ==="); +ok(spalten.includes("uhrzeit"), + `die Eintraege haben eine Spalte 'uhrzeit' (${spalten.length} Spalten)`); + +/* ======================================================================= + 1. SETZEN, WIEDERFINDEN, ENTFERNEN + ======================================================================= */ +melde(""); +melde("=== 1. Die Uhrzeit ==="); +let idHeute = 0; +{ + const a = await anlegen({ art: "stream", titel: "Abendstream", + datum: heuteLokal(), uhrzeit: "23:30" }); + ok(a.status === 201, `ein Stream mit Uhrzeit (${a.status} ${a.daten?.fehler || ""})`); + idHeute = a.daten?.id || 0; + + const l = await liste(); + const e = l.find((x) => x.id === idHeute); + ok(e?.uhrzeit === "23:30", `sie kommt zurueck (${e?.uhrzeit})`); + + /* WIEDER ENTFERNEN. Ein Feld, das `""` als "unveraendert" liest, + macht aus dem Loeschen ein Nichtstun -- ohne Fehlermeldung. */ + const w = await fetch(`${BASIS}/workspace/api/bereich/ansteht/${idHeute}`, { + method: "PATCH", headers: jsonKopf, body: JSON.stringify({ uhrzeit: "" }), + }); + const l2 = await liste(); + ok(w.ok && !l2.find((x) => x.id === idHeute)?.uhrzeit, + `und laesst sich wieder entfernen (${w.status})`); + + const z = await fetch(`${BASIS}/workspace/api/bereich/ansteht/${idHeute}`, { + method: "PATCH", headers: jsonKopf, body: JSON.stringify({ uhrzeit: "23:30" }), + }); + const l3 = await liste(); + ok(z.ok && l3.find((x) => x.id === idHeute)?.uhrzeit === "23:30", + "und wieder setzen auch"); +} + +/* ======================================================================= + 2. WAS NICHT DURCHGEHT + ======================================================================= */ +melde(""); +melde("=== 2. Unmoegliche Zeiten ==="); +{ + /* DER TITEL MUSS GUELTIG SEIN -- sonst prueft man etwas anderes. + Beim ersten Lauf stand hier "X": ein Zeichen, und der Server + verlangt zwei. Die Absage kam dann fuer den Titel UND die Uhrzeit, + und die Pruefung war aus dem halb falschen Grund gruen. Genau + dafuer gibt es die Gegenprobe drei Zeilen tiefer -- sie ist + durchgefallen und hat es verraten. */ + for (const falsch of ["25:00", "20:60", "7:00", "abends"]) { + const a = await anlegen({ art: "stream", titel: "Probestream", + datum: heuteLokal(), uhrzeit: falsch }); + ok(a.status === 400 && /Uhrzeit/i.test(a.daten?.fehler || ""), + `"${falsch}" wird abgelehnt (${a.status} ${a.daten?.fehler || ""})`); + } + /* GEGENPROBE: Sonst hiesse 400 nur, dass Anlegen ueberhaupt scheitert. */ + const gut = await anlegen({ art: "stream", titel: "Frühstream", + datum: heuteLokal(), uhrzeit: "07:00" }); + ok(gut.status === 201, `"07:00" dagegen geht (${gut.status} ${gut.daten?.fehler || ""})`); +} + +/* ======================================================================= + 3. DER ZEITSTRAHL BRAUCHT MEHR ALS EINEN TERMIN + ======================================================================= */ +melde(""); +melde("=== 3. Termine in allen Abschnitten ==="); +{ + await anlegen({ art: "event", titel: "Turnierabend", datum: tagLokal(3) }); + await anlegen({ art: "stream", titel: "Im naechsten Monat", datum: tagLokal(30) }); + await anlegen({ art: "pause", titel: "War letzte Woche", datum: tagLokal(-6) }); + const l = await liste(); + ok(l.length >= 5, `${l.length} Termine angelegt`); +} + +/* ======================================================================= + 4. IM BROWSER: DIE ANTWORT STEHT IM ERSTEN BILDSCHIRM + ======================================================================= */ +melde(""); +melde("=== 4. Der erste Bildschirm ==="); +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); +try { + const HOEHE = 844; + const kontext = await browser.newContext({ viewport: { width: 390, height: HOEHE } }); + await kontext.addCookies(keks.split("; ").map((k) => { + const [name, ...rest] = k.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); }); + await seite.goto(`${BASIS}/workspace/bereich.html?b=ansteht`, { waitUntil: "networkidle" }); + await seite.waitForSelector("#countdown", { timeout: 15_000 }); + await seite.waitForTimeout(600); + + const m = await seite.evaluate(() => { + const k = document.getElementById("countdown"); + const wann = k?.querySelector(".countdown__wann"); + const schilder = [...document.querySelectorAll(".zeitstrahl__schild")] + .map((z) => z.firstChild?.textContent?.trim() || z.textContent.trim()); + return { + sichtbar: !!k && !k.hidden, + wann: wann?.textContent?.trim() || "", + /* DAS ABNAHMEKRITERIUM aus dem Plan, in Pixeln. */ + y: wann ? Math.round(wann.getBoundingClientRect().top + window.scrollY) : -1, + groesse: wann ? Math.round(parseFloat(getComputedStyle(wann).fontSize)) : 0, + titel: k?.querySelector(".countdown__titel")?.textContent?.trim() || "", + stufe: k?.dataset.stufe || "", + schilder, + }; + }); + + ok(m.sichtbar, "der Countdown steht da"); + ok(m.y >= 0 && m.y < HOEHE, + `die Antwort steht im ersten Bildschirm (y = ${m.y} px von ${HOEHE})`); + /* GROSS heisst messbar gross: groesser als jede Fliesstextzeile. */ + ok(m.groesse >= 24, `und sie ist gross genug (${m.groesse} px)`); + /* MIT UHRZEIT UND HEUTE -> STUNDEN. Genau das war der Grund fuer die + neue Spalte; ohne sie stuende hier "heute". */ + ok(/^in \d+ (Std|Min)/.test(m.wann) || m.wann === "läuft", + `sie zaehlt in Stunden, nicht in Tagen ("${m.wann}")`); + ok(m.titel.length > 0, `und sagt, WAS ansteht ("${m.titel}")`); + + /* DER ZEITSTRAHL. Kommendes zuerst, Vergangenes zuletzt -- ein + abgelaufener Termin zwischen kommenden liest sich wie ein Fehler. */ + ok(m.schilder.length >= 3, `der Zeitstrahl hat Abschnitte (${m.schilder.join(" · ")})`); + ok(m.schilder[m.schilder.length - 1] === "Vorbei", + `und "Vorbei" steht ganz unten (${m.schilder[m.schilder.length - 1]})`); + ok(m.schilder[0] === "Heute und morgen", + `das Naechste ganz oben (${m.schilder[0]})`); + + if (process.env.SCHIRM) { + const pfad = fileURLToPath(new URL("pruef-countdown.png", import.meta.url)); + await seite.screenshot({ path: pfad, fullPage: false }); + melde(" -- Bildschirmfoto: server/pruef-countdown.png"); + } + + /* ===================================================================== + 5. UND WENN NICHTS ANSTEHT? + ===================================================================== */ + melde(""); + melde("=== 5. Der leere Fall ==="); + { + /* Alle Termine weg -- dann MUSS ein Satz dastehen. Eine leere + Flaeche sieht kaputt aus, nicht leer. */ + const alle = await liste(); + for (const e of alle) { + await fetch(`${BASIS}/workspace/api/bereich/ansteht/${e.id}`, + { method: "DELETE", headers: { cookie: keks } }); + } + await seite.reload({ waitUntil: "networkidle" }); + await seite.waitForTimeout(600); + const leer = await seite.evaluate(() => { + const k = document.getElementById("countdown"); + return { + sichtbar: !!k && !k.hidden, + satz: k?.querySelector(".countdown__leer")?.textContent?.trim() || "", + }; + }); + ok(leer.sichtbar, "der Kasten bleibt stehen"); + ok(leer.satz.length > 30, `und sagt in Worten, dass nichts ansteht ("${leer.satz.slice(0, 60)}…")`); + } + + ok(konsole.length === 0, + konsole.length ? `Konsole: ${konsole[0]}` : "keine Fehler in der Konsole"); + await kontext.close(); +} finally { + await browser.close(); +} + +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 2fce6c3a..6bfecde2 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -981,7 +981,7 @@ function sichtbarRoh2(person, praefix = "e") { } const SPALTEN = ` - e.id, e.bereich, e.art, e.titel, e.text, e.datum, e.bewertung, + e.id, e.bereich, e.art, e.titel, e.text, e.datum, e.uhrzeit, e.bewertung, e.dringlichkeit, e.status, e.creator_id, e.erstellt, e.erstellt_von, e.geaendert, e.hook, e.format, e.saeule_id, e.geplant, e.event_ende, e.event_aufgaben, e.event_regeln, @@ -1233,6 +1233,16 @@ function pruefe(bereich, körper, { neu }) { fehler.push("Datum ist ungültig."); } else aus.datum = d; } + /* DIE UHRZEIT (17.09.2026) -- optional, und leer heisst ausdruecklich + "ohne Uhrzeit" und nicht "unveraendert". Sonst liesse sich eine + einmal gesetzte Zeit nie wieder entfernen, und das faellt erst auf, + wenn ein Termin verschoben wird. */ + if (körper.uhrzeit !== undefined) { + const u = String(körper.uhrzeit ?? "").trim(); + if (!u) aus.uhrzeit = null; + else if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(u)) fehler.push("Uhrzeit ist ungültig."); + else aus.uhrzeit = u; + } if (körper.status !== undefined) { if (!STATUS.includes(körper.status)) fehler.push("Unbekannter Status."); else aus.status = körper.status; @@ -1455,11 +1465,11 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re const { lastInsertRowid } = db().prepare(` INSERT INTO eintraege - (bereich, art, titel, text, datum, bewertung, dringlichkeit, status, + (bereich, art, titel, text, datum, uhrzeit, bewertung, dringlichkeit, status, creator_id, creator_extern, erstellt, erstellt_von, hook, format, saeule_id, geplant, event_ende, event_aufgaben, event_regeln, einsatz, nur_leitung) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( - bereich, aus.art, aus.titel, aus.text ?? null, aus.datum, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( + bereich, aus.art, aus.titel, aus.text ?? null, aus.datum, aus.uhrzeit ?? null, aus.bewertung ?? null, aus.dringlichkeit ?? "mittel", aus.status ?? "offen", aus.creator_id ?? null, aus.creator_extern ?? null, jetzt(), req.person.id, aus.hook ?? null, aus.format ?? null, aus.saeule_id ?? null, aus.geplant ?? null, diff --git a/server/workspace.js b/server/workspace.js index ea7f443f..a599f8aa 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1766,6 +1766,30 @@ function umstellungen(d) { falschen Moment. */ const jetztStempel = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, ""); + /* ---- Die Uhrzeit an einem Termin (17.09.2026) ---- + + "Was ansteht" bekommt einen Countdown: "Nächster Stream in 3 Std + 12 Min". Beim Bauen stellte sich heraus, dass die Daten das gar + nicht hergeben -- `eintraege.datum` ist ein TAG + (`^\d{4}-\d{2}-\d{2}$`, die Pruefung lehnt eine Uhrzeit ab). + + Das ist genau die Sorte Luecke, die man beim Planen uebersieht und + beim Bauen findet: Der Plan versprach eine Stundenangabe, und die + Datenbank kannte nur Tage. + + Optional, nicht Pflicht. Viele Termine haben keine Uhrzeit ("diese + Woche", "im Oktober"), und ein Pflichtfeld haette dafuer eine + erfundene erzwungen. Ohne Uhrzeit zaehlt der Countdown in Tagen. */ + try { + const spalten = d.prepare("PRAGMA table_info(eintraege)").all().map((s) => s.name); + if (spalten.length && !spalten.includes("uhrzeit")) { + d.exec("ALTER TABLE eintraege ADD COLUMN uhrzeit TEXT"); + console.log("[workspace] Spalte 'uhrzeit' an den Eintraegen ergaenzt."); + } + } catch (fehler) { + console.error("[workspace] Spalte 'uhrzeit':", fehler?.message); + } + /* ---- Der Kanal an der Aufgabe (17.09.2026) ---- Drei Accounts, eine Aufgabenliste: Ohne diese Spalte ist "schneide drei Ausschnitte" bei HasiDog und Clips mehrdeutig. diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 71b98e42..7e63d168 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609171020", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609171113", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609171020", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609171113", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171020", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171113", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171020", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171113", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index 43f2821c..9c89d7bb 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -669,3 +669,97 @@ border: 1px solid color-mix(in srgb, #668e6e 50%, transparent); } .eintrag__aufgabe:hover { color: var(--text); border-color: #668e6e; } + +/* ===================================================================== + DER COUNTDOWN BEI „WAS ANSTEHT" (17.09.2026) + + Filipe wollte für den Community-Bereich „eine richtige perfektion". + Hier ist das keine Frage der Optik: Es gibt genau EINE Frage, die ein + Zuschauer auf dieser Seite hat — *wann* — und sie stand bisher in + Zeile vier einer Liste. + + GROSS, ABER NICHT LAUT. Die Zeitangabe ist das Größte auf der Seite, + weil sie die Antwort ist. Sie leuchtet aber nicht: kein Neon, kein + Blinken, keine Animation. Auf dieser Seite steht niemand unter + Zeitdruck — er will es nur wissen. + + DER TON FOLGT DER NÄHE, nicht der Wichtigkeit: Was gleich anfängt, + ist wärmer; was in vier Wochen kommt, ist ruhig. Das ist die einzige + Information, die eine Farbe hier tragen kann, ohne zu behaupten, ein + Termin sei „besser" als ein anderer. + ------------------------------------------------------------------- */ +.countdown { + --c-ton: #6f89a8; + margin: 0 0 22px; + padding: 20px 22px; + border: 1px solid color-mix(in srgb, var(--c-ton) 30%, var(--rand)); + border-radius: var(--radius); + background: + linear-gradient(180deg, + color-mix(in srgb, var(--c-ton) 13%, transparent), + transparent 64%), + var(--flaeche); +} +.countdown[data-stufe="jetzt"] { --c-ton: #d97a45; } +.countdown[data-stufe="bald"] { --c-ton: #e0a33c; } +.countdown[data-stufe="woche"] { --c-ton: #5f93c4; } +.countdown[data-stufe="spaeter"] { --c-ton: #6f89a8; } + +.countdown__schild { + margin: 0 0 4px; + font-size: .72rem; font-weight: 600; + letter-spacing: .1em; text-transform: uppercase; + color: color-mix(in srgb, var(--c-ton) 62%, #ffffff); +} +/* DIE ANTWORT. clamp statt fester Größe: Auf 390 px soll sie die Zeile + nicht sprengen, auf dem Rechner soll sie wirken. Eine feste Zahl wäre + an einem der beiden Enden falsch. */ +.countdown__wann { + margin: 0; + font-size: clamp(1.7rem, 6vw, 2.5rem); + font-weight: 700; line-height: 1.05; + letter-spacing: -.01em; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.countdown__titel { + margin: 8px 0 0; + font-size: 1.02rem; font-weight: 600; color: var(--text); +} +.countdown__datum { + margin: 3px 0 0; + font-size: .86rem; color: var(--text-leise); +} +.countdown__weitere { + margin: 12px 0 0; padding-top: 10px; + border-top: 1px solid var(--rand); + font-size: .8rem; color: var(--text-still); +} +/* Wenn nichts ansteht: ein Satz, keine leere Fläche. Ein Bereich, der + schweigt, wenn nichts da ist, sieht kaputt aus statt leer. */ +.countdown[data-leer] { --c-ton: #6f89a8; } +.countdown__leer { margin: 0; font-size: .95rem; color: var(--text-leise); } + +/* --------------------------------------------------------------------- + DER ZEITSTRAHL — die Abschnitte in der Liste darunter + ------------------------------------------------------------------- */ +.zeitstrahl__schild { + display: flex; align-items: center; gap: 9px; + margin: 26px 0 12px; + font-size: .76rem; font-weight: 650; + letter-spacing: .09em; text-transform: uppercase; + color: var(--text-still); +} +.zeitstrahl__schild:first-child { margin-top: 4px; } +/* Eine Linie, die nach rechts ausläuft — sie trennt, ohne eine Wand zu + bauen. Dieselbe Überlegung wie bei den Spalten des Aufgabenbretts. */ +.zeitstrahl__schild::after { + content: ""; flex: 1; height: 1px; + background: linear-gradient(90deg, var(--rand), transparent); +} +.zeitstrahl__zahl { + padding: 1px 8px; border-radius: 999px; + background: rgba(255, 255, 255, .06); + font-size: .74rem; letter-spacing: normal; color: var(--text-still); + font-variant-numeric: tabular-nums; +} diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 9cd3eae8..d4b57003 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -97,6 +97,114 @@ const EVENT_ART = 'kampagne'; const istEventArt = (art) => bereich === EVENT_BEREICH && art === EVENT_ART; + /* ===================================================================== + "WANN IST DER NAECHSTE STREAM?" (17.09.2026) + + Filipe wollte fuer den Community-Bereich "eine richtige + perfektion". Bei "Was ansteht" ist das keine Frage der Optik: Es + gibt genau EINE Frage, die ein Zuschauer hier hat, und sie stand + bisher in Zeile vier einer Liste. + + BEIM BAUEN KAM EINE LUECKE HERAUS, die im Plan nicht stand: Ein + Eintrag hatte nur einen TAG, keine Uhrzeit -- die Pruefung im + Server lehnte eine Zeitangabe ausdruecklich ab. Ein Countdown "in + 3 Std 12 Min" war damit gar nicht moeglich. Die Spalte `uhrzeit` + ist deshalb heute dazugekommen; sie ist OPTIONAL, denn viele + Termine haben keine Uhrzeit, und ein Pflichtfeld haette dafuer + eine erfundene erzwungen. + + OHNE UHRZEIT ZAEHLT ER IN TAGEN. "Morgen" ist eine ehrliche + Antwort; "morgen um 00:00" waere eine erfundene. */ + + /* Der Zeitpunkt eines Eintrags als Millisekunden -- Ortszeit. + `new Date("2026-09-17T20:00:00")` OHNE Z ist Ortszeit, und genau + das ist gemeint: Der Stream faengt um 20 Uhr HIER an. */ + function zeitpunktVon(e) { + const tag = String(e?.datum || '').slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(tag)) return null; + const zeit = /^\d{2}:\d{2}$/.test(String(e.uhrzeit || '')) ? e.uhrzeit : null; + const d = new Date(`${tag}T${zeit || '00:00'}:00`); + return Number.isNaN(d.getTime()) ? null : { ms: d.getTime(), tag, zeit }; + } + + /* Wie lange noch? Gibt Worte zurueck, keine Zahl -- "in 3 Std 12 Min" + liest sich in einem Zug, "0.134 Tage" nicht. */ + function nochText(e, jetztMs = Date.now()) { + const z = zeitpunktVon(e); + if (!z) return null; + const heute = window.heuteLokal ? window.heuteLokal() : new Date().toISOString().slice(0, 10); + const tageHin = Math.round( + (new Date(`${z.tag}T00:00:00`).getTime() - new Date(`${heute}T00:00:00`).getTime()) / 86400000); + + if (tageHin < 0) return { wann: 'vorbei', stufe: 'vorbei' }; + /* MIT UHRZEIT UND HEUTE ODER MORGEN: in Stunden. Weiter entfernt + sagt eine Stundenzahl nichts mehr -- "in 73 Std" muss man erst + umrechnen, "in 3 Tagen" nicht. */ + if (z.zeit && tageHin <= 1) { + const min = Math.round((z.ms - jetztMs) / 60000); + if (min < 0) return { wann: tageHin === 0 ? 'läuft' : 'vorbei', stufe: tageHin === 0 ? 'jetzt' : 'vorbei' }; + if (min < 60) return { wann: `in ${min} Min`, stufe: 'jetzt' }; + const std = Math.floor(min / 60); + const rest = min % 60; + return { wann: `in ${std} Std${rest ? ' ' + rest + ' Min' : ''}`, stufe: 'bald' }; + } + if (tageHin === 0) return { wann: 'heute', stufe: 'jetzt' }; + if (tageHin === 1) return { wann: 'morgen', stufe: 'bald' }; + if (tageHin <= 7) return { wann: `in ${tageHin} Tagen`, stufe: 'woche' }; + return { wann: datum(z.tag), stufe: 'spaeter' }; + } + + /* In welchen Abschnitt des Zeitstrahls gehoert der Eintrag? */ + function abschnittVon(e) { + const n = nochText(e); + if (!n) return 'ohne'; + if (n.stufe === 'vorbei') return 'vorbei'; + if (n.stufe === 'jetzt' || n.stufe === 'bald') return 'heute'; + if (n.stufe === 'woche') return 'woche'; + return 'spaeter'; + } + const ABSCHNITT_NAME = { + heute: 'Heute und morgen', woche: 'Diese Woche', + spaeter: 'Später', ohne: 'Ohne Datum', vorbei: 'Vorbei', + }; + + /* Der Kasten ganz oben. */ + function countdownZeigen(eintraege) { + const ziel = $('countdown'); + if (!ziel) return; + if (bereich !== 'ansteht') { ziel.hidden = true; return; } + ziel.hidden = false; + ziel.textContent = ''; + + const kommend = (eintraege || []) + .map((e) => ({ e, n: nochText(e), z: zeitpunktVon(e) })) + .filter((x) => x.n && x.n.stufe !== 'vorbei') + .sort((a, b) => a.z.ms - b.z.ms); + + if (!kommend.length) { + /* EIN SATZ STATT EINER LEEREN FLAECHE. Ein Bereich, der nichts + sagt, wenn nichts da ist, sieht kaputt aus statt leer. */ + ziel.dataset.leer = 'ja'; + ziel.append(el('p', 'countdown__leer', + 'Noch kein Termin eingetragen. Der nächste Stream steht hier, sobald er feststeht.')); + return; + } + delete ziel.dataset.leer; + const { e, n } = kommend[0]; + ziel.dataset.stufe = n.stufe; + ziel.append(el('p', 'countdown__schild', 'Als Nächstes')); + ziel.append(el('p', 'countdown__wann', n.wann)); + ziel.append(el('p', 'countdown__titel', e.titel || 'Termin')); + const wann = e.uhrzeit + ? `${datum(String(e.datum).slice(0, 10))} um ${e.uhrzeit} Uhr` + : datum(String(e.datum).slice(0, 10)); + ziel.append(el('p', 'countdown__datum', wann)); + if (kommend.length > 1) { + ziel.append(el('p', 'countdown__weitere', + `${kommend.length - 1} weitere${kommend.length === 2 ? 'r Termin' : ' Termine'} danach`)); + } + } + /* Laeuft das Event noch, kommt es erst, ist es vorbei? Der Vergleich laeuft auf den ISO-Zeichenketten, nicht auf Date -- "2026-09-12" < "2026-09-30" ist als Text schon richtig, und ein @@ -568,6 +676,12 @@ if (filter === 'offen') return e.status === 'offen'; return e.art === filter; }); + /* DER COUNTDOWN steht ueber der Liste und rechnet mit ALLEN + Eintraegen, nicht mit der gefilterten Auswahl: Wer auf "nur + offene" stellt, will trotzdem wissen, wann der naechste Stream + ist. */ + countdownZeigen(daten.eintraege); + if (!sichtbar.length) { ziel.append(el('p', 'leer-hinweis', daten.eintraege.length ? 'Zu dieser Auswahl gibt es nichts.' @@ -581,10 +695,42 @@ return; } const karten = new Map(); - for (const e of sichtbar) { - const k = karte(e); - karten.set(e.id, k); - ziel.append(k); + if (bereich === 'ansteht') { + /* ALS ZEITSTRAHL, NICHT ALS LISTE (17.09.2026). + + Eine Liste beantwortet "was gibt es". Hier lautet die Frage + aber "was kommt WANN" -- und die beantwortet erst eine + Reihenfolge mit Abschnitten. Vorbei Gewesenes steht unten, + nicht dazwischen: Ein abgelaufener Termin mitten zwischen + kommenden liest sich wie ein Fehler. */ + const REIHE = ['heute', 'woche', 'spaeter', 'ohne', 'vorbei']; + const faecher = new Map(REIHE.map((r) => [r, []])); + for (const e of sichtbar) faecher.get(abschnittVon(e)).push(e); + for (const r of REIHE) { + const fach = faecher.get(r); + if (!fach.length) continue; + /* Innerhalb eines Abschnitts nach Zeitpunkt, das Vergangene + umgekehrt -- das zuletzt Gewesene zuerst. */ + fach.sort((a, b) => { + const za = zeitpunktVon(a)?.ms ?? 0; + const zb = zeitpunktVon(b)?.ms ?? 0; + return r === 'vorbei' ? zb - za : za - zb; + }); + const kopf = el('h2', 'zeitstrahl__schild', ABSCHNITT_NAME[r]); + kopf.append(el('span', 'zeitstrahl__zahl', String(fach.length))); + ziel.append(kopf); + for (const e of fach) { + const k = karte(e); + karten.set(e.id, k); + ziel.append(k); + } + } + } else { + for (const e of sichtbar) { + const k = karte(e); + karten.set(e.id, k); + ziel.append(k); + } } sprungZeigen(ziel, karten); @@ -677,6 +823,16 @@ function formularAnpassen() { const event = istEventArt($('f-art').value); + /* DIE UHRZEIT NUR DORT, WO TERMINE STEHEN (17.09.2026). Ein + Anschlag oder ein Wunsch hat keine Uhrzeit -- ein Feld, das da + ist und nie gebraucht wird, macht das Formular laenger und die + Entscheidung schwerer. */ + const uhrFeld = $('feld-uhrzeit'); + if (uhrFeld) { + uhrFeld.hidden = bereich !== 'ansteht'; + if (uhrFeld.hidden && $('f-uhrzeit')) $('f-uhrzeit').value = ''; + } + $('feld-ende').hidden = !event; $('feld-aufgaben').hidden = !event; $('feld-regeln').hidden = !event; @@ -737,6 +893,9 @@ text: $('f-text').value, datum: $('f-datum').value, }; + if ($('feld-uhrzeit') && !$('feld-uhrzeit').hidden) { + daten.uhrzeit = $('f-uhrzeit').value || ''; + } const event = istEventArt(daten.art); if (einstellung.dringlichkeit && !event) daten.dringlichkeit = $('f-dringlich').value; if (einstellung.vertraulich) daten.nur_leitung = $('f-vertraulich').checked ? 1 : 0; diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 188904e6..a078bbd7 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -291,10 +291,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 610b8677..3b382077 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -168,10 +168,10 @@ - - - - - + + + + + diff --git a/workspace/befinden.html b/workspace/befinden.html index e1d578eb..0956f5d7 100644 --- a/workspace/befinden.html +++ b/workspace/befinden.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -105,9 +105,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index f4081099..b36866fb 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -75,6 +75,15 @@ + + @@ -177,18 +186,22 @@ + +

Einträge werden geladen …

- - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 4adb7d48..58d16c41 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 8133b5af..6febeaea 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index ec7a12b4..58c678e3 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 91a41bcb..8af358d6 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -339,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index b5a4d385..dd638b15 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609171020", + "src": "/assets/img/app-symbole/crew-192.png?v=202609171113", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609171020", + "src": "/assets/img/app-symbole/crew-512.png?v=202609171113", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609171020", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609171113", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609171020", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609171113", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index d631a9e3..547e0234 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 4b11a8ca..2dbb0c6b 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -102,9 +102,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index e0e00838..5d24ad61 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 8b7e6d57..0df92ff8 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 032105d4..25fdd561 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -444,10 +444,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 82a40f49..1fc1f2ac 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 6a959f04..f6274ef8 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 6ebd6e65..dd1c38d5 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index 037bdc22..918c07fc 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index ab61710c..81354fa3 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index d687098a..bc6b59db 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 758381fb..7b23d233 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index cba4b9f4..5c37dd28 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -146,11 +146,11 @@ - - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index ec9e164d..ce651fe4 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index 4c678d87..4ae1837f 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index de50196c..565a8613 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index 09dd48c8..ae5b7678 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,9 +110,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index a130d5bf..a01838f3 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -211,10 +211,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 72b0b2f3..7806a1c7 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index e63f0f9a..05ae7d11 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +