diff --git a/pruef-kalender-computer.png b/pruef-kalender-computer.png new file mode 100644 index 00000000..7a81aa95 Binary files /dev/null and b/pruef-kalender-computer.png differ diff --git a/pruef-kalender-handy.png b/pruef-kalender-handy.png new file mode 100644 index 00000000..7363f5a5 Binary files /dev/null and b/pruef-kalender-handy.png differ diff --git a/server/pruef-buehne.mjs b/server/pruef-buehne.mjs index 5a96134a..48deed33 100644 --- a/server/pruef-buehne.mjs +++ b/server/pruef-buehne.mjs @@ -65,7 +65,11 @@ const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); -const SEITEN = ["start.html", "uebersicht.html", "personen.html", "content.html", "aufgaben.html"]; +/* Der Kalender kam am 01.09.2026 dazu -- er bringt die meisten neuen + Farbflächen mit (vier Arten, Feiertagsfärbung, Anlassmarken), und + genau dort entscheidet sich, ob der hellere Hintergrund noch trägt. */ +const SEITEN = ["start.html", "uebersicht.html", "personen.html", "content.html", + "aufgaben.html", "kalender.html"]; for (const [breite, hoehe, wie] of [[1440, 950, "Computer"], [390, 780, "Handy"]]) { console.log(`\n=== ${wie} (${breite}x${hoehe})`); diff --git a/server/pruef-kalender.mjs b/server/pruef-kalender.mjs new file mode 100644 index 00000000..4516b77f --- /dev/null +++ b/server/pruef-kalender.mjs @@ -0,0 +1,333 @@ +/* Browserpruefung des Kalenders: vier Ansichten, Anlaesse, Feiertage, + Filter, Tagesdialog -- und die selbst gerechneten Tage gegen bekannte + Werte. + + Die Anlassrechnung ist der Teil, der still falsch sein kann: Ein um + einen Tag verschobener Ostersonntag faellt niemandem auf, verschiebt + aber Karfreitag, Himmelfahrt und Pfingsten gleich mit. Deshalb werden + hier feste, nachschlagbare Daten geprueft und nicht nur "es steht + irgendwas da". */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-kal-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4188"; +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:4188"; +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 morgen = new Date(Date.now() + 86400_000).toISOString().slice(0, 10); +const in5 = new Date(Date.now() + 5 * 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 idAnna = anlegen("Tili", "creator", "CODE-ANNA-0001"); + +/* Drei Arten von Terminen und eine Frist -- damit alle vier Filter etwas + zu zeigen haben und man sieht, ob die Farben auseinandergehalten + werden. */ +const termine = [ + ["call", heute + "T10:00", "Wochen-Call"], + ["call", heute + "T15:30", "Kurzes Rueckfragen"], + ["termin", morgen + "T09:00", "Dreh im Studio"], + ["review", in5 + "T14:00", "Monats-Review"], +]; +for (const [art, beginn, titel] of termine) { + d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, teilnehmer_id, + erledigt, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,0,?,?)`) + .run(titel, art, beginn, 30, idAnna, idAnna, jetzt, idDogi); +} +d.prepare(`INSERT INTO aufgaben (titel, status, creator_id, verantwortlich_id, frist, + prioritaet, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?)`) + .run("Thumbnail bauen", "offen", idAnna, idAnna, morgen, "hoch", 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 }); + return seite; +} + +const lesen = (seite) => seite.evaluate(() => ({ + zeitraum: document.getElementById("zeitraum")?.textContent || "", + unter: document.getElementById("zeitraum-unter")?.textContent || "", + ansicht: document.getElementById("kalender")?.dataset.ansicht || null, + /* Monatsraster */ + zellen: document.querySelectorAll(".k-tag").length, + kwSpalten: document.querySelectorAll(".k-kw").length, + kwWerte: [...document.querySelectorAll(".k-kw")].map((k) => k.textContent), + heuteZellen: document.querySelectorAll('.k-tag[data-heute="ja"]').length, + feiertagZellen: document.querySelectorAll('.k-tag[data-feiertag="ja"]').length, + fremdZellen: document.querySelectorAll('.k-tag[data-fremd="ja"]').length, + /* Eintraege */ + pillen: [...document.querySelectorAll(".k-pille")].map((p) => ({ + text: p.textContent, + farbe: getComputedStyle(p).borderLeftColor, + })), + anlaesse: [...document.querySelectorAll("#kalender .k-anlass")].map((a) => a.textContent), + /* Leisten */ + saison: [...document.querySelectorAll("#saison .k-anlasskarte")].map((k) => ({ + name: k.querySelector(".k-anlasskarte__name")?.textContent, + rest: k.querySelector(".k-anlasskarte__rest")?.textContent, + feiertag: k.classList.contains("feiertag"), + })), + monatsanlaesse: [...document.querySelectorAll("#monatsanlaesse .k-anlasskarte")] + .map((k) => k.querySelector(".k-anlasskarte__name")?.textContent), + monatTitel: document.getElementById("monat-titel")?.textContent || "", + /* Filter */ + chips: [...document.querySelectorAll(".k-chip")].map((c) => ({ + text: c.textContent, an: c.getAttribute("aria-pressed") === "true", + })), + /* Andere Ansichten */ + wochenSpalten: document.querySelectorAll(".k-wochentag").length, + listentage: document.querySelectorAll(".k-listentag").length, + strahlTage: document.querySelectorAll(".k-strahl__tag").length, + zeilen: document.querySelectorAll(".k-zeile").length, + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, + /* Abgeschnittener Text ist der häufigste stille Fehler: Er sieht auf + den ersten Blick nach Absicht aus. Gemessen wird die tatsächliche + Textbreite gegen die verfügbare. Die Pillen im Raster sind bewusst + ausgenommen -- eine Zelle ist 150 px breit, dort IST das Kürzen die + richtige Lösung, und der volle Text steht im Tagesdialog. */ + abgeschnitten: [...document.querySelectorAll( + ".k-anlasskarte__name, .k-anlasskarte__wann, .k-anlasskarte__rest, " + + ".k-zeile__titel, .k-listentag__datum, .k-chip, .k-ansicht, .k-zeitraum")] + .filter((e) => e.scrollWidth > e.clientWidth + 1) + .map((e) => e.textContent.trim().slice(0, 30)), +})); + +for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) { + console.log(`\n=== ${wie} (${breite}px)`); + const kontext = await browser.newContext({ viewport: { width: breite, height: 1100 } }); + 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.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(700); + + const r = await lesen(seite); + console.log(" Zeitraum:", r.zeitraum, "·", r.unter); + console.log(" Als Nächstes:", r.saison.map((s) => `${s.name} (${s.rest})`).join(" · ")); + + /* ---- Monatsraster ---- */ + ok(r.ansicht === "monat", `startet im Monat (${r.ansicht})`); + /* Ein Monatsraster hat immer ganze Wochen: 28, 35 oder 42 Zellen. + Alles andere heisst, dass die Wochenberechnung daneben liegt. */ + ok([28, 35, 42].includes(r.zellen), `ganze Wochen im Raster (${r.zellen} Zellen)`); + ok(r.kwSpalten === r.zellen / 7, `je Woche eine KW (${r.kwSpalten} für ${r.zellen / 7} Wochen)`); + ok(r.kwWerte.every((w) => Number(w) >= 1 && Number(w) <= 53), + `alle Kalenderwochen plausibel (${r.kwWerte.join(",")})`); + ok(r.heuteZellen === 1, `genau ein Tag ist "heute" (${r.heuteZellen})`); + ok(r.fremdZellen > 0, `die Nachbarmonatstage sind erkennbar (${r.fremdZellen})`); + + /* ---- Eintraege im Raster ---- */ + ok(r.pillen.length === 5, `alle fünf Einträge stehen im Raster (${r.pillen.length})`); + ok(r.pillen.some((p) => p.text.includes("Wochen-Call")), "der Call ist dabei"); + ok(r.pillen.some((p) => p.text.includes("Thumbnail bauen")), "die Frist aus den Aufgaben auch"); + /* Jede Art traegt ihre eigene Farbe. Zwei Calls, ein Termin, ein + Review, eine Frist -> vier verschiedene Farben. */ + const farben = new Set(r.pillen.map((p) => p.farbe)); + ok(farben.size === 4, `vier Arten, vier Farben (${farben.size})`); + /* Die Uhrzeit steht vorn -- ausser bei der Frist, die keine hat. */ + ok(r.pillen.filter((p) => /^\d{2}:\d{2}/.test(p.text)).length === 4, + "vier Einträge mit Uhrzeit, die Frist ohne"); + + /* ---- Zaehler in der Kopfzeile ---- */ + ok(/KW \d+( – \d+)? · 5 Einträge/.test(r.unter), `die Kopfzeile zählt mit ("${r.unter}")`); + + /* ---- Filter ---- */ + ok(r.chips.length === 4, `vier Filter (${r.chips.length})`); + ok(r.chips.every((c) => c.an), "und alle zunächst an"); + ok(r.chips.some((c) => c.text.includes("Call (2)")), `der Call-Filter zählt zwei (${r.chips.map((c) => c.text).join(" ")})`); + ok(r.chips.some((c) => c.text.includes("Frist (1)")), "der Fristen-Filter zählt eine"); + + /* Ausschalten muss WIRKLICH etwas ausblenden -- ein Filter, der nur + die Farbe des Knopfes aendert, ist keiner. */ + await seite.click('.k-chip:has-text("Call")'); + await seite.waitForTimeout(250); + const nachFilter = await lesen(seite); + ok(nachFilter.pillen.length === 3, + `Calls ausgeschaltet: drei Einträge übrig (${nachFilter.pillen.length})`); + ok(/· 3 Einträge/.test(nachFilter.unter), `und die Kopfzeile zählt neu ("${nachFilter.unter}")`); + await seite.click('.k-chip:has-text("Call")'); + await seite.waitForTimeout(250); + + /* ---- Anlassleisten ---- */ + ok(r.saison.length === 3, `drei kommende Anlässe (${r.saison.length})`); + ok(r.saison.every((s) => /^(heute|morgen|in \d+ Tagen)$/.test(s.rest.replace("Vorlauf läuft", "").trim())), + `jeder mit Abstand in Tagen (${r.saison.map((s) => s.rest).join(" | ")})`); + ok(r.monatTitel.startsWith("Im "), `die zweite Leiste nennt den Monat ("${r.monatTitel}")`); + + /* ---- Blättern ---- */ + const vorher = r.zeitraum; + await seite.click("#vor-zeit"); + await seite.waitForTimeout(400); + const weiter = await lesen(seite); + ok(weiter.zeitraum !== vorher, `vorwärts blättern wechselt den Monat (${vorher} → ${weiter.zeitraum})`); + ok(weiter.heuteZellen === 0, "im nächsten Monat ist kein Tag mehr \"heute\""); + await seite.click("#heute-knopf"); + await seite.waitForTimeout(400); + const zurueck = await lesen(seite); + ok(zurueck.zeitraum === vorher, `"Heute" führt zurück (${zurueck.zeitraum})`); + + /* ---- Die anderen drei Ansichten ---- */ + for (const [name, pruefung] of [ + ["woche", (x) => x.wochenSpalten === 7], + ["liste", (x) => x.listentage > 0 && x.zeilen > 0], + ["zeitstrahl", (x) => x.strahlTage === 42], + ]) { + await seite.click(`.k-ansicht[data-ansicht="${name}"]`); + await seite.waitForTimeout(450); + const a = await lesen(seite); + ok(a.ansicht === name && pruefung(a), + `Ansicht ${name} baut auf (Spalten=${a.wochenSpalten} Tage=${a.listentage} ` + + `Striche=${a.strahlTage} Zeilen=${a.zeilen})`); + ok(a.ueber <= 0, ` und steht nicht über (${a.ueber}px)`); + } + await seite.click('.k-ansicht[data-ansicht="monat"]'); + await seite.waitForTimeout(400); + + /* ---- Tagesdialog ---- */ + await seite.click(`.k-tag[data-tag="${heute}"]`); + await seite.waitForTimeout(300); + const dialog = await seite.evaluate(() => ({ + offen: document.getElementById("tagdialog")?.open, + titel: document.getElementById("tagdialog-titel")?.textContent, + unter: document.getElementById("tagdialog-unter")?.textContent, + zeilen: document.querySelectorAll("#tagdialog-liste .k-zeile").length, + })); + ok(dialog.offen === true, "ein Klick auf den Tag öffnet den Dialog"); + ok(dialog.zeilen === 2, `und zeigt die beiden Calls von heute (${dialog.zeilen})`); + ok(/heute/.test(dialog.unter), `und weiss, dass es heute ist ("${dialog.unter}")`); + await seite.keyboard.press("Escape"); + await seite.waitForTimeout(250); + ok(await seite.evaluate(() => !document.getElementById("tagdialog").open), + "Esc schließt ihn wieder"); + + ok(r.ueber <= 0, `nichts steht über (${r.ueber}px)`); + ok(r.abgeschnitten.length === 0, + `kein Text wird abgeschnitten (${r.abgeschnitten.join(" | ") || "keiner"})`); + ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); + + await seite.screenshot({ + path: breite === 1440 ? "pruef-kalender-computer.png" : "pruef-kalender-handy.png", + fullPage: true }); + await kontext.close(); +} + +/* --------------------------------------------------------------------- + Die gerechneten Tage gegen bekannte Werte. + + Diese Zahlen stehen in jedem Kalender und lassen sich nachschlagen. + Ohne diese Prüfung koennte die Osterformel um einen Tag daneben + liegen und niemand wuerde es merken -- bis irgendwann jemand + Karfreitag arbeitet. + --------------------------------------------------------------------- */ +console.log("\n=== Die gerechneten Tage"); +{ + const kontext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001"); + await seite.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(600); + + /* Zu jedem Jahr: der Monat wird angesteuert und im Raster nachgesehen, + an welchem Tag der Anlass steht. Damit wird wirklich das geprueft, + was der Kalender ANZEIGT -- nicht eine Formel, die daneben noch + einmal gerechnet wird. */ + const faelle = [ + ["2026-04-05", "Ostersonntag"], + ["2026-04-03", "Karfreitag"], + ["2026-05-14", "Christi Himmelfahrt"], + ["2026-05-10", "Muttertag"], + ["2026-11-27", "Black Friday"], + ["2026-12-20", "4. Advent"], + ["2027-03-28", "Ostersonntag"], + ["2027-11-26", "Black Friday"], + ["2027-12-19", "4. Advent"], + ["2030-04-21", "Ostersonntag"], + ]; + + /* Zum Zielmonat blättern -- über die echten Knöpfe, nicht durch + Setzen einer inneren Variablen: So wird auch die Navigation selbst + mitgeprüft. + + Wichtig ist die RICHTUNG. Ein erster Versuch klickte stur vorwärts + und erreichte April 2026 nie, weil heute September 2026 ist. Der + Test meldete daraufhin zehnmal "Monat nicht erreicht" -- und hätte + bei oberflächlichem Hinsehen wie ein Rechenfehler in der + Osterformel ausgesehen. Deshalb wird jetzt verglichen, welcher + Monat offen ist, und gezielt in die passende Richtung geblättert. */ + async function zuMonat(tag) { + for (let i = 0; i < 90; i++) { + const stand = await seite.evaluate((t) => { + if (document.querySelector(`.k-tag[data-tag="${t}"]`)) return "da"; + /* Der offene Monat ist der der Zellen OHNE data-fremd -- die + Randzellen gehören zum Nachbarmonat. */ + const eigen = document.querySelector('.k-tag:not([data-fremd="ja"])'); + return eigen ? eigen.dataset.tag.slice(0, 7) : null; + }, tag); + if (stand === "da") return true; + if (!stand) return false; + await seite.click(stand < tag.slice(0, 7) ? "#vor-zeit" : "#zurueck-zeit"); + await seite.waitForTimeout(70); + } + return false; + } + + for (const [tag, name] of faelle) { + const erreicht = await zuMonat(tag); + const text = erreicht ? await seite.evaluate((t) => { + const z = document.querySelector(`.k-tag[data-tag="${t}"]`); + return z ? [...z.querySelectorAll(".k-anlass")].map((a) => a.textContent).join(" | ") : null; + }, tag) : null; + ok(text !== null && text.includes(name), + `${name} liegt ${tag} (gefunden: ${text ?? "Monat nicht erreicht"})`); + } + await kontext.close(); +} + +await browser.close(); +/* Der Testordner bleibt manchmal gesperrt, weil der Server im selben + Prozess noch auf der Datenbank sitzt. Das ist kein Prüfergebnis -- + ein Aufräumfehler darf einen bestandenen Lauf nicht rot machen. */ +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); +process.exit(fehler ? 1 : 0); diff --git a/workspace/assets/css/kalender.css b/workspace/assets/css/kalender.css index 6cc08e4c..fa9d7826 100644 --- a/workspace/assets/css/kalender.css +++ b/workspace/assets/css/kalender.css @@ -1,83 +1,547 @@ /* =================================================================== - Kalender. Nach Tagen gruppiert statt als Monatsraster: Es geht um - wenige, dafür konkrete Termine -- eine Liste liest sich dabei besser - und funktioniert auf dem Handy ohne Umbau. + Kalender + + Gebaut nach demselben Grundsatz wie die Kacheln auf der Startseite: + Die Farbe gehört zur SACHE, nicht zum Rang. Ein Call ist überall + blau, ein Review überall grün, eine Frist überall orange -- in der + Pille im Raster, in der Zeile in der Liste, im Filterknopf und im + Balken des Zeitstrahls. Wer die Farbe einmal gelernt hat, muss die + Beschriftung nicht mehr lesen. + + Augenschonend: gedeckte Töne, keine Signalfarben, kein Blinken. Der + einzige laute Punkt ist "heute" -- und der darf laut sein, weil man + ihn sucht. =================================================================== */ -.zeitraum { display: flex; gap: 8px; margin: 0 0 22px; flex-wrap: wrap; } -.zeitraum .schritt[aria-pressed="true"] { - /* Gedrueckt = der "voll"-Auftritt des Knopf-Systems. Vorher stand - hier eine eigene Flaeche, die den geschliffenen Rand ueberschrieb. */ - --f: var(--akzent); - color: #fff; - background: - linear-gradient(color-mix(in srgb, var(--f) 20%, #121926), - color-mix(in srgb, var(--f) 12%, #0d1420)) padding-box, - linear-gradient(150deg, var(--f), - color-mix(in srgb, var(--f) 45%, transparent)) border-box; - box-shadow: 0 4px 16px -9px var(--f); +/* ---------- Kopf: der Zeitraum als Überschrift -------------------------- + Man liest zuerst, WO man ist, und bewegt sich dann. Deshalb steht der + Monat groß und die Navigation daneben, nicht umgekehrt. */ +.k-kopf { + display: flex; align-items: flex-end; justify-content: space-between; + flex-wrap: wrap; gap: 16px; + margin-bottom: 22px; +} +.k-zeitraum { + margin: 0; + font-size: clamp(1.7rem, 3.2vw, 2.4rem); + letter-spacing: -.025em; + color: #f2f7fd; + text-shadow: 0 2px 26px rgba(63, 189, 245, .16); + /* Der Monatsname wechselt beim Blättern die Länge. Ohne feste + Zeichenbreite für die Jahreszahl springt die Zeile dabei. */ + font-variant-numeric: tabular-nums; +} +.k-kopf__unter { + margin: 5px 0 0; + font-size: .82rem; letter-spacing: .1em; text-transform: uppercase; + color: var(--text-still); + font-variant-numeric: tabular-nums; +} +.k-nav { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.k-pfeil { + min-width: 38px; padding-left: 0; padding-right: 0; + font-size: 1.15rem; line-height: 1; + justify-content: center; } -.tag { margin-bottom: 22px; } - -.tag__kopf { - display: flex; align-items: baseline; gap: 10px; - margin: 0 2px 9px; padding-bottom: 7px; - border-bottom: 1px solid var(--rand); +/* ---------- Die beiden Anlassreihen ------------------------------------ + Oben "Als Nächstes" (blickt ab heute nach vorn, ändert sich beim + Blättern NICHT), darunter die Anlässe des angezeigten Zeitraums. Sie + sehen absichtlich gleich aus -- es sind dieselben Dinge, nur zu einer + anderen Frage. Die Überschriften sagen, welche. */ +.k-leiste { margin-bottom: 20px; } +.k-leiste .feldschild { display: flex; align-items: center; gap: 9px; } +.k-leiste .feldschild::before { + content: ""; + width: 18px; height: 2px; border-radius: 2px; + background: linear-gradient(90deg, var(--akzent), transparent); + opacity: .8; } -.tag__datum { font-weight: 700; font-size: .95rem; } -.tag__wochentag { font-size: .8rem; color: var(--text-still); } -.tag__marke { - margin-left: auto; padding: 1px 9px; border-radius: 999px; - font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; - background: rgba(63, 189, 245, .16); color: #7fd2fb; +.k-anlassreihe { + display: grid; gap: 9px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + margin-top: 10px; } -/* Vergangene Tage treten zurück, bleiben aber lesbar. */ -.tag[data-vergangen="ja"] .tag__datum, -.tag[data-vergangen="ja"] .tag__wochentag { color: var(--text-still); } -.eintrag { - display: grid; gap: 3px 14px; - grid-template-columns: 78px 1fr auto; - align-items: start; - padding: 11px 14px; margin-bottom: 8px; - background: rgba(255, 255, 255, .03); - border: 1px solid var(--rand); - border-left: 3px solid var(--rand); +.k-anlasskarte { + --afarbe: var(--akzent); + position: relative; overflow: hidden; + display: flex; align-items: center; gap: 12px; + padding: 11px 14px 11px 16px; + border: 1px solid color-mix(in srgb, var(--afarbe) 26%, var(--rand)); border-radius: var(--radius-klein); + background: + linear-gradient(90deg, color-mix(in srgb, var(--afarbe) 11%, transparent), transparent 58%), + rgba(255, 255, 255, .03); + cursor: pointer; + transition: border-color .18s ease, background .18s ease, transform .18s ease; +} +.k-anlasskarte::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: linear-gradient(180deg, var(--afarbe), + color-mix(in srgb, var(--afarbe) 35%, transparent)); +} +.k-anlasskarte:hover, .k-anlasskarte:focus-visible { + border-color: color-mix(in srgb, var(--afarbe) 46%, var(--rand)); + transform: translateY(-1px); +} +/* Vorbei heißt: steht noch da, aber leiser. Ganz wegzulassen wäre + falsch -- man will sehen, dass der Tag gewesen ist. */ +.k-anlasskarte.vorbei { opacity: .5; } +.k-anlasskarte__sym { font-size: 1.3rem; line-height: 1; flex: none; } +.k-anlasskarte__txt { display: grid; gap: 2px; min-width: 0; flex: 1 1 auto; } +/* Der Name darf umbrechen statt abzubrechen. Vorher stand hier + "Tag der Deutsc…" -- abgeschnittener Text sieht auf den ersten Blick + nach Absicht aus und ist deshalb der teuerste stille Fehler in einer + Karte. Zwei Zeilen kosten nichts, ein verlorener Name schon. */ +.k-anlasskarte__name { + font-weight: 650; color: var(--text); + line-height: 1.25; + overflow-wrap: anywhere; +} +.k-anlasskarte__wann { font-size: .78rem; color: var(--text-leise); } +.k-anlasskarte__rest { + flex: none; text-align: right; + font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; + color: var(--afarbe); + font-variant-numeric: tabular-nums; +} +.k-anlasskarte__rest small { + display: block; margin-top: 2px; + font-size: .64rem; letter-spacing: .06em; + color: var(--text-still); text-transform: none; +} +.k-anlassleer { + grid-column: 1 / -1; + margin: 0; padding: 13px 16px; + font-size: .88rem; color: var(--text-still); + border: 1px dashed var(--rand); border-radius: var(--radius-klein); } -.eintrag[data-art="call"] { border-left-color: var(--akzent); } -.eintrag[data-art="review"] { border-left-color: #e0a33c; } -.eintrag[data-art="termin"] { border-left-color: var(--violett); } -.eintrag[data-art="frist"] { border-left-color: #d8656e; background: rgba(216, 101, 110, .05); } -.eintrag[data-erledigt="ja"] { opacity: .5; } -.eintrag__zeit { - font-variant-numeric: tabular-nums; font-weight: 600; font-size: .88rem; +/* ---------- Werkzeugleiste: Ansicht und Filter -------------------------- */ +.k-werkzeuge { + display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; gap: 12px; + margin: 4px 0 16px; +} +.k-ansichten { display: flex; gap: 6px; flex-wrap: wrap; } +.k-ansicht { + padding: 8px 15px; + border: 1px solid var(--rand); border-radius: 999px; + background: rgba(255, 255, 255, .03); + color: var(--text-leise); + font: inherit; font-size: .86rem; + cursor: pointer; + transition: border-color .18s ease, background .18s ease, color .18s ease; +} +.k-ansicht:hover { color: var(--text); border-color: var(--rand-hell); } +.k-ansicht[aria-pressed="true"] { + color: #eaf4fd; + border-color: color-mix(in srgb, var(--akzent) 46%, transparent); + background: color-mix(in srgb, var(--akzent) 15%, transparent); +} + +.k-filter { display: flex; gap: 6px; flex-wrap: wrap; } +.k-chip { + --cfarbe: var(--akzent); + display: inline-flex; align-items: center; gap: 8px; + padding: 8px 14px; + border: 1px solid var(--rand); border-radius: 999px; + background: rgba(255, 255, 255, .03); + color: var(--text-still); + font: inherit; font-size: .84rem; + cursor: pointer; + font-variant-numeric: tabular-nums; + transition: border-color .18s ease, background .18s ease, color .18s ease, opacity .18s ease; +} +.k-chip__punkt { + width: 8px; height: 8px; border-radius: 50%; + background: var(--cfarbe); + transition: opacity .18s ease, box-shadow .18s ease; +} +.k-chip[aria-pressed="true"] { + color: var(--text); + border-color: color-mix(in srgb, var(--cfarbe) 40%, transparent); + background: color-mix(in srgb, var(--cfarbe) 11%, transparent); +} +.k-chip[aria-pressed="true"] .k-chip__punkt { + box-shadow: 0 0 8px color-mix(in srgb, var(--cfarbe) 70%, transparent); +} +/* Ausgeschaltet: der Punkt wird hohl. Nur die Deckkraft zu senken wäre + im Kontrastmodus nicht zu unterscheiden. */ +.k-chip[aria-pressed="false"] .k-chip__punkt { + background: transparent; + box-shadow: inset 0 0 0 1.5px var(--cfarbe); + opacity: .55; +} + +/* ---------- Monatsraster ------------------------------------------------ + Eine Spalte für die Kalenderwoche, dann die sieben Tage. Die + KW-Spalte ist kein Schmuck: In Absprachen ("machen wir in KW 42") + ist sie die übliche Währung. */ +.k-raster { + display: grid; + grid-template-columns: 46px repeat(7, minmax(0, 1fr)); + gap: 1px; + background: var(--rand); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); + overflow: hidden; +} +.k-spaltenkopf { + padding: 11px 10px; + background: rgba(12, 17, 28, .82); + font-size: .74rem; letter-spacing: .12em; text-transform: uppercase; color: var(--text-leise); } -.eintrag__titel { font-weight: 600; font-size: .93rem; } -.eintrag__zusatz { - grid-column: 2; display: flex; flex-wrap: wrap; gap: 8px; - font-size: .78rem; color: var(--text-still); -} -.eintrag__zusatz a { color: var(--akzent); } -.eintrag__knoepfe { display: flex; gap: 6px; align-items: center; } +.k-spaltenkopf--kw { color: var(--text-still); } -.eintrag__art { - padding: 1px 8px; border-radius: 999px; - font-size: .68rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; - background: rgba(255, 255, 255, .06); color: var(--text-still); +.k-kw { + display: flex; align-items: flex-start; justify-content: center; + padding: 10px 4px; + background: rgba(9, 13, 22, .78); + font-size: .78rem; font-weight: 600; + color: var(--text-still); + font-variant-numeric: tabular-nums; +} +/* Die laufende Woche wird in der KW-Spalte markiert -- dadurch findet + man sich beim Blättern sofort zurecht. */ +.k-kw[data-jetzt="ja"] { + color: var(--akzent); + background: color-mix(in srgb, var(--akzent) 12%, rgba(9, 13, 22, .78)); } -.leer-hinweis { - padding: 26px 20px; text-align: center; - color: var(--text-still); font-size: .9rem; - border: 1px dashed var(--rand); border-radius: var(--radius); +.k-tag { + min-height: 108px; + padding: 8px 9px 10px; + background: rgba(11, 15, 25, .74); + display: flex; flex-direction: column; gap: 4px; + cursor: pointer; + transition: background .16s ease; +} +.k-tag:hover { background: rgba(20, 27, 42, .85); } +.k-tag:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; } +/* Tage des Nachbarmonats: da, aber deutlich zurückgenommen. Sie ganz + wegzulassen würde das Raster zerreißen. */ +.k-tag[data-fremd="ja"] { background: rgba(7, 10, 17, .72); } +.k-tag[data-fremd="ja"] .k-tag__zahl { color: var(--text-still); opacity: .6; } +.k-tag[data-wochenende="ja"] { background: rgba(9, 12, 20, .8); } +/* Gesetzlicher Feiertag: ein Hauch Rosé. Kein Signal, nur eine andere + Fläche -- man sieht ihn, ohne dass er ruft. */ +.k-tag[data-feiertag="ja"] { background: color-mix(in srgb, #ff9aa2 9%, rgba(11, 15, 25, .74)); } +.k-tag[data-feiertag="ja"]:hover { background: color-mix(in srgb, #ff9aa2 14%, rgba(11, 15, 25, .74)); } +.k-tag[data-heute="ja"] { + background: color-mix(in srgb, var(--akzent) 13%, rgba(11, 15, 25, .74)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--akzent) 50%, transparent); } -@media (max-width: 620px) { - .eintrag { grid-template-columns: 62px 1fr; } - .eintrag__knoepfe { grid-column: 1 / -1; margin-top: 8px; } - .eintrag__zusatz { grid-column: 1 / -1; } +.k-tag__kopf { display: flex; align-items: baseline; gap: 6px; } +.k-tag__zahl { + font-size: .95rem; font-weight: 650; color: var(--text); + font-variant-numeric: tabular-nums; +} +/* Am Monatsersten steht der Monatsname dabei -- sonst weiß man in der + letzten Rasterzeile nicht, ob die 1 schon zum nächsten Monat gehört. + + Bewusst --text-leise statt --text-still: Wenn der Monatserste + zufällig HEUTE ist, liegt der Text auf der aufgehellten Heute-Zelle, + und dort kam der leiseste Grauton nur noch auf 3,96:1. Gemessen an + echten Bildpunkten, nicht an der Farbangabe -- die weiß nichts davon, + was hinter ihr liegt. Jetzt 5,9:1. */ +.k-tag__monat { font-size: .7rem; color: var(--text-leise); } +.k-tag__heute { + margin-left: auto; + font-size: .62rem; letter-spacing: .1em; text-transform: uppercase; + color: var(--akzent); +} + +/* ---------- Anlassmarke im Raster --------------------------------------- */ +.k-anlass { + --afarbe: var(--akzent); + display: block; + padding: 2px 6px; + border-radius: 5px; + border-left: 2px solid var(--afarbe); + background: color-mix(in srgb, var(--afarbe) 13%, transparent); + color: color-mix(in srgb, var(--afarbe) 70%, #ffffff); + font-size: .68rem; line-height: 1.35; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +/* Feiertage sind lauter als Anlässe: Sie sind eine Tatsache, kein + Vorschlag. */ +.k-anlass.feiertag { + background: color-mix(in srgb, var(--afarbe) 20%, transparent); + font-weight: 600; +} + +/* ---------- Pille: ein Eintrag im Raster -------------------------------- */ +.k-pille { + --pfarbe: var(--akzent); + display: block; + padding: 3px 7px; + border-radius: 5px; + border-left: 2px solid var(--pfarbe); + background: color-mix(in srgb, var(--pfarbe) 16%, transparent); + color: var(--text); + font-size: .72rem; line-height: 1.4; + text-decoration: none; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + transition: background .16s ease; +} +.k-pille:hover { background: color-mix(in srgb, var(--pfarbe) 26%, transparent); } +.k-pille__zeit { + color: var(--pfarbe); font-weight: 650; margin-right: 5px; + font-variant-numeric: tabular-nums; +} +/* Erledigt: durchgestrichen UND blasser. Nur blasser wäre im + Kontrastmodus nicht zu erkennen. */ +.k-pille.erledigt { opacity: .5; text-decoration: line-through; } + +/* ---------- Wochenansicht ----------------------------------------------- */ +.k-woche { + display: grid; gap: 1px; + grid-template-columns: repeat(7, minmax(0, 1fr)); + background: var(--rand); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); + overflow: hidden; +} +.k-wochentag { + min-height: 190px; + padding: 10px; + background: rgba(11, 15, 25, .74); + display: flex; flex-direction: column; gap: 5px; + cursor: pointer; + transition: background .16s ease; +} +.k-wochentag:hover { background: rgba(20, 27, 42, .85); } +.k-wochentag:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; } +.k-wochentag[data-feiertag="ja"] { background: color-mix(in srgb, #ff9aa2 9%, rgba(11, 15, 25, .74)); } +.k-wochentag[data-heute="ja"] { + background: color-mix(in srgb, var(--akzent) 13%, rgba(11, 15, 25, .74)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--akzent) 50%, transparent); +} +.k-wochentag__kopf { + display: flex; align-items: baseline; gap: 7px; + padding-bottom: 7px; margin-bottom: 3px; + border-bottom: 1px solid var(--rand); +} +.k-wochentag__name { + font-size: .72rem; letter-spacing: .12em; text-transform: uppercase; + color: var(--text-still); +} +.k-wochentag__zahl { + font-size: 1.25rem; font-weight: 700; color: var(--text); + font-variant-numeric: tabular-nums; +} +.k-wochentag__leer { margin: auto 0; color: var(--text-still); font-size: .8rem; } + +/* ---------- Listenansicht ----------------------------------------------- */ +.k-liste { display: grid; gap: 12px; } +.k-listentag { + padding: 14px 16px; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + background: rgba(255, 255, 255, .026); + display: grid; gap: 7px; +} +.k-listentag[data-vergangen="ja"] { opacity: .62; } +.k-listentag[data-heute="ja"] { + border-color: color-mix(in srgb, var(--akzent) 40%, transparent); + background: color-mix(in srgb, var(--akzent) 7%, rgba(255, 255, 255, .026)); +} +.k-listentag__kopf { display: flex; align-items: baseline; gap: 11px; flex-wrap: wrap; } +.k-listentag__datum { font-weight: 650; color: var(--text); } +.k-listentag__kw { + font-size: .72rem; letter-spacing: .1em; text-transform: uppercase; + color: var(--text-still); font-variant-numeric: tabular-nums; +} +.k-listentag__marke { + margin-left: auto; padding: 2px 9px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--akzent) 34%, transparent); + background: color-mix(in srgb, var(--akzent) 13%, transparent); + color: var(--akzent); + font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; +} + +/* ---------- Eine ausführliche Zeile ------------------------------------- */ +.k-zeile { + --zfarbe: var(--akzent); + position: relative; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; gap: 12px; + padding: 9px 12px 9px 14px; + border-radius: 8px; + border: 1px solid var(--rand); + background: + linear-gradient(90deg, color-mix(in srgb, var(--zfarbe) 8%, transparent), transparent 50%), + rgba(255, 255, 255, .02); +} +.k-zeile::before { + content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px; + border-radius: 0 3px 3px 0; + background: var(--zfarbe); +} +.k-zeile[data-erledigt="ja"] { opacity: .55; } +.k-zeile[data-erledigt="ja"] .k-zeile__titel { text-decoration: line-through; } +.k-zeile__zeit { + font-size: .82rem; font-weight: 650; color: var(--zfarbe); + font-variant-numeric: tabular-nums; + min-width: 5.5ch; +} +.k-zeile__titel { color: var(--text); min-width: 0; } +.k-zeile__knoepfe { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.k-zeile__art { + padding: 2px 9px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--zfarbe) 32%, transparent); + color: var(--zfarbe); + font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; +} +.k-zeile__zusatz { + grid-column: 1 / -1; + display: flex; flex-wrap: wrap; gap: 4px 10px; + font-size: .78rem; color: var(--text-leise); +} +.k-zeile__zusatz a { color: var(--akzent); } + +/* ---------- Zeitstrahl --------------------------------------------------- + Sechs Wochen auf einer Achse. Die Höhe eines Strichs ist die Menge + des Tages -- man sieht Ballungen und Lücken, ohne zu zählen. */ +.k-strahl-rahmen { + padding: 20px 18px 14px; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + background: rgba(255, 255, 255, .026); +} +.k-strahl { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + align-items: end; + gap: 2px; + height: 150px; +} +.k-strahl__tag { + --menge: 0; + position: relative; + height: 100%; + display: flex; flex-direction: column; justify-content: flex-end; align-items: center; + gap: 3px; + padding-bottom: 16px; + border-radius: 4px 4px 0 0; + cursor: pointer; + transition: background .16s ease; +} +.k-strahl__tag:hover { background: rgba(255, 255, 255, .05); } +.k-strahl__tag:focus-visible { outline: 2px solid var(--akzent); outline-offset: 1px; } +.k-strahl__tag[data-wochenende="ja"] { background: rgba(255, 255, 255, .022); } +.k-strahl__tag[data-feiertag="ja"] { background: color-mix(in srgb, #ff9aa2 10%, transparent); } +.k-strahl__tag[data-heute="ja"] { background: color-mix(in srgb, var(--akzent) 15%, transparent); } +.k-strahl__saeule { + --sfarbe: var(--akzent); + width: 100%; + /* Ein leerer Tag bekommt einen Sockel von 3 px statt gar nichts -- + sonst sähe die Achse an ruhigen Stellen kaputt aus. */ + height: calc(3px + var(--menge) * 20px); + border-radius: 3px 3px 0 0; + background: linear-gradient(180deg, var(--sfarbe), + color-mix(in srgb, var(--sfarbe) 30%, transparent)); + opacity: .92; +} +.k-strahl__tag[style*="--menge: 0"] .k-strahl__saeule { opacity: .3; } +.k-strahl__sym { position: absolute; top: 2px; font-size: .8rem; } +.k-strahl__kw { + position: absolute; bottom: 0; + font-size: .62rem; color: var(--text-still); + font-variant-numeric: tabular-nums; +} +.k-strahl__fuss { + margin: 14px 0 0; + font-size: .76rem; color: var(--text-still); +} + +/* ---------- Leerer Zeitraum --------------------------------------------- */ +.k-leer { + padding: 26px 22px; + border: 1px dashed var(--rand); border-radius: var(--radius-klein); + text-align: center; +} +.k-leer__satz { margin: 0 0 6px; color: var(--text-leise); } +.k-leer__tipp { margin: 0; font-size: .82rem; color: var(--text-still); } + +/* ---------- Tagesdialog -------------------------------------------------- */ +.k-dialog { + width: min(560px, calc(100vw - 32px)); + padding: 20px 22px; + border: 1px solid var(--rand-hell); + border-radius: var(--radius); + background: var(--glas-tief); + color: var(--text); + box-shadow: var(--schatten); + backdrop-filter: blur(14px); +} +.k-dialog::backdrop { background: rgba(3, 5, 10, .72); backdrop-filter: blur(3px); } +.k-dialog__kopf { + display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; + margin-bottom: 14px; +} +.k-dialog__titel { display: block; font-size: 1.1rem; color: #eaf2fb; } +.k-dialog__unter { + display: block; margin-top: 3px; + font-size: .76rem; letter-spacing: .1em; text-transform: uppercase; + color: var(--text-still); +} +.k-dialog__anlaesse { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } +.k-dialog__anlaesse:empty { display: none; } +.k-dialog__anlaesse .k-anlass { font-size: .76rem; padding: 4px 9px; } +.k-dialog__liste { display: grid; gap: 8px; margin-bottom: 16px; } +.k-dialog__fuss { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding-top: 14px; + border-top: 1px solid var(--rand); +} +.k-dialog__tipp { font-size: .74rem; color: var(--text-still); } + +/* ---------- Kleinere Bildschirme ---------------------------------------- + Das Monatsraster ist auf einem Handy nicht sinnvoll zu quetschen: Bei + 390 px wären die Zellen 46 px breit, und in einer 46-px-Zelle steht + kein Termin. Statt zu schrumpfen wird deshalb umgebaut -- die + KW-Spalte fällt weg, die Zellen werden flacher, und die Pillen zeigen + nur noch die Uhrzeit als Punkt. */ +@media (max-width: 860px) { + .k-raster { grid-template-columns: 32px repeat(7, minmax(0, 1fr)); } + .k-tag { min-height: 76px; padding: 6px 5px 7px; } + .k-tag__monat, .k-tag__heute { display: none; } + .k-anlass, .k-pille { + font-size: .6rem; padding: 1px 4px; + border-left-width: 2px; + } + .k-pille__zeit { margin-right: 3px; } + .k-spaltenkopf { padding: 8px 4px; font-size: .66rem; letter-spacing: .06em; } + .k-kw { font-size: .66rem; padding: 7px 2px; } + .k-woche { grid-template-columns: 1fr; } + .k-wochentag { min-height: 0; } + .k-strahl { height: 110px; } +} +@media (max-width: 560px) { + .k-kopf { align-items: flex-start; } + .k-nav { width: 100%; } + .k-zeile { grid-template-columns: auto 1fr; } + .k-zeile__knoepfe { grid-column: 1 / -1; } + .k-anlassreihe { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + .k-anlasskarte, .k-tag, .k-wochentag, .k-pille, .k-chip, + .k-ansicht, .k-strahl__tag { transition: none; } + .k-anlasskarte:hover { transform: none; } +} + +/* Ohne Farbverläufe (Kontrastmodus, Druck) müssen Kante und Schrift + allein tragen -- deshalb bleiben die Ränder erhalten. */ +@media (forced-colors: active) { + .k-zeile::before, .k-anlasskarte::before { background: CanvasText; } + .k-pille, .k-anlass { border: 1px solid CanvasText; } + .k-strahl__saeule { background: CanvasText; } } diff --git a/workspace/assets/js/kalender.js b/workspace/assets/js/kalender.js index 8adc99b5..d152a98a 100644 --- a/workspace/assets/js/kalender.js +++ b/workspace/assets/js/kalender.js @@ -1,22 +1,48 @@ /* =================================================================== - Kalender: eigene Termine und Fristen aus den Aufgaben in einer Liste. - Fristen sind nur eingeblendet -- geändert werden sie im Aufgabenbrett. + Kalender + + Vier Ansichten auf DENSELBEN Daten -- der Server liefert einen + Zeitraum, hier wird er unterschiedlich dargestellt. So kann keine + Ansicht etwas anderes zeigen als die andere. + + Monat Überblick, Rhythmus, wo Lücken sind + Woche die sieben Tage groß, mit Uhrzeiten + Liste Arbeitsansicht: was kommt als Nächstes + Zeitstrahl Vorlauf und Ballungen auf einer Achse + + Termine (Calls, Termine, Reviews) und Fristen aus den Aufgaben liegen + auf einer Achse. Fristen erscheinen bewusst OHNE Uhrzeit als + Tagesmarke -- eine Frist ist ein Stichtag, kein Termin, und würde + sonst Platz blockieren, an dem nichts stattfindet. + + --------------------------------------------------------------------- + ANLÄSSE UND FEIERTAGE + + Ein leerer Kalender ist nicht nur kahl, er ist nutzlos: Die Frage beim + Planen lautet nie "was habe ich schon eingetragen", sondern "worauf + muss ich zuarbeiten". Für Creator sind das Halloween, Weihnachten, + Silvester, Valentinstag, Black Friday -- dort ist das Publikum wach, + dorthin gehört der Vorlauf. + + Der Kalender rechnet diese Tage selbst aus, für jedes beliebige Jahr: + Ostern über die Gauß-Formel, davon abgeleitet Karfreitag, Himmelfahrt + und Pfingsten, dazu die beweglichen Termine (Muttertag, Black Friday, + die vier Advente). Kein Dienst, keine Liste zum Nachpflegen, keine + Kosten, funktioniert offline und im Jahr 2040. + + Bewusst NUR die neun bundesweiten Feiertage. Fronleichnam, + Reformationstag und Allerheiligen gelten je nach Bundesland -- ein + Kalender, der sie überall anzeigt, wäre für die Hälfte der Leute + schlicht falsch. Lieber weniger behaupten als etwas Falsches. =================================================================== */ (() => { 'use strict'; /* DogFather und Manager haben dieselben Rechte -- die Unterscheidung - macht nur die Personenverwaltung, und die prueft der Server. */ + macht nur die Personenverwaltung, und die prüft der Server. */ const LEITUNG = new Set(['admin', 'manager']); const $ = (id) => document.getElementById(id); - const WOCHENTAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', - 'Donnerstag', 'Freitag', 'Samstag']; - const ARTNAME = { call: 'Call', termin: 'Termin', review: 'Review', frist: 'Frist' }; - - let ich = null; - let tage = 30; - const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; @@ -24,8 +50,688 @@ return k; }; + const ARTNAME = { call: 'Call', termin: 'Termin', review: 'Review', frist: 'Frist' }; + /* Jede Art trägt ihre eigene Farbe -- dieselbe Idee wie bei den Kacheln + auf der Startseite: Die Farbe gehört zur Sache, nicht zum Rang. */ + const ARTFARBE = { + call: '#3fbdf5', + termin: '#8a76ff', + review: '#50a752', + frist: '#d17717', + }; + const FEIERTAG_FARBE = '#ff9aa2'; + const ANLASS_FARBE = '#d8a13a'; + + const WOCHENTAGE_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + + let ich = null; + let daten = null; + let ansicht = 'monat'; + let anker = heuteText(); // Datum, um das herum angezeigt wird + const zeigen = { call: true, termin: true, review: true, frist: true }; + + /* ---------- Datumsrechnung ---------------------------------------------- + Alles läuft in UTC-Mittag. Grund: Wer mit lokalen Zeiten rechnet, + verliert bei der Zeitumstellung einen Tag -- der 27. Oktober wird + zum 26., und niemand findet den Fehler, weil er nur zweimal im Jahr + auftritt. Mittag ist von beiden Umstellungen weit genug weg. */ + + const alsText = (d) => d.toISOString().slice(0, 10); + const ausText = (t) => new Date(t + 'T12:00:00Z'); + function heuteText() { + const n = new Date(); + return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}` + + `-${String(n.getDate()).padStart(2, '0')}`; + } + const plus = (d, n) => { const k = new Date(d); k.setUTCDate(k.getUTCDate() + n); return k; }; + const datum = (jahr, monat, tag) => new Date(Date.UTC(jahr, monat - 1, tag, 12)); + + /** Ostersonntag (Gauß/Meeus, gregorianisch). Alle beweglichen + * kirchlichen Feiertage hängen daran, deshalb steht diese eine Formel + * am Anfang. */ + function ostersonntag(jahr) { + const a = jahr % 19, b = Math.floor(jahr / 100), c = jahr % 100; + const d = Math.floor(b / 4), e = b % 4; + const f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4), k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const monat = Math.floor((h + l - 7 * m + 114) / 31); + const tag = ((h + l - 7 * m + 114) % 31) + 1; + return new Date(Date.UTC(jahr, monat - 1, tag, 12)); + } + + /** n-ter bestimmter Wochentag eines Monats. wochentag: 0 = Sonntag. */ + function nterWochentag(jahr, monat, wochentag, n) { + const d = datum(jahr, monat, 1); + const versatz = (wochentag - d.getUTCDay() + 7) % 7; + return plus(d, versatz + (n - 1) * 7); + } + + /** ISO-8601-Kalenderwoche: Woche 1 ist die mit dem ersten Donnerstag. */ + function kalenderwoche(d) { + const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + t.setUTCDate(t.getUTCDate() - ((t.getUTCDay() + 6) % 7) + 3); + const ersterDo = new Date(Date.UTC(t.getUTCFullYear(), 0, 4)); + ersterDo.setUTCDate(ersterDo.getUTCDate() - ((ersterDo.getUTCDay() + 6) % 7) + 3); + return 1 + Math.round((t - ersterDo) / (7 * 86400000)); + } + + /** Montag der Woche, in der dieses Datum liegt. */ + function montagVon(d) { + const k = new Date(d); + k.setUTCDate(k.getUTCDate() - ((k.getUTCDay() + 6) % 7)); + return k; + } + + const fmt = (o) => new Intl.DateTimeFormat('de-DE', { timeZone: 'UTC', ...o }); + + /* ---------- Anlässe ------------------------------------------------------ */ + + const anlassSpeicher = new Map(); + function anlaesse(jahr) { + if (anlassSpeicher.has(jahr)) return anlassSpeicher.get(jahr); + const o = ostersonntag(jahr); + const roh = [ + /* Gesetzliche Feiertage, BUNDESWEIT. Nur die neun, die in allen + Ländern gelten -- siehe Begründung ganz oben. */ + { d: datum(jahr, 1, 1), n: 'Neujahr', art: 'feiertag', sym: '🎆' }, + { d: plus(o, -2), n: 'Karfreitag', art: 'feiertag', sym: '✝' }, + { d: plus(o, 1), n: 'Ostermontag', art: 'feiertag', sym: '🐣' }, + { d: datum(jahr, 5, 1), n: 'Tag der Arbeit', art: 'feiertag', sym: '🛠' }, + { d: plus(o, 39), n: 'Christi Himmelfahrt', art: 'feiertag', sym: '☁' }, + { d: plus(o, 50), n: 'Pfingstmontag', art: 'feiertag', sym: '🕊' }, + { d: datum(jahr, 10, 3), n: 'Tag der Deutschen Einheit', art: 'feiertag', sym: '🇩🇪' }, + { d: datum(jahr, 12, 25), n: '1. Weihnachtstag', art: 'feiertag', sym: '🎄' }, + { d: datum(jahr, 12, 26), n: '2. Weihnachtstag', art: 'feiertag', sym: '🎄' }, + + /* Anlässe, an denen das Publikum wach ist. Für Creator zählt nicht + der Umsatz eines Ladens, sondern die Aufmerksamkeit: Wann sind + Leute online, wann lohnt sich ein besonderer Stream, wann ein + Themenvideo. */ + { d: datum(jahr, 2, 14), n: 'Valentinstag', art: 'anlass', sym: '💗' }, + { d: plus(o, -48), n: 'Rosenmontag · Karneval', art: 'anlass', sym: '🎭' }, + { d: o, n: 'Ostersonntag', art: 'anlass', sym: '🐰' }, + { d: nterWochentag(jahr, 5, 0, 2), n: 'Muttertag', art: 'anlass', sym: '💐' }, + { d: plus(o, 39), n: 'Vatertag', art: 'anlass', sym: '🍺' }, + { d: datum(jahr, 10, 31), n: 'Halloween', art: 'anlass', sym: '🎃' }, + { d: datum(jahr, 11, 11), n: 'Martinstag', art: 'anlass', sym: '🏮' }, + /* Black Friday: der Freitag nach dem vierten Donnerstag im + November -- nicht "der letzte Freitag", das ist in manchen + Jahren ein anderer Tag. */ + { d: plus(nterWochentag(jahr, 11, 4, 4), 1), n: 'Black Friday', art: 'anlass', sym: '🏷' }, + { d: plus(nterWochentag(jahr, 11, 4, 4), 4), n: 'Cyber Monday', art: 'anlass', sym: '🏷' }, + { d: datum(jahr, 12, 6), n: 'Nikolaus', art: 'anlass', sym: '🥾' }, + { d: datum(jahr, 12, 24), n: 'Heiligabend', art: 'anlass', sym: '🎁' }, + { d: datum(jahr, 12, 31), n: 'Silvester', art: 'anlass', sym: '🎇' }, + ]; + + /* Die vier Advente: rückwärts vom vierten, dem letzten Sonntag vor + dem 25.12. Rückwärts gerechnet, weil "der vierte" der definierte + ist -- vorwärts zu zählen wäre in Jahren, in denen der 24.12. + selbst ein Sonntag ist, um eine Woche daneben. */ + const w = datum(jahr, 12, 25); + const vierter = plus(w, -(w.getUTCDay() === 0 ? 7 : w.getUTCDay())); + for (let i = 0; i < 4; i++) { + roh.push({ d: plus(vierter, -7 * (3 - i)), n: `${i + 1}. Advent`, art: 'anlass', sym: '🕯' }); + } + + const karte = new Map(); + for (const a of roh) { + const t = alsText(a.d); + if (!karte.has(t)) karte.set(t, []); + karte.get(t).push({ name: a.n, art: a.art, sym: a.sym, tag: t }); + } + anlassSpeicher.set(jahr, karte); + return karte; + } + + const anlaesseAm = (t) => anlaesse(Number(t.slice(0, 4))).get(t) || []; + const istFeiertag = (t) => anlaesseAm(t).some((a) => a.art === 'feiertag'); + + /** Alle Anlässe in einem Zeitraum, nach Datum sortiert. Läuft über + * Jahresgrenzen -- ein Dezember-Zeitraum reicht in den Januar. */ + function anlaesseIn(von, bis) { + const raus = []; + for (let j = Number(von.slice(0, 4)); j <= Number(bis.slice(0, 4)); j++) { + for (const [tag, liste] of anlaesse(j)) if (tag >= von && tag <= bis) raus.push(...liste); + } + return raus.sort((x, y) => x.tag.localeCompare(y.tag)); + } + + /* ---------- Zeitraum ------------------------------------------------------ */ + + function zeitraum() { + const a = ausText(anker); + if (ansicht === 'woche') { + const mo = montagVon(a); + return { von: alsText(mo), bis: alsText(plus(mo, 6)) }; + } + if (ansicht === 'liste' || ansicht === 'zeitstrahl') { + /* Arbeitsansichten zeigen einen rollenden Zeitraum ab dem Anker, + nicht einen Kalendermonat: Beim Arbeiten interessiert "was kommt + als Nächstes", nicht "was steht im September". */ + return { von: alsText(a), bis: alsText(plus(a, 41)) }; + } + /* Monat: ganze Wochen, damit das Raster aufgeht. */ + const erster = new Date(Date.UTC(a.getUTCFullYear(), a.getUTCMonth(), 1, 12)); + const letzter = new Date(Date.UTC(a.getUTCFullYear(), a.getUTCMonth() + 1, 0, 12)); + return { von: alsText(montagVon(erster)), bis: alsText(plus(montagVon(letzter), 6)) }; + } + + function zeitraumText() { + const a = ausText(anker); + if (ansicht === 'monat') return fmt({ month: 'long', year: 'numeric' }).format(a); + const { von, bis } = zeitraum(); + const f = (s) => fmt({ day: '2-digit', month: 'short' }).format(ausText(s)); + return `${f(von)} – ${f(bis)}`; + } + + function unterText() { + const { von, bis } = zeitraum(); + const kvon = kalenderwoche(ausText(von)), kbis = kalenderwoche(ausText(bis)); + const n = sichtbare().length; + const kw = kvon === kbis ? `KW ${kvon}` : `KW ${kvon} – ${kbis}`; + return `${kw} · ${n} ${n === 1 ? 'Eintrag' : 'Einträge'}`; + } + + function springen(richtung) { + const a = ausText(anker); + if (ansicht === 'monat') a.setUTCMonth(a.getUTCMonth() + richtung); + else if (ansicht === 'woche') a.setUTCDate(a.getUTCDate() + 7 * richtung); + else a.setUTCDate(a.getUTCDate() + 42 * richtung); + anker = alsText(a); + laden(); + } + + /* ---------- Die Einträge auf eine Achse bringen -------------------------- + + Termine und Fristen kommen aus zwei Tabellen, sehen aber im Kalender + gleich aus. Sie werden deshalb EINMAL in dieselbe Form gebracht -- + jede Ansicht arbeitet danach nur noch mit dieser einen Liste und + kann gar nicht mehr etwas anderes zeigen als die andere. */ + + function vereinheitlichen(roh) { + const raus = []; + for (const t of roh.termine || []) { + raus.push({ + art: t.art, tag: t.beginn.slice(0, 10), uhr: t.beginn.slice(11, 16), + titel: t.titel, erledigt: !!t.erledigt, id: t.id, + farbe: ARTFARBE[t.art] || ARTFARBE.termin, + zusatz: [t.dauer_min ? t.dauer_min + ' Min' : null, + t.teilnehmer_name ? 'mit ' + t.teilnehmer_name : null, + t.ort || null].filter(Boolean).join(' · '), + ort: t.ort, beschreibung: t.beschreibung, + creator_name: t.creator_name, teilnehmer_name: t.teilnehmer_name, + erstellt_von: t.erstellt_von, dauer_min: t.dauer_min, + }); + } + for (const f of roh.fristen || []) { + raus.push({ + art: 'frist', tag: f.frist, uhr: null, + titel: f.titel, erledigt: false, id: 'a' + f.id, + farbe: ARTFARBE.frist, + zusatz: ['Priorität ' + f.prioritaet, + f.verantwortlich_name || null].filter(Boolean).join(' · '), + ziel: 'aufgaben.html', + }); + } + /* Erst nach Tag, dann nach Uhrzeit. Fristen haben keine Uhrzeit und + stehen deshalb ans Ende des Tages -- sie blockieren keinen Slot. */ + return raus.sort((a, b) => + a.tag.localeCompare(b.tag) || (a.uhr || '99:99').localeCompare(b.uhr || '99:99')); + } + + const sichtbare = () => (daten ? daten.eintraege.filter((e) => zeigen[e.art]) : []); + + function nachTag() { + const karte = new Map(); + for (const e of sichtbare()) { + if (!karte.has(e.tag)) karte.set(e.tag, []); + karte.get(e.tag).push(e); + } + return karte; + } + + /** Alle Tage des Zeitraums, an denen etwas steht -- eigene Einträge + * ODER ein Anlass. Ohne die Anlässe wäre der Fehler teuer: Liste und + * Zeitstrahl hätten "nichts an" gemeldet, während in derselben Woche + * Halloween lag. Genau die Ansicht, in der man plant, hätte den + * Anlass verschwiegen. */ + function tageMitInhalt(karte) { + const { von, bis } = zeitraum(); + const tage = new Set(karte.keys()); + for (const a of anlaesseIn(von, bis)) tage.add(a.tag); + return [...tage].filter((t) => t >= von && t <= bis).sort(); + } + + /* ---------- Bausteine ----------------------------------------------------- */ + + function pille(e) { + const k = el(e.ziel ? 'a' : 'div', 'k-pille' + (e.erledigt ? ' erledigt' : '')); + if (e.ziel) k.href = e.ziel; + k.style.setProperty('--pfarbe', e.farbe); + k.title = `${e.titel}${e.zusatz ? ' — ' + e.zusatz : ''}`; + if (e.uhr) k.append(el('span', 'k-pille__zeit', e.uhr)); + k.append(document.createTextNode(e.titel)); + return k; + } + + function anlassMarke(a) { + const k = el('span', 'k-anlass' + (a.art === 'feiertag' ? ' feiertag' : ''), + a.sym + ' ' + a.name); + k.style.setProperty('--afarbe', a.art === 'feiertag' ? FEIERTAG_FARBE : ANLASS_FARBE); + k.title = a.art === 'feiertag' + ? a.name + ' — gesetzlicher Feiertag (bundesweit).' + : a.name + ' — Anlass, an dem das Publikum wach ist.'; + return k; + } + + /** Macht eine Tagesfläche anklickbar -- mit Maus UND Tastatur. Ein + * Klick auf eine Pille darin führt weiterhin zum Eintrag; nur der + * leere Teil öffnet den Tag. Sonst käme man an die verlinkten + * Einträge nicht mehr heran. */ + function tagKlickbar(k, tag) { + k.dataset.tag = tag; + k.tabIndex = 0; + k.setAttribute('role', 'button'); + k.setAttribute('aria-label', + fmt({ weekday: 'long', day: 'numeric', month: 'long' }).format(ausText(tag)) + + ' — öffnen'); + k.addEventListener('click', (ev) => { + if (ev.target.closest('a')) return; + tagOeffnen(tag); + }); + k.addEventListener('keydown', (ev) => { + if (ev.target !== k) return; + if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); tagOeffnen(tag); } + }); + } + + /* ---------- Anlasskarten ------------------------------------------------- + EINE Bauanleitung für beide Reihen. Zwei getrennte hätten früher oder + später unterschiedlich ausgesehen, weil eine Änderung nur an einer + Stelle nachgezogen wird. */ + + function anlassKarte(a) { + const tage = Math.round((ausText(a.tag) - ausText(daten.heute)) / 86400000); + const k = el('div', 'k-anlasskarte' + + (a.art === 'feiertag' ? ' feiertag' : '') + (tage < 0 ? ' vorbei' : '')); + k.style.setProperty('--afarbe', a.art === 'feiertag' ? FEIERTAG_FARBE : ANLASS_FARBE); + k.append(el('span', 'k-anlasskarte__sym', a.sym)); + + const txt = el('div', 'k-anlasskarte__txt'); + txt.append(el('span', 'k-anlasskarte__name', a.name)); + txt.append(el('span', 'k-anlasskarte__wann', + fmt({ weekday: 'short', day: '2-digit', month: 'long' }).format(ausText(a.tag)))); + k.append(txt); + + const rest = el('div', 'k-anlasskarte__rest', + tage < 0 ? 'vorbei' : tage === 0 ? 'heute' : tage === 1 ? 'morgen' : `in ${tage} Tagen`); + /* Drei Wochen sind die Faustregel für Vorlauf: Idee, drehen, + schneiden, hochladen. Wer erst in der Woche davor anfängt, + veröffentlicht hinterher. */ + if (a.art === 'anlass' && tage > 0 && tage <= 21) { + rest.append(el('small', null, 'Vorlauf läuft')); + } + k.append(rest); + + k.tabIndex = 0; + k.setAttribute('role', 'button'); + k.setAttribute('aria-label', a.name + ' — zum Tag springen'); + k.addEventListener('click', () => zumTag(a.tag)); + k.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); zumTag(a.tag); } + }); + return k; + } + + /* Reihe 1: was als Nächstes kommt. Ab HEUTE, nicht ab dem angezeigten + Monat -- sie beantwortet "was kommt auf mich zu", und das ändert + sich nicht dadurch, dass man weiterblättert. */ + function baueSaison() { + const ziel = $('saison'); + ziel.replaceChildren(); + const jahr = Number(daten.heute.slice(0, 4)); + const alle = []; + for (const j of [jahr, jahr + 1]) for (const l of anlaesse(j).values()) alle.push(...l); + const kommend = alle.filter((a) => a.tag >= daten.heute) + .sort((a, b) => a.tag.localeCompare(b.tag)).slice(0, 3); + kommend.forEach((a) => ziel.append(anlassKarte(a))); + $('saison-block').hidden = !kommend.length; + } + + /* Reihe 2: die Anlässe des angezeigten Zeitraums. Die Reihe darüber + blickt nach vorn; diese beschreibt, was man gerade ansieht -- sonst + müsste man ins Raster hinunterscrollen und die Tage absuchen, nur um + zu wissen, ob überhaupt etwas ansteht. */ + function baueMonatsanlaesse() { + const ziel = $('monatsanlaesse'); + const titel = $('monat-titel'); + ziel.replaceChildren(); + + let von, bis; + if (ansicht === 'monat') { + /* Der KALENDERMONAT, nicht der angezeigte Sechs-Wochen-Block -- + sonst stünden hier Anlässe aus dem Nachbarmonat, die nur + zufällig mit im Bild sind. */ + const a = ausText(anker); + von = alsText(new Date(Date.UTC(a.getUTCFullYear(), a.getUTCMonth(), 1, 12))); + bis = alsText(new Date(Date.UTC(a.getUTCFullYear(), a.getUTCMonth() + 1, 0, 12))); + titel.textContent = 'Im ' + fmt({ month: 'long', year: 'numeric' }).format(a); + } else { + ({ von, bis } = zeitraum()); + const f = (s) => fmt({ day: '2-digit', month: 'short' }).format(ausText(s)); + titel.textContent = `Von ${f(von)} bis ${f(bis)}`; + } + + const liste = anlaesseIn(von, bis); + if (!liste.length) { + ziel.append(el('p', 'k-anlassleer', 'In diesem Zeitraum steht kein besonderer Anlass an.')); + } else { + liste.forEach((a) => ziel.append(anlassKarte(a))); + } + $('monat-block').hidden = false; + } + + /* ---------- Filter -------------------------------------------------------- */ + + function baueFilter() { + const ziel = $('filter'); + ziel.replaceChildren(); + for (const art of ['call', 'termin', 'review', 'frist']) { + const n = daten.eintraege.filter((e) => e.art === art).length; + const b = el('button', 'k-chip'); + b.type = 'button'; + b.setAttribute('aria-pressed', String(zeigen[art])); + b.style.setProperty('--cfarbe', ARTFARBE[art]); + b.append(el('span', 'k-chip__punkt')); + b.append(document.createTextNode(`${ARTNAME[art]} (${n})`)); + b.addEventListener('click', () => { zeigen[art] = !zeigen[art]; zeichnen(); }); + ziel.append(b); + } + } + + /* ---------- Ansicht: Monat ------------------------------------------------ */ + + function baueMonat(karte) { + const { von, bis } = zeitraum(); + const monat = ausText(anker).getUTCMonth(); + const raster = el('div', 'k-raster'); + + raster.append(el('div', 'k-spaltenkopf k-spaltenkopf--kw', 'KW')); + for (const w of WOCHENTAGE_KURZ) raster.append(el('div', 'k-spaltenkopf', w)); + + let d = ausText(von); + const ende = ausText(bis); + while (d <= ende) { + const kw = el('div', 'k-kw', String(kalenderwoche(d))); + if (alsText(d) <= daten.heute && alsText(plus(d, 6)) >= daten.heute) kw.dataset.jetzt = 'ja'; + raster.append(kw); + + for (let i = 0; i < 7; i++) { + const tag = alsText(d); + const zelle = el('div', 'k-tag'); + if (d.getUTCMonth() !== monat) zelle.dataset.fremd = 'ja'; + if (tag === daten.heute) zelle.dataset.heute = 'ja'; + if (istFeiertag(tag)) zelle.dataset.feiertag = 'ja'; + if (d.getUTCDay() === 0 || d.getUTCDay() === 6) zelle.dataset.wochenende = 'ja'; + + const kopf = el('div', 'k-tag__kopf'); + kopf.append(el('span', 'k-tag__zahl', String(d.getUTCDate()))); + if (d.getUTCDate() === 1) { + kopf.append(el('span', 'k-tag__monat', fmt({ month: 'short' }).format(d))); + } + if (tag === daten.heute) kopf.append(el('span', 'k-tag__heute', 'heute')); + zelle.append(kopf); + + for (const a of anlaesseAm(tag)) zelle.append(anlassMarke(a)); + for (const e of (karte.get(tag) || [])) zelle.append(pille(e)); + + tagKlickbar(zelle, tag); + raster.append(zelle); + d = plus(d, 1); + } + } + return raster; + } + + /* ---------- Ansicht: Woche ------------------------------------------------ */ + + function baueWoche(karte) { + const { von } = zeitraum(); + const raster = el('div', 'k-woche'); + let d = ausText(von); + for (let i = 0; i < 7; i++) { + const tag = alsText(d); + const spalte = el('div', 'k-wochentag'); + if (tag === daten.heute) spalte.dataset.heute = 'ja'; + if (istFeiertag(tag)) spalte.dataset.feiertag = 'ja'; + + const kopf = el('div', 'k-wochentag__kopf'); + kopf.append(el('span', 'k-wochentag__name', WOCHENTAGE_KURZ[i])); + kopf.append(el('span', 'k-wochentag__zahl', String(d.getUTCDate()))); + spalte.append(kopf); + + for (const a of anlaesseAm(tag)) spalte.append(anlassMarke(a)); + const eintraege = karte.get(tag) || []; + for (const e of eintraege) spalte.append(pille(e)); + if (!eintraege.length && !anlaesseAm(tag).length) { + spalte.append(el('p', 'k-wochentag__leer', 'frei')); + } + tagKlickbar(spalte, tag); + raster.append(spalte); + d = plus(d, 1); + } + return raster; + } + + /* ---------- Ansicht: Liste ------------------------------------------------ */ + + function baueListe(karte) { + const wurzel = el('div', 'k-liste'); + const tage = tageMitInhalt(karte); + if (!tage.length) { + wurzel.append(leerHinweis()); + return wurzel; + } + for (const tag of tage) { + const block = el('section', 'k-listentag'); + if (tag < daten.heute) block.dataset.vergangen = 'ja'; + if (tag === daten.heute) block.dataset.heute = 'ja'; + + const kopf = el('div', 'k-listentag__kopf'); + kopf.append(el('span', 'k-listentag__datum', + fmt({ weekday: 'long', day: '2-digit', month: 'long' }).format(ausText(tag)))); + kopf.append(el('span', 'k-listentag__kw', 'KW ' + kalenderwoche(ausText(tag)))); + if (tag === daten.heute) kopf.append(el('span', 'k-listentag__marke', 'Heute')); + block.append(kopf); + + for (const a of anlaesseAm(tag)) block.append(anlassMarke(a)); + for (const e of (karte.get(tag) || [])) block.append(zeile(e)); + wurzel.append(block); + } + return wurzel; + } + + /** Eine ausführliche Zeile für Liste und Tagesdialog -- mit den + * Knöpfen, die es vorher schon gab (erledigt, löschen). */ + function zeile(e) { + const k = el('div', 'k-zeile'); + k.dataset.art = e.art; + k.dataset.erledigt = e.erledigt ? 'ja' : 'nein'; + k.style.setProperty('--zfarbe', e.farbe); + + k.append(el('span', 'k-zeile__zeit', e.uhr ? e.uhr + ' Uhr' : '—')); + k.append(el('span', 'k-zeile__titel', e.titel)); + + const rechts = el('div', 'k-zeile__knoepfe'); + rechts.append(el('span', 'k-zeile__art', ARTNAME[e.art] || e.art)); + + if (e.art === 'frist') { + const a = el('a', 'schritt', 'zur Aufgabe'); + a.href = 'aufgaben.html'; + rechts.append(a); + } else { + rechts.append(tuKnopf(e.erledigt ? 'wieder offen' : 'erledigt', async () => { + const a = await hole('/workspace/api/termine/' + e.id, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ erledigt: !e.erledigt }), + }); + if (!a.ok) { melde('Ändern ging nicht.'); return; } + await laden(); + })); + if (LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id) { + rechts.append(tuKnopf('löschen', async () => { + if (!confirm('Termin „' + e.titel + '" löschen?')) return; + const a = await hole('/workspace/api/termine/' + e.id, { method: 'DELETE' }); + if (!a.ok) { melde('Löschen ging nicht.'); return; } + await laden(); + })); + } + } + k.append(rechts); + + if (e.zusatz || e.beschreibung) { + const z = el('div', 'k-zeile__zusatz'); + if (e.ort && /^https?:\/\//i.test(e.ort)) { + /* Nur echte http(s)-Adressen werden zum Verweis -- sonst ließe + sich hier javascript: einschleusen. */ + const teile = e.zusatz.split(' · ').filter((s) => s !== e.ort); + if (teile.length) z.append(el('span', null, teile.join(' · '))); + const a = el('a', null, e.ort.replace(/^https?:\/\//i, '').slice(0, 60)); + a.href = e.ort; a.target = '_blank'; a.rel = 'noopener noreferrer'; + z.append(a); + } else if (e.zusatz) { + z.append(el('span', null, e.zusatz)); + } + if (e.beschreibung) z.append(el('span', null, e.beschreibung)); + k.append(z); + } + return k; + } + + /* ---------- Ansicht: Zeitstrahl ------------------------------------------ + Sechs Wochen auf einer Achse. Zeigt auf einen Blick, wo sich etwas + ballt und wo Luft ist -- die Frage "wann habe ich Vorlauf" lässt + sich in einem Monatsraster nicht beantworten. */ + + function baueZeitstrahl(karte) { + const { von, bis } = zeitraum(); + const wurzel = el('div', 'k-strahl'); + const tage = Math.round((ausText(bis) - ausText(von)) / 86400000) + 1; + + let d = ausText(von); + for (let i = 0; i < tage; i++) { + const tag = alsText(d); + const eintraege = karte.get(tag) || []; + const anl = anlaesseAm(tag); + + const balken = el('div', 'k-strahl__tag'); + if (tag === daten.heute) balken.dataset.heute = 'ja'; + if (istFeiertag(tag)) balken.dataset.feiertag = 'ja'; + if (d.getUTCDay() === 0 || d.getUTCDay() === 6) balken.dataset.wochenende = 'ja'; + /* Die Höhe des Balkens ist die Menge des Tages. Gedeckelt bei + fünf: Ein Tag mit zwölf Terminen soll nicht die ganze Grafik + sprengen, und ab fünf ist die Aussage ohnehin "voll". */ + balken.style.setProperty('--menge', String(Math.min(eintraege.length, 5))); + balken.title = `${fmt({ weekday: 'long', day: '2-digit', month: 'long' }).format(d)}` + + ` — ${eintraege.length} ${eintraege.length === 1 ? 'Eintrag' : 'Einträge'}` + + (anl.length ? ' · ' + anl.map((a) => a.name).join(', ') : ''); + + const saeule = el('span', 'k-strahl__saeule'); + if (eintraege.length) { + /* Die Farbe des Balkens ist die des ersten Eintrags an dem Tag -- + man erkennt Call-Wochen und Review-Wochen von weitem. */ + saeule.style.setProperty('--sfarbe', eintraege[0].farbe); + } + balken.append(saeule); + if (anl.length) { + balken.append(el('span', 'k-strahl__sym', anl[0].sym)); + } + if (d.getUTCDay() === 1) { + balken.append(el('span', 'k-strahl__kw', String(kalenderwoche(d)))); + } + tagKlickbar(balken, tag); + wurzel.append(balken); + d = plus(d, 1); + } + + const rahmen = el('div', 'k-strahl-rahmen'); + rahmen.append(wurzel); + rahmen.append(el('p', 'k-strahl__fuss', + 'Ein Strich je Tag, die Höhe ist die Menge. Die Zahl unten ist die Kalenderwoche.')); + return rahmen; + } + + function leerHinweis() { + const k = el('div', 'k-leer'); + k.append(el('p', 'k-leer__satz', 'In diesem Zeitraum steht nichts an.')); + k.append(el('p', 'k-leer__tipp', + 'Auf einen Tag klicken, um ihn zu öffnen – oder oben rechts einen Termin anlegen.')); + return k; + } + + /* ---------- Tagesdialog --------------------------------------------------- */ + + function tagOeffnen(tag) { + const d = ausText(tag); + $('tagdialog-titel').textContent = + fmt({ weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' }).format(d); + $('tagdialog-unter').textContent = 'KW ' + kalenderwoche(d) + + (tag === daten.heute ? ' · heute' : ''); + + const anl = $('tagdialog-anlaesse'); + anl.replaceChildren(); + for (const a of anlaesseAm(tag)) anl.append(anlassMarke(a)); + + const liste = $('tagdialog-liste'); + liste.replaceChildren(); + const eintraege = sichtbare().filter((e) => e.tag === tag); + if (!eintraege.length) { + liste.append(el('p', 'leise', 'An diesem Tag steht nichts.')); + } else { + for (const e of eintraege) liste.append(zeile(e)); + } + + $('tagdialog-neu').onclick = () => { + $('tagdialog').close(); + formularOeffnen(tag); + }; + $('tagdialog').showModal(); + } + + function zumTag(tag) { + /* Zum Tag springen heißt: den Monat anzeigen, in dem er liegt, und + ihn dann öffnen. Sonst müsste man erst selbst dorthin blättern. */ + anker = tag; + laden().then(() => tagOeffnen(tag)); + } + + /* ---------- Zeichnen ------------------------------------------------------ */ + + function zeichnen() { + const ziel = $('kalender'); + ziel.replaceChildren(); + ziel.setAttribute('aria-busy', 'false'); + ziel.dataset.ansicht = ansicht; + + $('zeitraum').textContent = zeitraumText(); + $('zeitraum-unter').textContent = unterText(); + baueFilter(); + + const karte = nachTag(); + if (ansicht === 'monat') ziel.append(baueMonat(karte)); + else if (ansicht === 'woche') ziel.append(baueWoche(karte)); + else if (ansicht === 'zeitstrahl') ziel.append(baueZeitstrahl(karte)); + else ziel.append(baueListe(karte)); + } + + /* ---------- Laden --------------------------------------------------------- */ + const melde = (t) => { $('fehler').textContent = t || ''; }; - const heute = new Date().toISOString().slice(0, 10); async function hole(pfad, optionen = {}) { const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); @@ -33,173 +739,85 @@ return a; } - function datumHuebsch(iso) { - const [j, m, t] = iso.slice(0, 10).split('-'); - return `${t}.${m}.${j}`; - } - - function wochentag(iso) { - return WOCHENTAGE[new Date(iso.slice(0, 10) + 'T12:00:00').getDay()]; - } - - /* ---------- Zeichnen --------------------------------------------------- */ - - function eintragTermin(t) { - const k = el('div', 'eintrag'); - k.dataset.art = t.art; - k.dataset.erledigt = t.erledigt ? 'ja' : 'nein'; - - k.append(el('span', 'eintrag__zeit', t.beginn.slice(11, 16) + ' Uhr')); - k.append(el('span', 'eintrag__titel', t.titel)); - - const knoepfe = el('div', 'eintrag__knoepfe'); - knoepfe.append(el('span', 'eintrag__art', ARTNAME[t.art] || t.art)); - knoepfe.append(tuKnopf(t.erledigt ? 'wieder offen' : 'erledigt', async () => { - const a = await hole('/workspace/api/termine/' + t.id, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ erledigt: !t.erledigt }), - }); - if (!a.ok) { melde('Ändern ging nicht.'); return; } - await laden(); - })); - if (LEITUNG.has(ich.rolle) || t.erstellt_von === ich.id) { - knoepfe.append(tuKnopf('löschen', async () => { - if (!confirm('Termin "' + t.titel + '" löschen?')) return; - const a = await hole('/workspace/api/termine/' + t.id, { method: 'DELETE' }); - if (!a.ok) { melde('Löschen ging nicht.'); return; } - await laden(); - })); - } - k.append(knoepfe); - - const zusatz = el('div', 'eintrag__zusatz'); - if (t.dauer_min) zusatz.append(el('span', null, t.dauer_min + ' Min')); - if (t.teilnehmer_name) zusatz.append(el('span', null, '· mit ' + t.teilnehmer_name)); - if (LEITUNG.has(ich.rolle) && t.creator_name && t.creator_name !== t.teilnehmer_name) { - zusatz.append(el('span', null, '· Bereich ' + t.creator_name)); - } - if (t.ort) { - /* Links sollen klickbar sein, aber nur echte http(s)-Adressen -- - sonst liesse sich hier javascript: einschleusen. */ - if (/^https?:\/\//i.test(t.ort)) { - const a = el('a', null, t.ort.replace(/^https?:\/\//i, '').slice(0, 60)); - a.href = t.ort; a.target = '_blank'; a.rel = 'noopener noreferrer'; - zusatz.append(a); - } else { - zusatz.append(el('span', null, '· ' + t.ort)); - } - } - if (t.beschreibung) zusatz.append(el('span', null, '· ' + t.beschreibung)); - if (zusatz.childElementCount) k.append(zusatz); - return k; - } - - function eintragFrist(f) { - const k = el('div', 'eintrag'); - k.dataset.art = 'frist'; - k.append(el('span', 'eintrag__zeit', '—')); - k.append(el('span', 'eintrag__titel', f.titel)); - const knoepfe = el('div', 'eintrag__knoepfe'); - knoepfe.append(el('span', 'eintrag__art', 'Frist')); - const link = el('a', 'schritt', 'zur Aufgabe'); - link.href = 'aufgaben.html'; - knoepfe.append(link); - k.append(knoepfe); - const zusatz = el('div', 'eintrag__zusatz'); - zusatz.append(el('span', null, 'Priorität ' + f.prioritaet)); - if (f.verantwortlich_name) zusatz.append(el('span', null, '· ' + f.verantwortlich_name)); - k.append(zusatz); - return k; - } - function tuKnopf(text, tu) { const b = el('button', 'schritt', text); b.type = 'button'; - b.addEventListener('click', async () => { + b.addEventListener('click', async (ev) => { + ev.stopPropagation(); b.disabled = true; melde(''); try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; } }); return b; } - function zeichne(daten) { - const ziel = $('liste'); - ziel.textContent = ''; - ziel.setAttribute('aria-busy', 'false'); - - /* Termine und Fristen nach Tag zusammenlegen. */ - const nachTag = new Map(); - for (const t of daten.termine) { - const tag = t.beginn.slice(0, 10); - if (!nachTag.has(tag)) nachTag.set(tag, { termine: [], fristen: [] }); - nachTag.get(tag).termine.push(t); - } - for (const f of daten.fristen) { - if (!nachTag.has(f.frist)) nachTag.set(f.frist, { termine: [], fristen: [] }); - nachTag.get(f.frist).fristen.push(f); - } - - if (!nachTag.size) { - ziel.append(el('p', 'leer-hinweis', - 'Für diesen Zeitraum ist nichts eingetragen.')); - return; - } - - for (const tag of [...nachTag.keys()].sort()) { - const block = el('section', 'tag'); - if (tag < heute) block.dataset.vergangen = 'ja'; - - const kopf = el('div', 'tag__kopf'); - kopf.append(el('span', 'tag__datum', datumHuebsch(tag))); - kopf.append(el('span', 'tag__wochentag', wochentag(tag))); - if (tag === heute) kopf.append(el('span', 'tag__marke', 'Heute')); - block.append(kopf); - - const { termine, fristen } = nachTag.get(tag); - for (const t of termine) block.append(eintragTermin(t)); - for (const f of fristen) block.append(eintragFrist(f)); - ziel.append(block); - } - } - async function laden() { melde(''); + const { von, bis } = zeitraum(); + const tage = Math.round((ausText(bis) - ausText(von)) / 86400000) + 1; try { - const a = await hole('/workspace/api/termine?tage=' + tage); + const a = await hole(`/workspace/api/termine?von=${von}&tage=${tage}`); if (!a.ok) { melde('Kalender konnte nicht geladen werden.'); return; } - zeichne(await a.json()); + const roh = await a.json(); + daten = { eintraege: vereinheitlichen(roh), heute: heuteText() }; + baueSaison(); + baueMonatsanlaesse(); + zeichnen(); } catch { /* umgeleitet */ } } - /* ---------- Zeitraum ---------------------------------------------------- */ + /* ---------- Bedienung ----------------------------------------------------- */ - for (const b of document.querySelectorAll('.zeitraum .schritt')) { - b.setAttribute('aria-pressed', String(Number(b.dataset.tage) === tage)); - b.addEventListener('click', async () => { - tage = Number(b.dataset.tage); - for (const x of document.querySelectorAll('.zeitraum .schritt')) { + $('zurueck-zeit').addEventListener('click', () => springen(-1)); + $('vor-zeit').addEventListener('click', () => springen(1)); + $('heute-knopf').addEventListener('click', () => { anker = heuteText(); laden(); }); + + for (const b of document.querySelectorAll('.k-ansicht')) { + b.addEventListener('click', () => { + ansicht = b.dataset.ansicht; + for (const x of document.querySelectorAll('.k-ansicht')) { x.setAttribute('aria-pressed', String(x === b)); } - await laden(); + laden(); }); } - /* ---------- Neuer Termin ------------------------------------------------ */ + $('tagdialog-zu').addEventListener('click', () => $('tagdialog').close()); + /* Klick auf den Rand schließt -- erwartet man bei einem Dialog. */ + $('tagdialog').addEventListener('mousedown', (ev) => { + if (ev.target === $('tagdialog')) $('tagdialog').close(); + }); - const umschalten = (auf) => { - $('neu').hidden = !auf; $('neu-oeffnen').hidden = auf; - if (auf) $('f-titel').focus(); - }; - $('neu-oeffnen').addEventListener('click', () => { - /* Vorbelegung: morgen 18:00 -- ein realistischer Zeitpunkt für einen - Call, spart in der Regel zwei Klicks. */ - const m = new Date(Date.now() + 86400_000); - m.setHours(18, 0, 0, 0); - $('f-beginn').value = new Date(m.getTime() - m.getTimezoneOffset() * 60000) + /* Pfeiltasten blättern, solange man nicht gerade in einem Feld tippt. */ + document.addEventListener('keydown', (ev) => { + if (ev.ctrlKey || ev.metaKey || ev.altKey) return; + if ($('tagdialog').open) return; + const z = document.activeElement; + if (z && (z.tagName === 'INPUT' || z.tagName === 'TEXTAREA' || z.tagName === 'SELECT' + || z.isContentEditable)) return; + if (ev.key === 'ArrowLeft') { ev.preventDefault(); springen(-1); } + else if (ev.key === 'ArrowRight') { ev.preventDefault(); springen(1); } + else if (ev.key === 't' || ev.key === 'T') { anker = heuteText(); laden(); } + }); + + /* ---------- Neuer Termin --------------------------------------------------- */ + + function formularOeffnen(tag) { + /* Vorbelegung: der angeklickte Tag um 18:00 -- ein realistischer + Zeitpunkt für einen Call, spart in der Regel zwei Klicks. Ohne Tag + (Knopf oben): morgen. */ + const d = tag ? new Date(tag + 'T18:00:00') : new Date(Date.now() + 86400_000); + if (!tag) d.setHours(18, 0, 0, 0); + $('f-beginn').value = new Date(d.getTime() - d.getTimezoneOffset() * 60000) .toISOString().slice(0, 16); umschalten(true); - }); + } + + const umschalten = (auf) => { + $('neu').hidden = !auf; + $('neu-oeffnen').hidden = auf; + if (auf) $('f-titel').focus(); + }; + $('neu-oeffnen').addEventListener('click', () => formularOeffnen(null)); $('neu-abbrechen').addEventListener('click', () => { $('neu').reset(); $('neu-fehler').textContent = ''; umschalten(false); }); @@ -208,7 +826,7 @@ e.preventDefault(); $('neu-fehler').textContent = ''; const knopf = $('neu-speichern'); - const daten = { + const koerper = { titel: $('f-titel').value, art: $('f-art').value, beginn: $('f-beginn').value, @@ -218,27 +836,31 @@ }; if (LEITUNG.has(ich.rolle)) { const wahl = $('f-teilnehmer').value; - daten.teilnehmer_id = wahl || null; - daten.creator_id = wahl || null; + koerper.teilnehmer_id = wahl || null; + koerper.creator_id = wahl || null; } knopf.disabled = true; try { const a = await hole('/workspace/api/termine', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(daten), + body: JSON.stringify(koerper), }); if (!a.ok) { $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; return; } $('neu').reset(); umschalten(false); + /* Zum Tag des neuen Termins springen -- sonst legt man etwas an + und sieht es nicht, weil es im nächsten Monat liegt. */ + const tag = koerper.beginn.slice(0, 10); + const { von, bis } = zeitraum(); + if (tag < von || tag > bis) anker = tag; await laden(); } catch { /* umgeleitet */ } finally { knopf.disabled = false; } }); - /* ---------- Start -------------------------------------------------------- */ - + /* ---------- Start --------------------------------------------------------- */ (async () => { try { diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 20c0e1fb..731378c9 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -139,8 +139,8 @@ - - - + + + diff --git a/workspace/automation.html b/workspace/automation.html index a2824019..55cc5288 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -132,8 +132,8 @@ - - - + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 4ef0e3fe..2791d0af 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -107,8 +107,8 @@ - - - + + + diff --git a/workspace/calls.html b/workspace/calls.html index 8dd250b2..698b9077 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -54,9 +54,9 @@ - - - - + + + + diff --git a/workspace/content.html b/workspace/content.html index d23a3d80..873c17f6 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -158,8 +158,8 @@ - - - + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index 3d4f203c..7e2c8956 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,8 +93,8 @@ - - - + + + diff --git a/workspace/index.html b/workspace/index.html index 3e22f76f..9d57750a 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 9030d1aa..94c25fe5 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -34,15 +34,37 @@ -
+
-
-
-

Termine

-

Kalender & Calls

-

Termine, Calls und Fristen aus den Aufgaben – in einer Ansicht.

+ +
+
+

…

+

- +
+ + + + +
+
+ + + + +
- - - + + + diff --git a/workspace/personen.html b/workspace/personen.html index b9f9bc71..ba41cc9d 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -121,8 +121,8 @@
- - - + + + diff --git a/workspace/profil.html b/workspace/profil.html index 4f8056ac..6ca7f621 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -132,8 +132,8 @@ - - - + + + diff --git a/workspace/report.html b/workspace/report.html index e6fffe07..b319ca35 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -91,9 +91,9 @@ - - - - + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index d182905a..8168489b 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,8 +124,8 @@ - - - + + + diff --git a/workspace/start.html b/workspace/start.html index 4635f59b..d06bcb85 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -99,8 +99,8 @@

- - - + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 33acdca3..dd61af63 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -70,9 +70,9 @@ - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 427dfce9..7fa508ce 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -7,9 +7,9 @@ - - - + + + @@ -57,7 +57,7 @@ - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 31bb3d55..106e4b0d 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,8 +175,8 @@ - - - + + +