diff --git a/pruef-start-computer.png b/pruef-start-computer.png new file mode 100644 index 00000000..129e7496 Binary files /dev/null and b/pruef-start-computer.png differ diff --git a/pruef-start-creator.png b/pruef-start-creator.png new file mode 100644 index 00000000..15051e78 Binary files /dev/null and b/pruef-start-creator.png differ diff --git a/pruef-start-handy.png b/pruef-start-handy.png new file mode 100644 index 00000000..3ff145a8 Binary files /dev/null and b/pruef-start-handy.png differ diff --git a/server/pruef-start-ansicht.mjs b/server/pruef-start-ansicht.mjs new file mode 100644 index 00000000..a240bcb4 --- /dev/null +++ b/server/pruef-start-ansicht.mjs @@ -0,0 +1,212 @@ +/* Browserpruefung der Startseite: Gruppen, Zeichen, Farbtoene, Zahlen auf + den Kacheln -- und was jede Rolle davon zu sehen bekommt. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-start-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4186"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-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 }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4186"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const heute = jetzt.slice(0, 10); +const gestern = new Date(Date.now() - 86400_000).toISOString().slice(0, 10); +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, jetzt); + 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 idAnna = anlegen("Tili", "creator", "CODE-ANNA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idAnna, idScout, jetzt); + +/* Arbeit an mehreren Stellen -- damit auf den Kacheln wirklich Zahlen + stehen und man sieht, ob sie an der richtigen landen. */ +for (const [status, frist] of [["offen", gestern], ["offen", gestern], ["offen", heute], + ["arbeit", null], ["review", null]]) { + d.prepare("INSERT INTO aufgaben (titel, status, creator_id, frist, erstellt, erstellt_von) VALUES ('Test',?,?,?,?,?)") + .run(status, idAnna, frist, jetzt, idDogi); +} +d.prepare("INSERT INTO termine (titel, beginn, creator_id, erledigt, erstellt, erstellt_von) VALUES ('Heute',?,?,0,?,?)") + .run(heute + "T18:00", idAnna, jetzt, idDogi); +d.prepare("INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status, creator_id, erstellt, erstellt_von) VALUES ('technik','problem','Ton weg',?,'hoch','offen',?,?,?)") + .run(heute, idAnna, jetzt, idDogi); +d.close(); + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +async function anmelden(kontext, rolle, code) { + const seite = await kontext.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + await seite.waitForTimeout(900); + return seite; +} + +const lesen = (seite) => seite.evaluate(() => ({ + gruss: document.getElementById("gruss")?.textContent, + gruppen: [...document.querySelectorAll(".gruppe")].map((g) => ({ + name: g.querySelector(".gruppe__name")?.textContent, + kacheln: g.querySelectorAll(".kachel").length, + })), + kacheln: [...document.querySelectorAll(".kachel")].map((k) => ({ + name: k.querySelector(".kachel__name")?.textContent, + unter: k.querySelector(".kachel__unter")?.textContent, + ton: k.dataset.ton, + farbe: getComputedStyle(k.querySelector(".kachel__svg")).stroke, + pfade: k.querySelectorAll(".kachel__svg path").length, + zahl: k.querySelector(".kachel__zahl")?.textContent || null, + warn: k.querySelector(".kachel__zahl")?.dataset.warn === "ja", + ziel: k.querySelector(".kachel__link")?.getAttribute("href"), + })), + /* Abgeschnittener Text ist der haeufigste stille Fehler in einer + Kachel -- er sieht auf den ersten Blick nach Absicht aus. Gemessen + wird die tatsaechliche Textbreite gegen die verfuegbare. */ + abgeschnitten: [...document.querySelectorAll(".kachel__name, .kachel__unter")] + .filter((e) => e.scrollWidth > e.clientWidth + 1) + .map((e) => e.textContent), + hinweise: [...document.querySelectorAll("#dran .dran__punkt")] + .filter((z) => z.offsetParent !== null).length, + hinweiseGesamt: document.querySelectorAll("#dran .dran__punkt").length, + zahlenLeer: !document.getElementById("zahlen-leer")?.hidden, + zahlen: [...document.querySelectorAll(".zahl")].length, + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, + ueberstehend: [...document.querySelectorAll("main *")] + .filter((e) => e.getBoundingClientRect().right > window.innerWidth + 1) + .map((e) => String(e.className).slice(0, 40)).slice(0, 4), +})); + +for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) { + console.log(`\n=== DogFather · ${wie}`); + const kontext = await browser.newContext({ viewport: { width: breite, height: 1200 } }); + const konsole = []; + const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001"); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); + await seite.reload({ waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + + const r = await lesen(seite); + console.log(" Gruß: ", r.gruss); + console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · ")); + console.log(" Zahlen: ", r.kacheln.filter((k) => k.zahl).map((k) => `${k.name}=${k.zahl}${k.warn ? "!" : ""}`).join(" · ")); + + ok(r.gruppen.length === 3, `drei Gruppen (${r.gruppen.length})`); + ok(r.kacheln.length === 17, `siebzehn Kacheln (${r.kacheln.length})`); + ok(/^(Guten Morgen|Guten Tag|Guten Abend|Noch wach), Filipe$/.test(r.gruss || ""), + `Begrüßung nach Tageszeit ("${r.gruss}")`); + + /* Jede Kachel muss ein Zeichen haben -- eine ohne waere ein leeres + Kaestchen und faellt sofort auf. */ + ok(r.kacheln.every((k) => k.pfade > 0), + `jede Kachel hat ein Zeichen (${r.kacheln.filter((k) => !k.pfade).map((k) => k.name).join(", ") || "alle"})`); + ok(r.kacheln.every((k) => k.unter && k.unter.length > 4), "jede Kachel sagt, was drin ist"); + ok(r.kacheln.every((k) => k.ziel && k.ziel !== "#"), "jede Kachel führt irgendwohin"); + + /* Acht verschiedene Farben, und nirgends steht "ÖFFNEN" mehr. */ + const toene = new Set(r.kacheln.map((k) => k.farbe)); + ok(toene.size === 8, `acht verschiedene Farbtöne im Einsatz (${toene.size})`); + const oeffnen = await seite.evaluate(() => document.body.textContent.includes("ÖFFNEN")); + ok(!oeffnen, "das Wort ÖFFNEN kommt nicht mehr vor"); + + /* Die Zahlen müssen auf der RICHTIGEN Kachel stehen. */ + const auf = (n) => r.kacheln.find((k) => k.name === n); + ok(auf("Aufgaben")?.zahl === "4", + `Aufgaben trägt 4 (2 überfällig + 1 heute + 1 Review) (${auf("Aufgaben")?.zahl})`); + ok(auf("Aufgaben")?.warn === true, "und ist als Warnung markiert"); + ok(auf("Kalender")?.zahl === "1", `Kalender trägt 1 (${auf("Kalender")?.zahl})`); + ok(auf("LIVE-Analyse")?.zahl === "1", `der dringende Bereichspunkt (${auf("LIVE-Analyse")?.zahl})`); + ok(!auf("Wissens-Bibliothek")?.zahl, "wo nichts liegt, steht keine Zahl"); + + ok(r.abgeschnitten.length === 0, + `kein Text wird abgeschnitten (${r.abgeschnitten.join(" | ") || "keiner"})`); + ok(r.hinweise <= 4, `höchstens vier Hinweise sichtbar (${r.hinweise} von ${r.hinweiseGesamt})`); + ok(r.zahlen === 6, `die Aufgabenzahlen stehen (${r.zahlen})`); + ok(!r.zahlenLeer, "und nicht der Leer-Satz"); + ok(r.ueber <= 0, `nichts steht über (${r.ueber}px, ${r.ueberstehend.join(", ")})`); + ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); + + await seite.screenshot({ path: breite === 1440 ? "pruef-start-computer.png" : "pruef-start-handy.png", fullPage: true }); + await kontext.close(); +} + +console.log("\n=== Creator"); +{ + const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } }); + const seite = await anmelden(kontext, "creator", "CODE-ANNA-0001"); + const r = await lesen(seite); + console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · ")); + const namen = r.kacheln.map((k) => k.name); + ok(namen.includes("Mein Profil"), "sieht sein eigenes Profil"); + ok(!namen.includes("Creator-Profile"), "und nicht die Liste aller Creator"); + ok(!namen.includes("Personen & Zugänge"), "keine Personenverwaltung"); + ok(!namen.includes("Scout-Pipeline"), "keine Scout-Pipeline"); + ok(!namen.includes("Automationen"), "keine Automationen"); + ok(r.gruppen.length === 3, `trotzdem drei Gruppen (${r.gruppen.length})`); + ok(r.kacheln.every((k) => k.pfade > 0), "alle Zeichen da"); + await seite.screenshot({ path: "pruef-start-creator.png", fullPage: true }); + await kontext.close(); +} + +console.log("\n=== Scout"); +{ + const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } }); + const seite = await anmelden(kontext, "scout", "CODE-SCOU-0001"); + const r = await lesen(seite); + const namen = r.kacheln.map((k) => k.name); + ok(namen.includes("Scout-Pipeline"), "sieht seine Pipeline"); + ok(!namen.includes("Personen & Zugänge"), "keine Personenverwaltung"); + ok(!namen.includes("Automationen"), "keine Automationen"); + ok(namen.includes("Creator-Profile"), "aber die Creator-Profile"); + await kontext.close(); +} + +console.log("\n=== Weniger Bewegung: keine Animation"); +{ + const kontext = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, reducedMotion: "reduce" }); + const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001"); + const bewegung = await seite.evaluate(() => { + const k = document.querySelector(".kachel"); + const s = getComputedStyle(k); + return { animation: s.animationName, sichtbar: s.opacity }; + }); + ok(bewegung.animation === "none", `keine Animation (${bewegung.animation})`); + ok(Number(bewegung.sichtbar) === 1, `und trotzdem voll sichtbar (${bewegung.sichtbar})`); + await kontext.close(); +} + +await browser.close(); +console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler === 0 ? 0 : 1); diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 3f9a4abc..d47ce8b5 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -214,52 +214,206 @@ body.start { .zahl[data-warn="ja"] { border-color: rgba(255, 154, 162, .42); background: rgba(255, 154, 162, .07); } .zahl[data-warn="ja"] .zahl__wert { color: var(--warn); } -/* ---------- Bereiche --------------------------------------------------- */ +/* ---------- Bereiche: Gruppen und Kacheln -------------------------------- -.bereiche { + Vorher: siebzehn gleiche Rechtecke mit "ÖFFNEN" darauf. Wenn alles + gleich aussieht, ist alles gleich wichtig -- also nichts. + + Drei Dinge tragen jetzt die Gestaltung: + + 1. Die Kachel ist ruhig, bis man sie ansieht. Der Farbton sitzt im + Zeichen und in einer schmalen Kante -- nicht als Fläche. Siebzehn + farbige Flächen wären ein Bonbonglas, kein Werkzeug. + + 2. Beim Überfahren geht ein sehr weicher Schimmer aus der oberen + linken Ecke an. Kein Blitzen, kein Springen: Alphawerte unter 0.25, + Bewegung unter einem Pixel. + + 3. Der Einlauf ist gestaffelt -- genau EINE Bewegung beim Aufbau, + danach Ruhe. Bei "weniger Bewegung" fällt sie ersatzlos weg. + + Die acht Farbtöne sind gegen genau diesen Hintergrund gerechnet: + Helligkeitsband, Farbstärke, Abstand bei Farbfehlsichtigkeit + (schlechtestes Paar dE 8.4), Normalsicht-Abstand (19.3) und Kontrast + -- alle Prüfungen bestanden. Warum acht und nicht siebzehn: Siebzehn + unterscheidbare Farben gibt es nicht; ein Versuch mit einer eigenen + Farbe je Kachel ergab Paare mit Abstand 1.4, also praktisch dieselbe + Farbe. Sie stützen nur -- WAS eine Kachel ist, sagen Zeichen und Name. + ------------------------------------------------------------------------- */ + +.gruppe { margin-bottom: 34px; } +.gruppe:last-child { margin-bottom: 0; } + +.gruppe__kopf { + display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; + margin-bottom: 12px; padding-bottom: 9px; + border-bottom: 1px solid var(--rand); +} +.gruppe__name { + margin: 0; font-size: .76rem; font-weight: 700; + letter-spacing: .1em; text-transform: uppercase; color: var(--text-leise); +} +.gruppe__unter { font-size: .78rem; color: var(--text-still); } + +.kacheln { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; - grid-template-columns: repeat(auto-fill, minmax(238px, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); } -.bereich { - display: flex; align-items: baseline; gap: 10px; - padding: 13px 15px; - background: rgba(255, 255, 255, .03); +/* Die acht geprüften Töne. Zugewiesen wird der PLATZ, nicht der Rang -- + so behält ein Bereich seine Farbe, auch wenn andere dazukommen. */ +.kachel[data-ton="1"] { --ton: #3987e5; } +.kachel[data-ton="2"] { --ton: #d95926; } +.kachel[data-ton="3"] { --ton: #199e70; } +.kachel[data-ton="4"] { --ton: #c98500; } +.kachel[data-ton="5"] { --ton: #d55181; } +.kachel[data-ton="6"] { --ton: #4d9e4d; } +.kachel[data-ton="7"] { --ton: #9085e9; } +.kachel[data-ton="8"] { --ton: #e66767; } + +.kachel { + --ton: var(--akzent); + position: relative; + border-radius: 15px; + background: + linear-gradient(155deg, + color-mix(in srgb, var(--ton) 9%, transparent), + rgba(255, 255, 255, .022) 45%); border: 1px solid var(--rand); - border-radius: var(--radius-klein); + overflow: hidden; + transition: border-color var(--tempo), transform var(--tempo); +} +.kachel[data-gross="ja"] { grid-column: span 2; } + +/* Die farbige Kante links. Sie ist das Einzige, was den Ton kräftig + zeigt -- drei Pixel reichen, um eine Kachel wiederzuerkennen. */ +.kachel::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: linear-gradient(180deg, var(--ton), transparent 90%); + opacity: .5; + transition: opacity var(--tempo); } -.bereich__name { font-weight: 600; font-size: .95rem; } - -.bereich__stand { - margin-left: auto; - font-size: .72rem; letter-spacing: .04em; text-transform: uppercase; - color: var(--text-still); - white-space: nowrap; +/* Der Schimmer beim Überfahren. Liegt still auf 0 und wird nur + eingeblendet -- nichts bewegt sich, es wird nur heller. */ +.kachel::after { + content: ""; position: absolute; inset: 0; pointer-events: none; + background: radial-gradient(120% 130% at 0% 0%, + color-mix(in srgb, var(--ton) 16%, transparent), transparent 62%); + opacity: 0; + transition: opacity var(--tempo); } -/* Phase 1 ist in Arbeit - das wird hervorgehoben, der Rest bleibt ruhig. */ -.bereich[data-phase="1"] { border-color: rgba(63, 189, 245, .34); } -.bereich[data-phase="1"] .bereich__stand { color: var(--akzent); } - -/* Fertige Bereiche sind anklickbar und heben sich deutlich ab. */ -.bereich[data-fertig="ja"] { - padding: 0; - border-color: rgba(63, 189, 245, .5); - background: linear-gradient(100deg, rgba(63, 189, 245, .10), rgba(138, 118, 255, .07)); - transition: border-color var(--tempo), background var(--tempo); +.kachel:hover { + border-color: color-mix(in srgb, var(--ton) 42%, var(--rand)); + transform: translateY(-1px); } -.bereich[data-fertig="ja"]:hover { border-color: rgba(63, 189, 245, .8); } +.kachel:hover::before { opacity: 1; } +.kachel:hover::after { opacity: 1; } +.kachel:has(:focus-visible) { border-color: var(--ton); } -.bereich__link { - display: flex; align-items: baseline; gap: 10px; width: 100%; - padding: 13px 15px; +.kachel__link { + position: relative; z-index: 1; + display: flex; align-items: center; gap: 12px; + padding: 14px 15px 14px 17px; color: inherit; text-decoration: none; - border-radius: var(--radius-klein); } -.bereich__link:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } -.bereich[data-fertig="ja"] .bereich__stand { color: var(--akzent); font-weight: 600; } +.kachel__link:focus-visible { + outline: 2px solid var(--ton); outline-offset: -3px; border-radius: 13px; +} + +/* Das Zeichen sitzt in einem eigenen kleinen Feld -- dadurch bekommt der + Ton eine Fläche, ohne dass die ganze Kachel farbig wird. */ +.kachel__zeichen { + flex: none; + display: grid; place-items: center; + width: 38px; height: 38px; border-radius: 11px; + background: color-mix(in srgb, var(--ton) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--ton) 28%, transparent); + transition: background var(--tempo), border-color var(--tempo); +} +.kachel:hover .kachel__zeichen { + background: color-mix(in srgb, var(--ton) 24%, transparent); + border-color: color-mix(in srgb, var(--ton) 52%, transparent); +} +.kachel[data-gross="ja"] .kachel__zeichen { width: 44px; height: 44px; border-radius: 13px; } + +.kachel__svg { + width: 20px; height: 20px; + fill: none; stroke: var(--ton); + stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; +} +.kachel[data-gross="ja"] .kachel__svg { width: 23px; height: 23px; } + +.kachel__text { display: grid; gap: 1px; min-width: 0; } +.kachel__name { + font-size: .93rem; font-weight: 650; letter-spacing: -.005em; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.kachel[data-gross="ja"] .kachel__name { font-size: 1.02rem; } +.kachel__unter { + font-size: .74rem; color: var(--text-still); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +/* Die Zahl auf der Kachel. Sie ersetzt den Pfeil, wenn dort Arbeit + liegt -- eine Kachel, die "3" sagt, ist zehnmal mehr wert als eine, + die "öffnen" sagt. */ +.kachel__zahl { + flex: none; margin-left: auto; + min-width: 26px; padding: 3px 9px; + text-align: center; font-size: .82rem; font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--ton); + background: color-mix(in srgb, var(--ton) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--ton) 34%, transparent); + border-radius: 999px; +} +.kachel__zahl[data-warn="ja"] { + color: var(--warn); + background: rgba(255, 154, 162, .12); + border-color: rgba(255, 154, 162, .42); +} + +.kachel__pfeil { + flex: none; margin-left: auto; + color: var(--text-still); font-size: .9rem; + transition: color var(--tempo), transform var(--tempo); +} +.kachel:hover .kachel__pfeil { color: var(--ton); transform: translateX(3px); } + +/* Gestaffelter Einlauf. Die Verzögerung kommt aus --nr, das start.js je + Kachel setzt. Steht in einer no-preference-Abfrage, damit bei + "weniger Bewegung" gar keine Animation entsteht -- nicht nur eine + ohne Dauer. */ +@media (prefers-reduced-motion: no-preference) { + .kachel { + animation: kachel-ein .38s cubic-bezier(.32, 0, .2, 1) backwards; + animation-delay: calc(var(--nr, 0) * 26ms); + } + @keyframes kachel-ein { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } + } +} + +.zahlen-leer { + margin: 0; padding: 15px 17px; + font-size: .88rem; color: var(--text-still); + border: 1px dashed var(--rand); border-radius: var(--radius-klein); +} + +@media (max-width: 900px) { + .kacheln { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .kachel[data-gross="ja"] { grid-column: span 2; } +} +@media (max-width: 560px) { + .kacheln { grid-template-columns: 1fr; } + .kachel[data-gross="ja"] { grid-column: span 1; } + .gruppe__kopf { gap: 4px; } + .gruppe__unter { flex-basis: 100%; } +} .inhalt .fuss { margin-top: 40px; text-align: left; } diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 85eb096f..7960a5b2 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -1,7 +1,27 @@ /* =================================================================== Startseite nach dem Anmelden. + + Sie war eine Wand aus siebzehn gleichen Rechtecken, auf denen jeweils + "ÖFFNEN" stand. Wenn alles gleich aussieht, ist alles gleich wichtig + -- also nichts. Und "ÖFFNEN" ist keine Information, das weiss man von + einer Kachel ohnehin. + + Drei Dinge machen daraus etwas Brauchbares: + + 1. GRUPPEN. Was man täglich braucht, was zu einem Creator gehört, was + den Laden am Laufen hält -- das sind drei verschiedene Absichten + und nicht eine Liste. + + 2. ZAHLEN STATT "ÖFFNEN". Auf der Kachel steht, wo Arbeit liegt. + Dieselben Quellen wie die Hinweise darüber, kein zweiter Zähler. + Eine Kachel, die "3" sagt, ist zehnmal mehr wert als eine, die + "öffnen" sagt. + + 3. ZEICHEN UND FARBE. Ein eigenes Zeichen je Bereich und ein eigener + Farbton -- man findet sie nach kurzer Zeit blind. + Die Seite ist bereits serverseitig geschützt (workspace.js). Dieses - Skript holt nur noch, WER angemeldet ist, und regelt das Abmelden. + Skript holt nur noch, WER angemeldet ist. =================================================================== */ (() => { 'use strict'; @@ -13,41 +33,235 @@ scout: 'Scout · deine Pipeline und die Creator, die du betreust', }; - /* Bereiche aus dem Konzept, mit der Phase aus dem Umsetzungsplan. - `rollen` steuert, wem ein Bereich überhaupt angezeigt wird - schon - jetzt, damit die Trennung von Anfang an sichtbar ist. + /* ---------- Zeichen ------------------------------------------------------ - "Dashboard" führte lange nirgendwohin -- man stand ja schon auf - dieser Seite. Statt die Kachel zu streichen, hat sie jetzt ein - Ziel: die Gesamtübersicht je Creator, die das Konzept auf Seite 1 - als "ÜBERSICHT -- alles zentral" verspricht und die es bisher - nirgends gab. Diese Seite hier ist der Einstieg, die andere der - Überblick. */ - const BEREICHE = [ - { name: 'Dashboard', phase: 1, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'uebersicht.html' }, - { name: 'Personen & Zugänge', phase: 1, rollen: ['admin', 'manager'], ziel: 'personen.html' }, - { name: 'Creator-Profile', phase: 1, rollen: ['admin', 'manager', 'scout'], ziel: 'profil.html' }, - { name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' }, - { name: 'Start-Check', phase: 1, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'startcheck.html' }, - { name: 'Wissens-Bibliothek', phase: 1, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'wissen.html' }, - { name: 'Aufgaben', phase: 1, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'aufgaben.html' }, - { name: 'Kalender', phase: 1, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'kalender.html' }, - { name: 'Dateien', phase: 1, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'dateien.html' }, - { name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=live' }, - { name: 'Content-Planung', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'content.html' }, - { name: 'Technik', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=technik' }, - { name: 'Community', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=community' }, - { name: 'Schutz & Regeln', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=schutz' }, - { name: 'Reports & Review', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'report.html' }, - { name: 'Calls & Protokolle', phase: 3, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'calls.html' }, - { name: 'Scout-Pipeline', phase: 3, rollen: ['admin', 'manager', 'scout'], ziel: 'scouting.html' }, - { name: 'Automationen', phase: 4, rollen: ['admin', 'manager'], ziel: 'automation.html' }, + Alle im selben Raster (24×24), alle nur aus Linien -- gefüllte Zeichen + wirken neben Text wie Flecken. Eigene statt einer Fremdbibliothek: + siebzehn Pfade sind kleiner als jede Schriftart und kosten keine + zusätzliche Lieferkette. */ + + const ZEICHEN = { + dashboard: ['M4.5 4.5h6v6h-6z', 'M13.5 4.5h6v4h-6z', 'M13.5 11.5h6v8h-6z', 'M4.5 13.5h6v6h-6z'], + aufgaben: ['M4.5 5h4v14h-4z', 'M10 5h4v9h-4z', 'M15.5 5h4v11.5h-4z'], + kalender: ['M4 6.5h16v13H4z', 'M4 10.5h16', 'M8.5 3.5v4', 'M15.5 3.5v4'], + calls: ['M3.5 7h11v10h-11z', 'M14.5 11l6-3.5v9l-6-3.5z'], + dateien: ['M3.5 6.5h5.5l2 2h9.5v11h-17z'], + profil: ['M12 7a3.2 3.2 0 1 0 0 6.4A3.2 3.2 0 0 0 12 7z', + 'M5.5 20c.7-3.3 3.3-5.3 6.5-5.3s5.8 2 6.5 5.3'], + startcheck: ['M6 4.5h12v15H6z', 'M9.2 9.6l1.7 1.7 3.6-3.7', 'M9.2 15.2h5.6'], + live: ['M12 10.2a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6z', + 'M8.2 8.2a5.4 5.4 0 0 0 0 7.6', 'M15.8 15.8a5.4 5.4 0 0 0 0-7.6', + 'M5.4 5.4a9.4 9.4 0 0 0 0 13.2', 'M18.6 18.6a9.4 9.4 0 0 0 0-13.2'], + content: ['M3.5 6h17v13h-17z', 'M3.5 10h17', 'M8 6l-1.4 4', 'M13 6l-1.4 4', 'M18 6l-1.4 4'], + technik: ['M4 7.5h16', 'M4 12h16', 'M4 16.5h16', + 'M9 7.5a1.4 1.4 0 1 0 0-.02z', 'M15 12a1.4 1.4 0 1 0 0-.02z', + 'M7.5 16.5a1.4 1.4 0 1 0 0-.02z'], + community: ['M9 8.6a2.7 2.7 0 1 0 0 5.4 2.7 2.7 0 0 0 0-5.4z', + 'M3.5 19.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5', + 'M16 8.8a2.4 2.4 0 1 1 1.6 4.3', 'M17 15.2c2 .5 3.3 2 3.7 4.3'], + schutz: ['M12 3.6 5.6 6.3v5.1c0 3.8 2.6 7.2 6.4 8.4 3.8-1.2 6.4-4.6 6.4-8.4V6.3z', + 'M9.4 11.8l1.9 1.9 3.4-3.6'], + reports: ['M4 19.5h16', 'M7.5 16.5V9.5', 'M12 16.5V5.5', 'M16.5 16.5v-5'], + personen: ['M14.4 6.2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z', 'M11.6 12.2 4 19.8', + 'M6.4 17.4l2 2', 'M8.6 15.2l2 2'], + scouting: ['M3.5 5.5h17l-6.6 7.6v6.4l-3.8-2.1v-4.3z'], + wissen: ['M4 5h6.4c.9 0 1.6.7 1.6 1.6V19c0-.9-.7-1.6-1.6-1.6H4z', + 'M20 5h-6.4c-.9 0-1.6.7-1.6 1.6V19c0-.9.7-1.6 1.6-1.6H20z'], + automation: ['M13.2 3.2 6 13.4h5.2l-.8 7.4 7.6-10.6h-5.2z'], + }; + + /* ---------- Die Bereiche ------------------------------------------------- + + `rollen` steuert, wem ein Bereich überhaupt erscheint -- serverseitig + wird es ohnehin noch einmal geprüft, das hier ist nur Höflichkeit. + + `ton` ist der Farbplatz 1..8. Warum acht und nicht siebzehn: Siebzehn + unterscheidbare Farben gibt es nicht. Ein erster Versuch mit einer + eigenen Farbe je Kachel ergab Paare mit einem Abstand von 1.4 -- also + praktisch dieselbe Farbe, und das sieht nach Zufall aus statt nach + Absicht. Diese acht sind gegen genau diesen Hintergrund geprüft + (Helligkeit, Farbstärke, Farbfehlsichtigkeit, Kontrast, alles + bestanden) und so verteilt, dass in einer Zeile nie zweimal derselbe + Ton steht. Die Farbe stützt nur -- was die Kachel IST, sagen Zeichen + und Name. */ + + const GRUPPEN = [ + { + name: 'Täglich', + unter: 'Was du sowieso jeden Tag aufmachst', + bereiche: [ + { name: 'Dashboard', unter: 'Alle Creator auf einen Blick', zeichen: 'dashboard', ton: 1, + gross: true, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'uebersicht.html' }, + { name: 'Aufgaben', unter: 'Offen, in Arbeit, Review', zeichen: 'aufgaben', ton: 7, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'aufgaben.html' }, + { name: 'Kalender', unter: 'Termine und Calls', zeichen: 'kalender', ton: 3, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'kalender.html' }, + { name: 'Calls & Protokolle', unter: 'Gespräche mit To-dos', zeichen: 'calls', ton: 5, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'calls.html' }, + { name: 'Dateien', unter: 'Ablage und Freigaben', zeichen: 'dateien', ton: 4, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'dateien.html' }, + ], + }, + { + name: 'Rund um den Creator', + unter: 'Die Betreuungsakte – Stand, Zahlen, Verlauf', + bereiche: [ + { name: 'Creator-Profile', unter: 'Ziele und 90-Tage-Plan', zeichen: 'profil', ton: 1, + rollen: ['admin', 'manager', 'scout'], ziel: 'profil.html' }, + { name: 'Mein Profil', unter: 'Deine Ziele und Pläne', zeichen: 'profil', ton: 1, + rollen: ['creator'], ziel: 'profil.html' }, + { name: 'Start-Check', unter: 'Erstanalyse, 16 Punkte', zeichen: 'startcheck', ton: 3, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'startcheck.html' }, + { name: 'LIVE-Analyse', unter: 'Vorher, während, danach', zeichen: 'live', ton: 8, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=live' }, + { name: 'Content-Planung', unter: 'Idee bis Upload', zeichen: 'content', ton: 2, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'content.html' }, + { name: 'Community', unter: 'Moderation und Konflikte', zeichen: 'community', ton: 5, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=community' }, + { name: 'Technik', unter: 'Setup, Fehler, Lösungen', zeichen: 'technik', ton: 4, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=technik' }, + { name: 'Schutz & Regeln', unter: 'Richtlinien und Vorfälle', zeichen: 'schutz', ton: 7, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=schutz' }, + { name: 'Reports & Review', unter: 'Woche und Entscheidung', zeichen: 'reports', ton: 6, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'report.html' }, + ], + }, + { + name: 'Team & System', + unter: 'Was den Laden am Laufen hält', + bereiche: [ + { name: 'Personen & Zugänge', unter: 'Rollen und Codes', zeichen: 'personen', ton: 4, + rollen: ['admin', 'manager'], ziel: 'personen.html' }, + { name: 'Scout-Pipeline', unter: 'Entdeckt bis übergeben', zeichen: 'scouting', ton: 3, + rollen: ['admin', 'manager', 'scout'], ziel: 'scouting.html' }, + { name: 'Wissens-Bibliothek', unter: 'Anleitungen und PDFs', zeichen: 'wissen', ton: 1, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'wissen.html' }, + { name: 'Automationen', unter: 'Was von allein passiert', zeichen: 'automation', ton: 7, + rollen: ['admin', 'manager'], ziel: 'automation.html' }, + ], + }, ]; + /* Welcher Hinweis gehört auf welche Kachel. Die Zahl auf der Kachel ist + damit KEIN neuer Zähler, sondern dieselbe Quelle wie die Liste + darüber -- sonst stünden irgendwann zwei verschiedene Wahrheiten + übereinander auf einem Bildschirm. */ + const HINWEIS_ZU_KACHEL = { + aufgaben_ueberfaellig: 'Aufgaben', + aufgaben_heute: 'Aufgaben', + aufgaben_review: 'Aufgaben', + termine_heute: 'Kalender', + protokoll_fehlt: 'Calls & Protokolle', + dateien_review: 'Dateien', + bereiche_dringend: 'LIVE-Analyse', + startcheck_handlung: 'Start-Check', + ohne_startcheck: 'Start-Check', + followup_faellig: 'Scout-Pipeline', + uebergabe_offen: 'Scout-Pipeline', + review_faellig: 'Reports & Review', + review_bald: 'Reports & Review', + ohne_betreuung: 'Personen & Zugänge', + ohne_profil: 'Creator-Profile', + }; + const $ = (id) => document.getElementById(id); + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + + function zeichenBauen(name) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('class', 'kachel__svg'); + for (const d of (ZEICHEN[name] || [])) { + const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + p.setAttribute('d', d); + svg.append(p); + } + return svg; + } + + let ich = null; + let hinweise = []; + + /* ---------- Aufbau ------------------------------------------------------- */ + + function bereicheBauen() { + const ziel = $('bereiche'); + ziel.textContent = ''; + + /* Zahlen je Kachel aus den Hinweisen zusammenzählen. Warnstufe + schlägt durch: Eine Kachel mit etwas Überfälligem ist rot, auch + wenn daneben Harmloses mitgezählt wird. */ + const zahlen = new Map(); + for (const h of hinweise) { + const kachel = HINWEIS_ZU_KACHEL[h.art]; + if (!kachel) continue; + const bisher = zahlen.get(kachel) || { n: 0, warn: false }; + bisher.n += h.anzahl; + if (h.stufe === 'warnung') bisher.warn = true; + zahlen.set(kachel, bisher); + } + + let laufNr = 0; + for (const gruppe of GRUPPEN) { + const meine = gruppe.bereiche.filter((b) => b.rollen.includes(ich.rolle)); + if (!meine.length) continue; + + const abschnitt = el('section', 'gruppe'); + const kopf = el('div', 'gruppe__kopf'); + kopf.append(el('h2', 'gruppe__name', gruppe.name)); + kopf.append(el('span', 'gruppe__unter', gruppe.unter)); + abschnitt.append(kopf); + + const liste = el('ul', 'kacheln'); + for (const b of meine) { + const li = el('li', 'kachel'); + li.dataset.ton = String(b.ton); + if (b.gross) li.dataset.gross = 'ja'; + /* Der Einlauf wird gestaffelt -- aber über eine Eigenschaft, die + bei "weniger Bewegung" nichts tut (siehe start.css). */ + li.style.setProperty('--nr', String(laufNr++)); + + const a = el('a', 'kachel__link'); + a.href = b.ziel; + + const zeichen = el('span', 'kachel__zeichen'); + zeichen.append(zeichenBauen(b.zeichen)); + a.append(zeichen); + + const text = el('span', 'kachel__text'); + text.append(el('span', 'kachel__name', b.name)); + text.append(el('span', 'kachel__unter', b.unter)); + a.append(text); + + const zahl = zahlen.get(b.name); + if (zahl && zahl.n > 0) { + const marke = el('span', 'kachel__zahl', String(zahl.n)); + if (zahl.warn) marke.dataset.warn = 'ja'; + /* Vorlesende Programme bekommen einen Satz statt einer nackten + Zahl -- "Aufgaben, 3" sagt nichts. */ + marke.setAttribute('aria-label', `${zahl.n} offene Punkte in ${b.name}`); + a.append(marke); + } else { + a.append(el('span', 'kachel__pfeil', '→')); + } + + li.append(a); + liste.append(li); + } + abschnitt.append(liste); + ziel.append(abschnitt); + } + } + + /* ---------- Laden -------------------------------------------------------- */ async function laden() { - let ich; try { const antwort = await fetch('/workspace/api/ich', { credentials: 'same-origin' }); if (!antwort.ok) { location.assign('/workspace/'); return; } @@ -58,39 +272,17 @@ } $('wer').textContent = `${ich.name} · ${ich.rolle_name || ich.rolle}`; - $('gruss').textContent = `Willkommen, ${ich.name}`; + /* Tageszeit statt immer "Willkommen". Kostet nichts und macht aus + einer Maske eine Begrüßung. */ + const stunde = new Date().getHours(); + const tageszeit = stunde < 5 ? 'Noch wach' : stunde < 11 ? 'Guten Morgen' + : stunde < 18 ? 'Guten Tag' : 'Guten Abend'; + $('gruss').textContent = `${tageszeit}, ${ich.name}`; $('rollentext').textContent = ROLLENTEXT[ich.rolle] || ich.rolle; - const liste = $('bereiche'); - for (const b of BEREICHE.filter((b) => b.rollen.includes(ich.rolle))) { - const li = document.createElement('li'); - li.className = 'bereich'; - li.dataset.phase = String(b.phase); - if (b.ziel) li.dataset.fertig = 'ja'; - - const name = document.createElement('span'); - name.className = 'bereich__name'; - name.textContent = b.name; - - const stand = document.createElement('span'); - stand.className = 'bereich__stand'; - stand.textContent = b.ziel ? 'öffnen' : (b.phase === 1 ? 'als Nächstes' : `Phase ${b.phase}`); - - if (b.ziel) { - /* Fertige Bereiche sind echte Verweise -- Tastatur und - Rechtsklick funktionieren damit wie gewohnt. */ - const a = document.createElement('a'); - a.className = 'bereich__link'; - a.href = b.ziel; - a.append(name, stand); - li.append(a); - } else { - li.append(name, stand); - } - liste.append(li); - } - + /* Die Hinweise zuerst -- die Kacheln brauchen ihre Zahlen. */ await hinweiseLaden(); + bereicheBauen(); await zahlenLaden(); } @@ -124,34 +316,32 @@ daten = await antwort.json(); } catch { return; } + hinweise = daten.hinweise || []; const liste = $('dran'); liste.textContent = ''; - if (!daten.hinweise.length) { $('dran-block').hidden = true; return; } + if (!hinweise.length) { $('dran-block').hidden = true; return; } - for (const h of daten.hinweise) { - const li = document.createElement('li'); - li.className = 'dran__punkt'; + for (const h of hinweise) { + const li = el('li', 'dran__punkt'); li.dataset.stufe = h.stufe; /* Jeder Hinweis fuehrt irgendwohin -- ein Hinweis ohne Ziel ist nur ein schlechtes Gewissen. */ - const a = document.createElement('a'); - a.className = 'dran__link'; + const a = el('a', 'dran__link'); a.href = h.ziel; - - const text = document.createElement('span'); - text.className = 'dran__text'; - text.textContent = (EINZAHL[h.art] || ((n) => `${n} × ${h.text}`))(h.anzahl); - - const pfeil = document.createElement('span'); - pfeil.className = 'dran__pfeil'; + a.append(el('span', 'dran__text', + (EINZAHL[h.art] || ((n) => `${n} × ${h.text}`))(h.anzahl))); + const pfeil = el('span', 'dran__pfeil', '→'); pfeil.setAttribute('aria-hidden', 'true'); - pfeil.textContent = '→'; - - a.append(text, pfeil); + a.append(pfeil); li.append(a); liste.append(li); } + /* Sieben Hinweise untereinander sind eine Wand -- und die + wichtigsten stehen ohnehin oben (der Server sortiert nach Stufe). + 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; } @@ -174,27 +364,31 @@ daten = await antwort.json(); } catch { return; } + /* Sechs Nullen nebeneinander sind kein Bericht, sondern Rauschen. + Wenn wirklich nichts offen ist, steht dort ein Satz -- und der + Platz gehört den Bereichen. */ + const summe = ZAHLEN.reduce((n, z) => n + (daten[z.schluessel] ?? 0), 0); + const block = $('zahlen-block'); + if (!summe) { + block.dataset.leer = 'ja'; + $('zahlen').textContent = ''; + $('zahlen-leer').hidden = false; + return; + } + block.removeAttribute('data-leer'); + $('zahlen-leer').hidden = true; + const liste = $('zahlen'); liste.textContent = ''; for (const z of ZAHLEN) { const wert = daten[z.schluessel] ?? 0; - const li = document.createElement('li'); - li.className = 'zahl'; + const li = el('li', 'zahl'); if (z.warn && wert > 0) li.dataset.warn = 'ja'; - - const n = document.createElement('span'); - n.className = 'zahl__wert'; - n.textContent = String(wert); - - const t = document.createElement('span'); - t.className = 'zahl__name'; - t.textContent = z.name; - - li.append(n, t); + li.append(el('span', 'zahl__wert', String(wert))); + li.append(el('span', 'zahl__name', z.name)); liste.append(li); } } - laden(); })(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index f06536fe..7be3b23a 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + +
@@ -134,8 +134,8 @@ - - - + + +