diff --git a/server/pruef-tagesruf.mjs b/server/pruef-tagesruf.mjs new file mode 100644 index 00000000..ec80fa9d --- /dev/null +++ b/server/pruef-tagesruf.mjs @@ -0,0 +1,314 @@ +/* ===================================================================== + PRUEFUNG: Der Tagesruf -- der Wecker fuer offene Aufgaben (screen10) + + Wunsch Filipe (08.09.2026): "ich will das neben diesem kreis auch ein + kleiner button ist fuer den wecker von den aufgaben, oder quasi eine + taetige meldung einmal am tag zu aktivieren wenn noch aufgaben auf + sind." + + DREI SCHICHTEN, GETRENNT GEPRUEFT -- weil sie getrennt kaputtgehen: + + 1 DIE OBERFLAECHE, wenn der Browser Benachrichtigungen sperrt. + Chromium meldet headless `Notification.permission === 'denied'`, + und `grantPermissions` aendert daran nichts (nachgemessen). Das + ist kein Hindernis, sondern genau der Fall eines Menschen, der + einmal "Nicht zulassen" gedrueckt hat -- und der haeufigste + Grund fuer "das funktioniert nicht". + + 2 DAS SCHALTEN ueber die Schnittstelle, aus der SEITE heraus + aufgerufen: So gehen Sitzung und Herkunftspruefung mit, ohne + dass hier ein Geheimnis liegt. Geprueft wird vor allem, ob die + eingestellte Uhrzeit ein Aus- und Einschalten ueberlebt -- der + Weg, auf dem still Daten verlorengehen. + + 3 DIE ZEITENTSCHEIDUNG als reine Rechnung. Sie steckte + urspruenglich zwischen einer Datenbankabfrage und einem + Push-Versand; um sie zu pruefen, haette man beides aufbauen + muessen -- also haette man sie nicht geprueft. + + ZU JEDER SCHICHT GEHOERT EINE GEGENPROBE. Eine Pruefung, die immer + bestaetigt, bestaetigt nichts: "99:99", "7:30" ohne fuehrende Null + und ein Wert an einer Art, die keinen kennt, MUESSEN abgelehnt + werden. + ===================================================================== */ +/* 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 }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort + zwei Auffangnetze ein, die einen Absturz nur protokollieren -- + richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese + Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der + Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */ +notbremse(480_000, "pruef-start-ansicht"); +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?.(); + +/* ORTSZEIT, nicht UTC. Die Anwendung rechnet "heute" nach der Uhr des + Benutzers; toISOString() liefert dagegen UTC und damit zwischen + Mitternacht und 2 Uhr den VORTAG. In diesem Fenster schlug diese + Pruefung fehl, ohne dass etwas kaputt war. */ +const tagText = (d = new Date()) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` + + `-${String(d.getDate()).padStart(2, "0")}`; + +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 = tagText(); +const gestern = tagText(new Date(Date.now() - 86400_000)); +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 kontext = await browser.newContext({ viewport: { width: 1500, height: 1000 }, deviceScaleFactor: 2 }); +/* OHNE DIESE ZEILE PRUEFT DER LAUF DAS FALSCHE. Chromium meldet ohne + Erlaubnis `Notification.permission === 'denied'`; der Knopf lehnt + dann korrekt ab, und der Test haette nie den Normalfall gesehen. */ +await kontext.grantPermissions(['notifications'], { origin: BASIS }); +const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001"); +await seite.waitForTimeout(1500); + +const lies = () => seite.evaluate(() => { + const k = document.getElementById('tagesruf'); + const f = document.querySelector('.tagesruf__feld'); + const g = document.getElementById('glocke'); + const platz = document.getElementById('glocke-platz'); + if (!k) return { fehlt: 'tagesruf' }; + const rk = k.getBoundingClientRect(), rp = platz.getBoundingClientRect(); + const rf = f ? f.getBoundingClientRect() : null; + const kachel = document.querySelector('.willkommen').getBoundingClientRect(); + return { + an: k.dataset.an, gedrueckt: k.getAttribute('aria-pressed'), aus: k.disabled, + text: k.querySelector('.tagesruf__text').textContent, + titel: k.title, + knopfGroesse: [Math.round(rk.width), Math.round(rk.height)], + platzGroesse: [Math.round(rp.width), Math.round(rp.height)], + glockeDa: !!g, + feldSichtbar: f ? !f.hidden : null, + feldImBild: rf && rf.width ? (rf.left >= kachel.left && rf.right <= kachel.right + && rf.top >= kachel.top && rf.bottom <= kachel.bottom) : null, + feldWert: f ? f.querySelector('input')?.value : null, + ueberlappt: (() => { + const u = document.querySelector('.uhr')?.getBoundingClientRect(); + if (!u || !rf || !rf.width) return null; + return !(rf.right < u.left || rf.left > u.right || rf.bottom < u.top || rf.top > u.bottom); + })(), + }; +}); +seite.on('console', (m) => { if (m.type() === 'warning' || m.type() === 'error') console.log(' [Browser]', m.text()); }); +seite.on('dialog', async (d) => { console.log(' [Meldung]', d.message().slice(0, 70)); await d.dismiss(); }); +console.log('Lage im Browser:', JSON.stringify(await seite.evaluate(() => ({ + push: 'PushManager' in window, sw: 'serviceWorker' in navigator, + recht: window.Notification ? Notification.permission : 'keine API', +})))); +const zeige = (n, w) => console.log(` ${n.padEnd(22)} an=${w.an} text=${w.text} feld=${w.feldSichtbar} wert=${w.feldWert}`); + +/* ---- SCHICHT 1: die Oberflaeche, wenn der Browser NEIN sagt -------- + Chromium meldet headless `Notification.permission === 'denied'`, und + `grantPermissions` aendert daran nichts. Das ist kein Hindernis, + sondern ein Testfall: Genau so sieht es bei jemandem aus, der + Benachrichtigungen einmal abgelehnt hat. */ +console.log(''); +/* DIE ERWARTUNG RICHTET SICH NACH DER LAGE, statt sie vorauszusetzen. + + Heute meldet Chromium headless 'denied'. Erlaubt eine kuenftige + Fassung Benachrichtigungen von sich aus, waere ein fest verdrahtetes + "muss blockiert sein" ein Fehlalarm ohne Fehler -- dieselbe Sorte + Zeitbombe wie eine Pruefung, die ein Datum als "jetzt" annimmt. Also + erst nachsehen, dann pruefen, was in DIESER Lage richtig ist. */ +const recht = await seite.evaluate(() => (window.Notification ? Notification.permission : 'keine')); +console.log(`SCHICHT 1 -- Berechtigung im Browser: ${recht}`); +const g = await lies(); +zeige('Grundzustand', g); +let fehl1 = 0; +const pruefe = (gut, satz, sonst) => { + if (gut) console.log(` OK ${satz}`); + else { fehl1++; console.log(` FEHL ${sonst}`); } +}; +pruefe(g.an === 'nein', 'der Knopf steht am Anfang auf aus (Vorgabe: nicht eingeschaltet)', + 'er steht am Anfang schon auf an'); + +if (recht === 'denied') { + pruefe(/blockiert/.test(g.titel), 'der Grund steht VOR dem Klick im Titel', + `der Titel verspricht etwas, das nicht geht: "${g.titel.slice(0, 60)}"`); + pruefe(g.feldSichtbar === false, 'kein Zeitfeld, wo ohnehin nichts ankaeme', + 'das Zeitfeld steht offen, obwohl nichts zugestellt wuerde'); + await seite.click('#tagesruf'); + await seite.waitForTimeout(400); + pruefe((await lies()).an === 'nein', 'ein Klick schaltet nichts ein, was nicht ankaeme', + 'der Klick hat trotz Sperre eingeschaltet'); +} else { + pruefe(!/blockiert|nicht|geht/.test(g.titel), 'der Titel beschreibt, was der Knopf tut', + `der Titel meldet ein Hindernis, obwohl keins da ist: "${g.titel.slice(0, 60)}"`); + await seite.click('#tagesruf'); + await seite.waitForTimeout(600); + const n = await lies(); + pruefe(n.an === 'ja', 'ein Klick schaltet ein', 'der Klick hat nicht eingeschaltet'); + pruefe(n.feldSichtbar === true, 'eingeschaltet erscheint das Zeitfeld', + 'das Zeitfeld bleibt aus, obwohl eingeschaltet'); + await seite.click('#tagesruf'); + await seite.waitForTimeout(600); + pruefe((await lies()).an === 'nein', 'ein zweiter Klick schaltet wieder aus', + 'der zweite Klick hat nicht ausgeschaltet'); +} + +/* ---- SCHICHT 2: das Schalten selbst, ueber die Schnittstelle ------- + Aus der SEITE heraus aufgerufen, nicht von node: So gehen Sitzung + und Herkunftspruefung mit, ohne dass ich ein Geheimnis anfassen + muss. Geprueft wird der Weg, an dem hier wirklich etwas + schiefgehen kann -- ob die Uhrzeit ein Aus- und Einschalten + ueberlebt. */ +console.log(''); +console.log('SCHICHT 2 -- Schalten und Merken'); +const api = (koerper) => seite.evaluate(async (k) => { + const a = await fetch('/workspace/api/push/art', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(k) }); + return { code: a.status, ...(await a.json().catch(() => ({}))) }; +}, koerper); +const stand = () => seite.evaluate(async () => { + const a = await fetch('/workspace/api/push/stand'); + const s = await a.json(); + return (s.arten || []).find((x) => x.schluessel === 'tagesruf'); +}); + +console.log(' Vorgabe:', JSON.stringify(await stand())); +console.log(' an + 07:30:', JSON.stringify(await api({ art: 'tagesruf', an: true, wert: '07:30' }))); +console.log(' aus (ohne wert):', JSON.stringify(await api({ art: 'tagesruf', an: false }))); +const nachAus = await stand(); +console.log(nachAus.wert === '07:30' + ? ' OK die Uhrzeit hat das Abschalten ueberlebt' + : ` FEHL die Uhrzeit ist beim Abschalten verlorengegangen (${nachAus.wert})`); +console.log(' wieder an:', JSON.stringify(await api({ art: 'tagesruf', an: true }))); +const nachAn = await stand(); +console.log(nachAn.wert === '07:30' && nachAn.an + ? ' OK beim Einschalten steht wieder 07:30 da' + : ` FEHL ${JSON.stringify(nachAn)}`); + +/* Gegenprobe: Kann die Pruefung ueberhaupt FEHL sagen? Eine ungueltige + Uhrzeit muss abgelehnt werden -- sonst prueft der Bereichstest + nichts. */ +const falsch = await api({ art: 'tagesruf', an: true, wert: '99:99' }); +console.log(falsch.code === 400 ? ' OK "99:99" wird abgelehnt (400)' : ` FEHL "99:99" ging durch: ${JSON.stringify(falsch)}`); +const falsch2 = await api({ art: 'tagesruf', an: true, wert: '7:30' }); +console.log(falsch2.code === 400 ? ' OK "7:30" ohne fuehrende Null wird abgelehnt' : ` FEHL ${JSON.stringify(falsch2)}`); +const ohneWert = await api({ art: 'aufgabe_faellig', an: true, wert: '08:00' }); +console.log(ohneWert.code === 400 ? ' OK eine Art ohne Wert nimmt keinen an' : ` FEHL ${JSON.stringify(ohneWert)}`); +const unversehrt = await stand(); +console.log(unversehrt.wert === '07:30' ? ' OK nach drei Ablehnungen steht 07:30 unveraendert da' : ` FEHL ${unversehrt.wert}`); + +await seite.screenshot({ path: 'ruf.png', clip: await seite.$eval('.willkommen__rechts', (e) => { + const r = e.getBoundingClientRect(); + return { x: r.x - 40, y: r.y - 20, width: r.width + 60, height: r.height + 40 }; }) }); + +console.log(''); +console.log('SCHICHT 3 -- wann er faellig ist (reine Rechnung)'); +const { tagesrufFaellig, TAGESRUF_FENSTER } = await import('./workspace-push.js'); +const M = (h, m) => h * 60 + m; +let fehl3 = 0; +for (const [jetzt, zeit, soll, was] of [ + [M(9, 0), '09:00', true, 'genau zur eingestellten Zeit'], + [M(9, 4), '09:00', true, 'vier Minuten spaeter (ein Takt)'], + [M(12, 0), '09:00', true, 'genau am Ende des Fensters'], + [M(12, 1), '09:00', false, 'eine Minute zu spaet -- der Tag faellt aus'], + [M(20, 0), '09:00', false, 'abends fuer morgens -- faellt aus'], + [M(8, 59), '09:00', false, 'eine Minute zu frueh'], + [M(0, 0), '00:00', true, 'Mitternacht auf Mitternacht'], + [M(0, 10), '23:30', false, 'ueber Mitternacht hinweg -- kein Rueckwaertsfenster'], + [M(9, 0), '9:00', false, 'ohne fuehrende Null (Gegenprobe)'], + [M(9, 0), '99:99', false, 'keine Uhrzeit (Gegenprobe)'], + [M(9, 0), null, false, 'nichts eingestellt (Gegenprobe)'], +]) { + const ist = tagesrufFaellig(jetzt, zeit); + if (ist !== soll) { fehl3++; console.log(` FEHL ${was}: ${ist} statt ${soll}`); } + else console.log(` OK ${was}`); +} +console.log(` (Fenster: ${TAGESRUF_FENSTER} Minuten)`); + +/* Die Ruhezeit und das Fenster muessen zusammenpassen: Wer eine Zeit + in der Ruhezeit einstellt, bekommt nie etwas. Der Browser warnt + davor -- hier wird geprueft, dass es die Warnung ueberhaupt gibt. */ +const ruhe = await seite.evaluate(async () => (await (await fetch('/workspace/api/push/stand')).json()).ruhe); +console.log(` Ruhezeit laut Server: ${ruhe.ab}:00 bis ${ruhe.bis}:00`); +await seite.evaluate(async () => { + await fetch('/workspace/api/push/art', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ art: 'tagesruf', an: true, wert: '23:00' }) }); +}); +await seite.reload({ waitUntil: 'domcontentloaded' }); +await seite.waitForTimeout(1400); +const warnt = await seite.evaluate(() => { + const h = document.querySelector('.tagesruf__warnung'); + return { da: !!h, sichtbar: h ? !h.hidden : false, text: h?.textContent || '' }; +}); +console.log(warnt.da ? ` -- Warnung vorhanden: "${warnt.text}"` + : ' -- (Warnung nur sichtbar, wenn Benachrichtigungen erlaubt sind -- hier gesperrt)'); + +await seite.screenshot({ path: 'ruf.png', clip: await seite.$eval('.willkommen__rechts', (e) => { + const r = e.getBoundingClientRect(); + return { x: r.x - 40, y: r.y - 20, width: r.width + 60, height: r.height + 40 }; }) }); +await browser.close(); +console.log(''); +const fehl = fehl1 + fehl3; +console.log(fehl === 0 ? 'Alles in Ordnung.' : fehl + ' Pruefung(en) fehlgeschlagen.'); + +process.exit(fehl === 0 ? 0 : 1); diff --git a/server/workspace-push.js b/server/workspace-push.js index 70c6b939..0e93ea42 100644 --- a/server/workspace-push.js +++ b/server/workspace-push.js @@ -84,6 +84,21 @@ export const ARTEN = [ die am haeufigsten kommt, und wer die Liste ueberfliegt, sucht zuerst sie. */ { schluessel: "chat_nachricht", name: "Neue Nachricht im Chat", vorgabe: true }, + /* DER TAGESRUF (08.09.2026, screen10). Filipe: "eine taetige meldung + einmal am tag zu aktivieren wenn noch aufgaben auf sind." + + ALS EINZIGE ART MIT `vorgabe: false`, und das ist keine + Nachlaessigkeit: Er ist ausdruecklich etwas zum AKTIVIEREN. Alle + anderen Arten antworten auf ein Ereignis, das gerade passiert ist + (eine Aufgabe wird faellig, jemand schreibt). Der Tagesruf kommt + ungefragt zur selben Zeit, ob es etwas Neues gibt oder nicht -- + so etwas schaltet man sich selbst ein, sonst ist es Werbung. + + `wertVorgabe` ist die Uhrzeit. Neun Uhr, weil die Ruhezeit bis + sieben geht und ein Ruf um 7:01 niemanden erreicht, der um acht + aufsteht. */ + { schluessel: "tagesruf", name: "Einmal am Tag: was noch offen ist", + vorgabe: false, wertVorgabe: "09:00", wertArt: "uhrzeit" }, { schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true }, ]; const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel)); @@ -164,6 +179,57 @@ export function willHaben(personId, art) { return ARTEN.find((a) => a.schluessel === art)?.vorgabe ?? true; } +/** Der eingestellte Wert einer Art -- oder die Vorgabe. + * + * Dieselbe Bauart wie `willHaben`: Fehlt die Zeile, gilt was in + * `ARTEN` steht. Eine Vorgabe in der Datenbank waere eine zweite + * Wahrheit, die bei jeder Aenderung vergessen wird. + */ +export function wertVon(personId, art) { + const z = db().prepare( + "SELECT wert FROM push_einstellungen WHERE person_id = ? AND art = ?").get(personId, art); + const vorgabe = ARTEN.find((a) => a.schluessel === art)?.wertVorgabe ?? null; + return z?.wert ?? vorgabe; +} + +/** Ist "hh:mm" eine Uhrzeit, die es gibt? + * + * Geprueft wird der BEREICH, nicht nur die Form: "99:99" passt auf + * jedes naive Muster und ist trotzdem keine Uhrzeit. Aus dem Netz + * kommt, was jemand schickt -- nicht, was die Oberflaeche anbietet. + */ +/** Ist der Tagesruf jetzt faellig? -- die Entscheidung allein. + * + * HERAUSGELOEST, DAMIT SIE PRUEFBAR IST. Im Rundgang steckte sie + * zwischen einer Datenbankabfrage und einem Push-Versand; um sie zu + * pruefen, haette man beides aufbauen muessen -- also haette man sie + * nicht geprueft. Eine Entscheidung, die nur aus Zahlen besteht, + * gehoert in eine Funktion, die nur aus Zahlen besteht. + * + * DAS FENSTER IST DREI STUNDEN BREIT. Der Takt laeuft alle fuenf + * Minuten; ein einfaches "jetzt >= eingestellte Zeit" wuerde nach + * einem Serverausfall den Ruf fuer neun Uhr um zwanzig Uhr + * zustellen -- eine Erinnerung an einen Tag, der vorbei ist. Wer sie + * um 21 Uhr bekommt, schaltet sie ab. Faellt der Tag aus, ist das die + * ehrlichere Antwort als eine Meldung zur falschen Zeit. + * + * @param jetztMinuten Minuten seit Mitternacht, Ortszeit. + * @param zeit "hh:mm", die eingestellte Uhrzeit. + */ +export const TAGESRUF_FENSTER = 180; +export function tagesrufFaellig(jetztMinuten, zeit) { + if (!istUhrzeit(zeit)) return false; + const [h, m] = zeit.split(":").map(Number); + const seit = jetztMinuten - (h * 60 + m); + return seit >= 0 && seit <= TAGESRUF_FENSTER; +} + +export function istUhrzeit(t) { + if (typeof t !== "string" || !/^\d{2}:\d{2}$/.test(t)) return false; + const [h, m] = t.split(":").map(Number); + return h >= 0 && h <= 23 && m >= 0 && m <= 59; +} + /* --------------------------------------------------------------------- VERSCHICKEN — der einzige Weg nach draussen. @@ -266,6 +332,62 @@ export async function laufen() { }); } + /* --- DER TAGESRUF: einmal am Tag, was noch offen ist -------------- + + Filipe (screen10): "eine taetige meldung einmal am tag zu + aktivieren wenn noch aufgaben auf sind." + + NUR WER IHN EINGESCHALTET HAT. Die Vorgabe ist `false`, also + gibt es die Zeile nur, wenn jemand sie angelegt hat -- die + Abfrage ist damit zugleich die Teilnehmerliste. Kein Rundgang + ueber alle Personen, um dann 95 Prozent wieder wegzuwerfen. + + EIN FENSTER VON DREI STUNDEN, und das ist der eigentliche + Gedanke hier. Der Takt laeuft alle fuenf Minuten; ein einfaches + "jetzt >= eingestellte Zeit" wuerde nach einem Serverausfall + den Ruf fuer neun Uhr um zwanzig Uhr zustellen -- eine + Erinnerung an einen Tag, der vorbei ist. Wer sie um 21 Uhr + bekommt, schaltet sie ab. Also: bis drei Stunden danach ja, + spaeter nicht mehr. Der Tag faellt dann aus, und das ist die + ehrlichere Antwort als eine Meldung zur falschen Zeit. + + Die Ruhezeit prueft `benachrichtige` selbst. Wer sechs Uhr + einstellt, bekommt den Ruf um sieben (Ruhezeit bis 7) -- immer + noch im Fenster. + + "WENN NOCH AUFGABEN AUF SIND" heisst: bei null Aufgaben kommt + nichts. Eine taegliche Meldung "du hast nichts zu tun" ist + genau die Sorte Nachricht, die dazu fuehrt, dass man die + naechste nicht mehr liest. */ + const n = new Date(); + const jetztMinuten = n.getHours() * 60 + n.getMinutes(); + for (const e of d.prepare( + "SELECT person_id, wert FROM push_einstellungen WHERE art = 'tagesruf' AND an = 1").all()) { + const zeit = e.wert || ARTEN.find((a) => a.schluessel === "tagesruf")?.wertVorgabe || "09:00"; + if (!tagesrufFaellig(jetztMinuten, zeit)) continue; + + const z = d.prepare(` + SELECT COUNT(*) AS offen, + SUM(CASE WHEN frist IS NOT NULL AND frist < ? THEN 1 ELSE 0 END) AS spaet + FROM aufgaben + WHERE verantwortlich_id = ? AND status NOT IN ('erledigt', 'abgebrochen')`) + .get(heute, e.person_id); + if (!z || !z.offen) continue; + + /* Der Satz nennt die Zahl und, wenn es sie gibt, die + Ueberfaelligen -- das ist die Information, wegen der man + hinsieht. "Du hast offene Aufgaben" allein zwingt zum + Nachschauen und hilft niemandem. */ + const spaet = z.spaet || 0; + await senden(e.person_id, "tagesruf", { + titel: z.offen === 1 ? "Eine Aufgabe ist noch offen" : `${z.offen} Aufgaben sind noch offen`, + text: spaet ? (spaet === 1 ? "Davon eine überfällig." : `Davon ${spaet} überfällig.`) + : "Nichts davon ist überfällig.", + ziel: "/workspace/aufgaben.html", + merkmal: `tagesruf:${heute}`, + }); + } + /* --- Termine in der naechsten Stunde ------------------------------ */ const gleich = new Date(Date.now() + 60 * 60_000); const p = (n) => String(n).padStart(2, "0"); @@ -443,6 +565,10 @@ pushRouter.get("/workspace/api/push/stand", (req, res) => { WHERE person_id = ? ORDER BY erstellt`).all(req.person.id); const arten = ARTEN.map((a) => ({ ...a, an: willHaben(req.person.id, a.schluessel), + /* Nur dort, wo die Art ueberhaupt einen Wert kennt -- sonst + staende bei sieben Arten ein `wert: null` in der Antwort und + jemand baut irgendwann ein Eingabefeld dafuer. */ + ...(a.wertArt ? { wert: wertVon(req.person.id, a.schluessel) } : {}), })); res.json({ /* Nur die Anzahl und ein Kuerzel nach aussen -- der volle @@ -518,9 +644,36 @@ pushRouter.put("/workspace/api/push/art", gleicheHerkunft, express.json({ limit: const art = String(req.body?.art || ""); if (!ARTEN_SCHLUESSEL.has(art)) return res.status(400).json({ fehler: "Diese Art gibt es nicht." }); const an = req.body?.an ? 1 : 0; - db().prepare(`INSERT INTO push_einstellungen (person_id, art, an) VALUES (?,?,?) - ON CONFLICT(person_id, art) DO UPDATE SET an = excluded.an`).run(req.person.id, art, an); - res.json({ ok: true, art, an: !!an }); + + /* DER WERT WIRD NUR ANGEFASST, WENN EINER MITKAM. + + `undefined` und `null` sind hier zweierlei: Wer nur den Schalter + umlegt, schickt kein `wert` -- die eingestellte Uhrzeit muss + dann stehen bleiben. Wuerde ich hier stumpf `req.body.wert` + schreiben, loeschte jedes Ein- und Ausschalten die Uhrzeit, und + beim naechsten Einschalten staende wieder neun Uhr da. Ein + stiller Datenverlust, den niemand meldet, weil er wie eine + Vorgabe aussieht. */ + const definiert = Object.prototype.hasOwnProperty.call(req.body || {}, "wert"); + let wert = null; + if (definiert) { + const kennt = ARTEN.find((a) => a.schluessel === art)?.wertArt; + if (!kennt) return res.status(400).json({ fehler: "Diese Art hat keinen Wert." }); + wert = req.body.wert; + if (kennt === "uhrzeit" && !istUhrzeit(wert)) { + return res.status(400).json({ fehler: "Das ist keine Uhrzeit (hh:mm)." }); + } + } + + if (definiert) { + db().prepare(`INSERT INTO push_einstellungen (person_id, art, an, wert) VALUES (?,?,?,?) + ON CONFLICT(person_id, art) DO UPDATE SET an = excluded.an, wert = excluded.wert`) + .run(req.person.id, art, an, wert); + } else { + db().prepare(`INSERT INTO push_einstellungen (person_id, art, an) VALUES (?,?,?) + ON CONFLICT(person_id, art) DO UPDATE SET an = excluded.an`).run(req.person.id, art, an); + } + res.json({ ok: true, art, an: !!an, wert: wertVon(req.person.id, art) }); } catch (fehler) { console.error("[push] Art:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); diff --git a/server/workspace.js b/server/workspace.js index e7a99996..7f94a96e 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -357,6 +357,25 @@ function umstellungen(d) { ["eintraege", "event_ende", "TEXT"], ["eintraege", "event_aufgaben", "TEXT"], ["eintraege", "event_regeln", "TEXT"], + + /* DER TAGESRUF BRAUCHT MEHR ALS AN/AUS (08.09.2026, screen10). + + Filipe: "ich will das neben diesem kreis auch ein kleiner button + ist fuer den wecker von den aufgaben, oder quasi eine taetige + meldung einmal am tag zu aktivieren wenn noch aufgaben auf sind." + + Eine Uhrzeit ist kein Schalter. Statt einer eigenen Tabelle fuer + eine einzige Zahl bekommt `push_einstellungen` eine allgemeine + Spalte `wert`: Wo eine Art mehr braucht als an/aus, steht es + dort. Die vorhandenen Arten lassen sie leer und merken nichts + davon. + + KEIN `NOT NULL`, KEINE VORGABE IN DER DATENBANK: Die Vorgabe + gehoert nach workspace-push.js zu den Arten, sonst stuende sie an + zwei Stellen -- und die in der Datenbank wuerde bei einer + Aenderung vergessen. Genau diese Sorte Doppelung hat mich heute + schon dreimal aufgehalten. */ + ["push_einstellungen", "wert", "TEXT"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); diff --git a/workspace/assets/css/heim.css b/workspace/assets/css/heim.css index 238ee9b7..740017b5 100644 --- a/workspace/assets/css/heim.css +++ b/workspace/assets/css/heim.css @@ -906,1201 +906,19 @@ denen es sie gar nicht gibt (iPhone im Browser), bleibt ein leerer Streifen neben der Uhr; das ist der Preis, und er ist niedriger als ein springendes Bild. */ -.glocke-platz { width: 46px; height: 46px; flex: none; } +.glocke-platz { + /* ZWEI KNOEPFE UEBEREINANDER, und der Platz gilt fuer beide. -/* ---------- Die Uhr -------------------------------------------------- - ROT x BABYBLAU (07.09.2026, zweiter Entwurf). - - Filipe: "die uhr rechts soll auch viel viel viel viel viel viel viel - spezieller noch sein und soll eine mischung von rot und babyblau - haben." - - WAS EINE UHR TEUER AUSSEHEN LAESST, IST NICHT DIE FARBE, SONDERN DAS - GEHAEUSE. Bei einer echten Uhr sieht man drei Dinge übereinander: - einen METALLRAND, der das Licht von oben fängt; eine VERTIEFTE - Scheibe darin; und ein GLAS darüber, das spiegelt. Genau diese drei - Schichten sind hier gebaut -- und sie sind der Grund, warum das Ding - jetzt wie ein Gegenstand aussieht und nicht wie ein Kreis mit Zahlen. - - Die Farben kommen aus dem Haus, nicht aus dem Farbkasten: Rot ist - Spicy Media, Babyblau ist DogFather. Dieselben zwei stehen im Kopf - der Anmeldekarte nebeneinander und bilden im Motiv den Neonrahmen. - - Beide Verlaeufe stehen im SVG (siehe start.html) und laufen - GEGENEINANDER -- der Stundenring rot -> blau, der Sekundenring blau - -> rot. Wo der eine warm ist, ist der andere kuehl; so bleiben sie - auseinanderzuhalten, wo sie sich ueberlagern. - -------------------------------------------------------------------- */ -/* ===================================================================== - DIE UHR — ein Instrument, keine Anzeige (07.09.2026, zweiter Anlauf) - - Filipe: "AUCH MIT DER UHR RECHTS; DAS IST VIEL ZU EINFACH; ICH WILL - SIE NOCH VIEEEEEEL KRASSER; WIE IN DER BUISNESS HUB SEITE VON VANVAN, - WIE DIE UHR BEI IHR ABER NOCH KRASSER UND SPEZIELLER." - - NACHGESEHEN, NICHT GERATEN. Auf VanVans Business-Hub gibt es keine - Uhr -- gemeint ist das `gate-medaillon` auf der Anmeldeseite: ein - runder Kegelverlauf, der wie gebuerstetes Metall aussieht, gefasst in - zwei eingelassenen Ringen. Genau diese Bauart steht jetzt hier, in - drei Punkten weitergetrieben: - - 1. FASSUNG STATT RAND. Der Kegelverlauf laeuft rundherum durch - hell/dunkel/hell -- deshalb sieht die Fassung gedreht aus und - nicht bemalt. Sie ist 5 px breit statt 3; darunter liegt ein - zweiter, dunkler Ring, der sie einlaesst. - 2. AUFGESETZTE STUNDENMARKEN. Zwoelf kraeftige Striche aus einem - einzigen sich wiederholenden Kegelverlauf, dazu sechzig feine. - Sechzig Elemente im HTML waeren dieselbe Optik mit sechzig - Elementen mehr im Baum. - 3. GLASKUPPE. Eine Spiegelung von oben links plus ein heller Bogen - an der Oberkante. Ohne den Bogen wirkt jede runde Flaeche flach. - - UND SIE IST GROESSER: 164 statt 124 px. Das ist kein Detail -- - "krasser" heisst bei einem Instrument zuerst "lesbar". Auf dem Handy - bleibt sie bei 118 px, dort steht sie unter dem Text statt daneben. - ===================================================================== */ -.uhr { + Seit dem 08.09.2026 steht hier neben der Glocke der Tagesruf + (screen10). Die Begruendung oben aendert sich dadurch nicht, sie + wird nur groesser: 48 px je Knopf, 8 px dazwischen, macht 104 px -- + reserviert, ob die Knoepfe kommen oder nicht. Haette ich die Hoehe + auf 46 stehen lassen, waere genau der Sprung zurueckgekommen, gegen + den der Absatz darueber geschrieben wurde: Der zweite Knopf haengt + an derselben Abfrage wie der erste. */ + width: 48px; height: 104px; flex: none; + display: flex; flex-direction: column; align-items: center; gap: 8px; position: relative; - width: 164px; height: 164px; flex: none; - display: grid; place-items: center; - border-radius: 50%; - padding: 10px; - /* SCHICHT 1: DIE LUENETTE. Zehn Pixel breit und DECKEND. - Halbdurchsichtig war der Fehler des ersten Anlaufs: Metall, durch - das der Hintergrund scheint, sieht nicht nach Metall aus, sondern - nach grauem Glas -- im Bildschirmfoto gemessen, es las sich als - flacher grauer Ring. Echtes Metall ist undurchsichtig; sein - Aussehen entsteht allein daraus, WIE es das Licht zurueckwirft. - Deshalb hier volle Farben und ein harter Wechsel hell/dunkel. */ - background: - conic-gradient(from 208deg, - #eaf2fb 0deg, - #8fa3bd 26deg, - #f6fbff 52deg, - #6f8299 84deg, - #dfe9f5 116deg, - #ff8a94 150deg, - #f4f9ff 176deg, - #5d6f87 214deg, - #e6eff9 246deg, - #8fd8ff 282deg, - #f8fcff 312deg, - #7c8ea6 340deg, - #eaf2fb 360deg); - box-shadow: - /* Der zweifarbige Schein nach aussen, weit unter der Blendschwelle. */ - -4px -4px 26px -13px rgba(255, 95, 109, .6), - 4px 4px 26px -13px rgba(143, 216, 255, .6), - 0 14px 30px -14px rgba(0, 0, 0, .95), - /* DIE MULDE. Zwei Ringe aus Schatten mit Ausdehnung: aussen die - Fraesung selbst (dunkel, 11 px breit), darauf eine haarfeine - helle Kante -- dort trifft das Licht die gegenueberliegende Wand - der Vertiefung. Zusammen macht das aus "davor" ein "darin", und - zwar konzentrisch bei jeder Groesse. */ - 0 0 0 11px rgba(0, 0, 0, .5), - 0 0 0 12px rgba(190, 208, 230, .12), - 0 6px 14px 6px rgba(0, 0, 0, .45), - /* Und der dunkle Ring, der die Fassung einlaesst -- ohne ihn liegt - sie auf dem Zifferblatt statt darin. */ - inset 0 0 0 1px rgba(6, 10, 18, .85); -} -/* SCHICHT 2: die vertiefte Scheibe. */ -.uhr::before { - content: ""; position: absolute; inset: 10px; border-radius: 50%; - /* DAS GUILLOCHIERTE ZIFFERBLATT. - - Guillochieren ist das Verfahren, mit dem seit zweihundert Jahren - hochwertige Zifferblaetter gemacht werden: Eine Maschine schneidet - ein feines, regelmaessiges Muster in das Metall, und weil jede - Rille das Licht in einem anderen Winkel zurueckwirft, LEBT die - Flaeche, wenn man den Kopf bewegt. Es ist der Unterschied zwischen - einem lackierten und einem bearbeiteten Blatt -- und der Grund, - warum ein teures Zifferblatt teuer AUSSIEHT, ohne dass eine - einzige Farbe daran teuer waere. - - Hier aus zwei Ebenen: STRAHLEN vom Mittelpunkt (der Kegelverlauf, - 240 Rillen) und RINGE um den Mittelpunkt (der wiederholte - Radialverlauf, alle 3 px). Beide extrem leise -- bei 4 Prozent - Deckkraft sieht man kein Muster, man sieht eine Oberflaeche. Wer - sie einzeln erkennt, hat sie zu laut gemacht. - - `closest-side`, damit die Ringe im Radius rechnen und nicht in der - weitesten Ecke -- derselbe Fehler, der heute die Skala unsichtbar - gemacht hat. */ - background: - repeating-conic-gradient(from 0deg, - rgba(255, 255, 255, .045) 0deg 0.75deg, - rgba(0, 0, 0, .05) 0.75deg 1.5deg), - repeating-radial-gradient(circle closest-side at 50% 50%, - rgba(255, 255, 255, .035) 0 1px, - rgba(0, 0, 0, .045) 1px 3px), - radial-gradient(circle at 50% 26%, rgba(255, 255, 255, .06), transparent 56%), - radial-gradient(circle at 50% 50%, #0c121e 56%, #04070d 100%); - box-shadow: - inset 0 2px 7px -2px rgba(0, 0, 0, .95), - inset 0 0 0 1px rgba(190, 205, 225, .16), - inset 0 -1px 0 rgba(255, 255, 255, .05); -} -/* DIE SKALA LIEGT AUF DER LUENETTE, NICHT AUF DEM ZIFFERBLATT. - - Im ersten Anlauf lag sie bei 90 bis 99 Prozent des Radius -- und - genau dort laeuft der Sekundenring. Sie war vollstaendig verdeckt, - und zwar so, dass man es nicht merkt: Eine unsichtbare Skala sieht - aus wie gar keine, nicht wie ein Fehler. Im Bildschirmfoto gesehen, - nicht im Kopf ausgerechnet. - - Jetzt sitzt sie dort, wo sie bei einer echten Uhr sitzt: eingraviert - in den Metallring. Deshalb sind die Striche DUNKEL -- eine Gravur ist - eine Vertiefung, in der Schatten steht, kein aufgemalter heller - Strich. Zwoelf kraeftige fuer die Stunden, sechzig feine dazwischen, - beide aus je EINEM sich wiederholenden Kegelverlauf. */ -.uhr__skala { - position: absolute; inset: 0; border-radius: 50%; - /* NUR die zwoelf Stundenmarken -- lang und kraeftig. Die sechzig - feinen Minutenstriche liegen auf einer eigenen Ebene (.uhr::after), - weil sie eine ANDERE Laenge brauchen: Auf einer echten Luenette - reichen Stundenmarken durch den ganzen Ring, Minutenstriche nur - ein Stueck vom Rand herein. Zwei Laengen heissen zwei Masken, und - zwei Masken heissen zwei Ebenen -- eine Ebene kann nur eine haben. - - Beim ersten Anlauf lagen beide hier, gleich lang: Aus der Skala - wurde ein Zahnkranz. Im Bildschirmfoto gesehen. */ - background: - /* AUFGESETZTE INDIZES bei 3, 6 und 9 Uhr. Auf einer guten Luenette - sind die Viertelstunden keine Striche wie die anderen, sondern - eigene Marken -- breiter und HELL statt graviert, weil sie - aufgesetzt sind und deshalb Licht fangen statt Schatten zu - halten. Ein Kegelverlauf mit 90-Grad-Takt, um 90 Grad gedreht: - So trifft er 3, 6 und 9 und laesst die 12 der roten Marke. */ - repeating-conic-gradient(from 88.6deg, - rgba(244, 250, 255, .92) 0deg 2.8deg, - transparent 2.8deg 90deg), - repeating-conic-gradient(from 0deg, - rgba(6, 10, 18, .85) 0deg 1.5deg, - transparent 1.5deg 30deg); - /* `closest-side` IST HIER PFLICHT, NICHT GESCHMACK. - - Ohne Angabe rechnet ein `radial-gradient` seine Prozente auf die - WEITESTE ECKE des Kastens -- bei 164 px Kantenlaenge sind das - 116 px, nicht die 82 px des Radius. "89 % bis 99,5 %" lag damit - bei 103 bis 115 px und komplett AUSSERHALB der runden Uhr: Die - Skala war unsichtbar, und zwar seit es sie gibt. Eine unsichtbare - Verzierung sieht aus wie gar keine, nicht wie ein Fehler -- genau - deshalb ist sie nie aufgefallen. - - Mit `closest-side` sind 100 % der Radius, und die Prozente - bedeuten das, wonach sie aussehen. */ - -webkit-mask: radial-gradient(circle closest-side, transparent 0 88%, #000 88% 99.5%, transparent 99.5%); - mask: radial-gradient(circle closest-side, transparent 0 88%, #000 88% 99.5%, transparent 99.5%); - pointer-events: none; z-index: 4; -} -/* Die sechzig feinen Minutenstriche -- kurz, vom Aussenrand herein, - und deutlich leiser als die Stundenmarken. Das Verhaeltnis der beiden - ist der ganze Unterschied zwischen "Skala" und "Zahnkranz". */ -.uhr::after { - content: ""; position: absolute; inset: 0; border-radius: 50%; - z-index: 4; pointer-events: none; - background: - repeating-conic-gradient(from 0deg, - rgba(6, 10, 18, .42) 0deg 0.4deg, - transparent 0.4deg 6deg); - -webkit-mask: radial-gradient(circle closest-side, transparent 0 94%, #000 94% 99.5%, transparent 99.5%); - mask: radial-gradient(circle closest-side, transparent 0 94%, #000 94% 99.5%, transparent 99.5%); } -/* DER AUFGESETZTE ZWOELF-INDEX. Ein Keil in Hausrot mit heller - Oberkante, gesetzt auf die Luenette. Er beantwortet die Frage "wo ist - oben" ohne eine einzige Ziffer -- und er ist der einzige farbige - Punkt auf dem Metall, also auch der Blickfang. */ -.uhr__zwoelf { - position: absolute; top: 3px; left: 50%; - width: 7px; height: 13px; margin-left: -3.5px; - z-index: 5; pointer-events: none; - clip-path: polygon(50% 0, 100% 100%, 0 100%); - background: linear-gradient(180deg, #ffd9dc 0%, #ff5f6d 34%, #b3323e 100%); - filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .8)); -} -/* DIE PERLE. Ein Kasten in voller Groesse, der sich dreht -- die Perle - selbst sitzt oben auf dem Radius des Sekundenbogens. So braucht die - Drehung keine Rechnung mit Winkelfunktionen: Der Kasten dreht sich, - die Perle wird mitgenommen. */ -.uhr__perle { - position: absolute; inset: 10px; - z-index: 5; pointer-events: none; - transform: rotate(0deg); - transition: transform .12s cubic-bezier(.34, 1.56, .64, 1); -} -/* DER ABSTAND FOLGT DER BAHN, DIE FARBE FOLGT DER BAHN -- und beides - muss beim Farbtausch mitgehen. Am 08.09.2026 habe ich genau das - vergessen: Die drei BOEGEN waren getauscht (Stunde rot, Minute - silber, Sekunde babyblau), die drei KOEPFE trugen weiter die alte - Zuordnung. Im Bildschirmfoto sass dadurch ein roter Kopf auf der - blauen Sekundenreihe und ein silberner auf den roten Stundenbalken - -- es sah nach einem Rechenfehler bei den Winkeln aus, obwohl alle - drei auf die Zehntelgrad genau standen (354 / 161,9 / 343,0 bei - 23:26:59, nachgemessen). Wer eine Farbe tauscht, tauscht sie an - ALLEN Stellen, die sie tragen -- Bogen, Kopf, Schein, Kranz. */ - -/* AUSSEN, BABYBLAU: der Kopf der Sekundenpunkte. Am groessten, weil er - der einzige ist, der sich sichtbar bewegt. */ -.uhr__perle::before { - content: ""; - position: absolute; top: 4.2%; left: 50%; - width: 7px; height: 7px; margin-left: -3.5px; - border-radius: 50%; - background: - radial-gradient(circle at 36% 32%, #fff 0%, #d8efff 34%, #7ec8f2 70%, #1d5a80 100%); - box-shadow: - 0 0 6px 1px rgba(126, 200, 242, .75), - 0 0 13px 3px rgba(110, 180, 235, .3), - inset 0 0 0 .5px rgba(255, 255, 255, .55); -} -/* MITTE, SILBER: der Kopf der Minutenbalken. Poliertes Metall, kein - Licht -- er ist der Bezug, an dem man die anderen beiden misst. */ -.uhr__perle--minute::before { - top: 12.6%; - width: 6px; height: 6px; margin-left: -3px; - background: radial-gradient(circle at 34% 30%, #ffffff 0%, #e6edf6 34%, #97a4b4 70%, #414b58 100%); - box-shadow: - 0 0 4px 1px rgba(220, 235, 255, .45), - inset 0 0 0 .5px rgba(255, 255, 255, .6); -} -/* INNEN, ROT: der Kopf der Stundenbalken. Der kleinste Punkt in der - kraeftigsten Farbe -- er bewegt sich am wenigsten und darf deshalb - auffallen, ohne zu stoeren. */ -.uhr__perle--stunde::before { - top: 21.4%; - width: 5.4px; height: 5.4px; margin-left: -2.7px; - background: radial-gradient(circle at 36% 32%, #fff 0%, #ffb0b6 38%, #ff4257 72%, #7e1a24 100%); - box-shadow: - 0 0 5px 1px rgba(255, 80, 95, .6), - inset 0 0 0 .5px rgba(255, 255, 255, .5); -} - -@media (prefers-reduced-motion: reduce) { - /* Kein Nachfedern -- der Sprung bleibt, das Schwingen geht. */ - .uhr__perle { transition: none; } -} -@media (forced-colors: active) { - .uhr__zwoelf, .uhr__perle { display: none; } - .uhr__schatten, .uhr__kante { display: none; } -} - -/* SCHICHT 3: DAS GLAS -- ENTSPIEGELTES SAPHIRGLAS (07.09.2026) - - Nachgelesen statt geraten. Eine Entspiegelung (Anti-Reflex) wird im - Vakuum aufgedampft und senkt die Spiegelung auf unter ein Prozent; - das Zifferblatt wirkt dadurch SCHAERFER, nicht milchiger. Und das - Erkennungszeichen einer guten Beschichtung ist kein weisser Schleier, - sondern ein **topasblauer Schimmer**, der je nach Lichteinfall ueber - das Glas laeuft -- daran sieht ein Kenner sofort, dass da Saphirglas - sitzt und kein Mineralglas. - - Vorher lag hier genau das Gegenteil: ein breiter weisser Verlauf - ueber ein Fuenftel der Scheibe. Das ist die Spiegelung eines - UNBESCHICHTETEN Glases -- also ausgerechnet das Merkmal des - billigeren Materials. - - Jetzt drei Dinge, jedes davon aus der Sache begruendet: - 1. ein SCHMALER, harter Reflexbogen oben links -- die Kante der - Woelbung, das Einzige, was auch entspiegelt stehen bleibt - 2. der topasblaue Schimmer, diagonal, sehr leise - 3. die haarfeine helle Linie an der Oberkante -- die Schnittkante - des Glases selbst - - Quellen zur Beschichtung: reservoir-watch.com und botta-design.de - (siehe Antwort im Verlauf). */ -.uhr__glas { - position: absolute; inset: 10px; border-radius: 50%; - background: - linear-gradient(142deg, - rgba(255, 255, 255, .16) 0%, - rgba(255, 255, 255, .05) 7%, - transparent 15%), - linear-gradient(128deg, - transparent 30%, - rgba(120, 190, 255, .10) 44%, - rgba(150, 130, 255, .07) 52%, - transparent 64%); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, .22); - pointer-events: none; z-index: 3; -} - -.uhr__ring { - position: absolute; inset: 10px; width: calc(100% - 20px); height: calc(100% - 20px); - /* Bei Null soll der Ring OBEN beginnen, nicht rechts. */ - transform: rotate(-90deg); - z-index: 2; - overflow: visible; -} -/* DREI RINGE, ALLE SCHARF (07.09.2026). - - Filipe: "die minuten blau, [die stunden] schwarzsilber glaenzend und - die sekunden rot ... soll null unscharf sein, sehr schoen praezise." - - KEIN blur(), KEIN drop-shadow auf den Boegen. Ein Weichzeichner auf - einer Linie macht sie nicht edler, sondern unscharf -- genau das war - der Vorwurf, und er stimmte: An der alten Uhr lag ein blur(1.2px) auf - dem Schein und ein drop-shadow auf dem Sekundenbogen. - - Der GLANZ entsteht stattdessen aus dem Verlauf selbst. Echtes Metall - glaenzt nicht, weil es leuchtet, sondern weil es das Licht an - verschiedenen Stellen verschieden zurueckwirft: hell, dunkel, hell, - dunkel. Genau das machen die fuenf Stopps des Silberverlaufs im SVG. - Ein zweifarbiger Verlauf waere ein Farbverlauf; erst der Wechsel ist - Metall. - - Die Kappen sind BUTT und nicht rund. Eine runde Kappe steht ueber das - Ende hinaus -- bei "null Sekunden" saehe man dann trotzdem einen - Punkt, und die Anzeige waere um zwei Grad falsch. Praezise heisst - auch: nichts anzeigen, wo nichts ist. */ -.uhr__bahn, .uhr__stunde, .uhr__minute, .uhr__sekunde { - fill: none; - stroke-linecap: butt; -} -/* DIE BAHN IST EINE GEFRAESTE RILLE, kein blasser Kreis. - - Ein Zeiger laeuft bei einem guten Instrument IN einer Vertiefung, - nicht auf einer Flaeche. Eine Rille erkennt man an zweierlei: Sie ist - dunkler als ihre Umgebung, und an ihrer unteren Wand steht eine helle - Kante, wo das Licht von oben auftrifft. Beides steht hier -- die - Kante als zweiter, um ein Drittel nach unten versetzter Kreis. - - Die Breite folgt dem Ring, der darin laeuft: aussen 3,4 fuer die - Sekunde, in der Mitte 3,8, innen 4,4. Eine Rille, die schmaler ist - als ihr Zeiger, ist keine. */ -.uhr__bahn { stroke: rgba(4, 7, 13, .78); stroke-width: 4.4; } -.uhr__bahn:nth-of-type(1) { stroke-width: 3.6; } -.uhr__bahn:nth-of-type(2) { stroke-width: 4; } -.uhr__rillenkante circle { - fill: none; - stroke: rgba(190, 215, 245, .16); - stroke-width: 1; -} - -/* AUSSEN: die SEKUNDE in Rot, auf der laengsten Bahn (07.09.2026 - gedreht, Wunsch Filipe: "die minuten in der mitte und sekunden - aussen"). - - Das ist die Anordnung eines Chronographen und nicht die eines - Fortschrittsbalkens: Der schnellste Zeiger laeuft aussen, weil man - Bewegung dort am besten sieht -- und der langsamste innen, wo eine - kleine Drehung schon viel bedeutet. - - DIE ZAHLEN GEHOEREN ZUM RADIUS. Ein vertauschter Ring ohne - vertauschte Umfaenge sieht aus wie eine haengende Uhr: Der Bogen - endet dann nie dort, wo er soll. U = 2 * pi * r, ausgerechnet und - nicht geschaetzt. */ -.uhr__sekunde { - stroke: url(#uhr-sekunde-farbe); - stroke-width: 2.6; - stroke-dasharray: 285.88; /* r = 45.5 */ - stroke-dashoffset: 285.88; -} -/* MITTE: die MINUTE in Schwarzsilber -- der ruhige Bezug zwischen - den beiden farbigen Bahnen. */ -.uhr__minute { - stroke: url(#uhr-minute-farbe); - stroke-width: 3; - stroke-dasharray: 241.90; /* r = 38.5 */ - stroke-dashoffset: 241.90; -} -/* INNEN: die STUNDE in Rot. Am breitesten und am ruhigsten -- - sie bewegt sich in fuenf Minuten um ein halbes Grad. */ -.uhr__stunde { - stroke: url(#uhr-stunde-farbe); - stroke-width: 3.6; - stroke-dasharray: 197.92; /* r = 31.5 */ - stroke-dashoffset: 197.92; -} - -/* DER SCHLAGSCHATTEN JEDES BOGENS. Dieselbe Bahn, schwarz, ein wenig - breiter -- und um ein halbes Rastermass nach unten geschoben (im - HTML). Er macht aus einem aufgemalten Strich einen Koerper, der - ueber dem Zifferblatt liegt. */ -.uhr__schatten circle { - stroke: rgba(0, 0, 0, .62) !important; - stroke-width: 4.6; -} -.uhr__schatten .uhr__sekunde { stroke-width: 3.8; } -.uhr__schatten .uhr__minute { stroke-width: 4.2; } - -/* DIE BELEUCHTETE OBERKANTE. Duenner, hell, nach oben versetzt: Sie - schaut oben hervor und verschwindet unten -- genau das tut eine - gewoelbte Kante bei Licht von oben. Das ist der ganze Unterschied - zwischen einem Strich und einem Ring aus Material. */ -.uhr__kante circle { - stroke: rgba(255, 255, 255, .72) !important; - stroke-width: .9; -} -.uhr__kante .uhr__stunde { stroke: rgba(255, 255, 255, .5) !important; } - -/* DIE ZIFFERNINSEL (07.09.2026). - - Seit der Stundenring eine beleuchtete Oberkante hat, laeuft ein - heller Bogen hinter den Ziffern durch. pruef-buehne hat es sofort - gemeldet: schlechtester Kontrast 2,98:1 gegen rgb(125,147,165) -- - knapp unter der Grenze von 3:1, und zwar genau bei der Uhrzeit, also - der wichtigsten Angabe der ganzen Flaeche. - - Das ist kein Grund, die Kante wieder wegzunehmen. Auf einer echten - Uhr steht eine Anzeige, die ueber Zeigern liegt, auf einer eigenen - Flaeche -- einer vertieften Insel im Zifferblatt. Genau die steht - jetzt hier: dunkel in der Mitte, nach aussen weich auslaufend, damit - sie keine sichtbare Kante bekommt. Der Kontrast ist damit - unabhaengig davon, was hinter ihr vorbeilaeuft. - - Die Lehre allgemein: Wenn eine neue Schicht einen Text unlesbar - macht, ist die Antwort selten "Schicht weg" -- meistens fehlt dem - Text sein Untergrund. */ -.uhr__mitte { - position: relative; z-index: 4; - display: grid; justify-items: center; gap: 2px; line-height: 1; - padding: 6px 14px; -} -.uhr__mitte::before { - content: ""; position: absolute; inset: -6px -10px; - z-index: -1; pointer-events: none; - border-radius: 50%; - background: - radial-gradient(closest-side, rgba(4, 7, 13, .93) 0%, - rgba(4, 7, 13, .88) 52%, rgba(4, 7, 13, .55) 78%, transparent 100%); -} -.uhr__zeit { - font-size: 1.86rem; font-weight: 700; letter-spacing: -.015em; - /* Gleiche Zifferbreite: Ohne sie zappelt die Anzeige bei jedem - Minutenwechsel, weil die 1 schmaler ist als die 8. */ - font-variant-numeric: tabular-nums; - color: #f2f8ff; - /* DIE MISCHUNG IM TEXT, ohne Verlaufsschrift: zwei Schatten, einer - rot von links, einer babyblau von rechts. Ein Verlauf IN der - Schrift (background-clip: text) wuerde im Windows-Kontrastmodus zu - unsichtbarer Schrift -- derselbe Grund, aus dem die Ueberschrift - daneben eine feste Farbe traegt. */ - /* GRAVIERT STATT GEDRUCKT. Zwei zusaetzliche Schatten mit einem - halben Pixel Versatz: einer dunkel nach oben, einer hell nach - unten. Das ist genau das Lichtverhalten einer Vertiefung -- oben - faellt Schatten hinein, unten trifft das Licht die - gegenueberliegende Wand. Zusammen mit den beiden farbigen Scheinen - stehen die Ziffern damit IM Blatt statt darauf. */ - text-shadow: - 0 -.5px 0 rgba(0, 0, 0, .85), - 0 .5px 0 rgba(255, 255, 255, .22), - -6px 0 16px rgba(255, 95, 109, .38), - 6px 0 16px rgba(143, 216, 255, .42); -} -.uhr__sek { - /* .74rem = 11,84 px. Bei .68rem waren es 10,88 -- unter der Grenze von - 11,5, die pruef-css-klassen zieht, und sie hat recht: Diese Ziffern - stehen gesperrt und in Versalhöhe, dadurch wirken sie noch kleiner - als die Zahl sagt. */ - font-size: .74rem; font-weight: 700; - font-variant-numeric: tabular-nums; - letter-spacing: .18em; text-indent: .18em; - /* EIN EINGELASSENES FENSTER, kein Etikett. - - Auf einer Uhr sitzt eine Zusatzanzeige in einer AUSSPARUNG des - Zifferblatts -- man sieht durch das Blatt hindurch auf die Scheibe - darunter. Das erkennt man an zwei Dingen: der Schatten faellt oben - hinein, und unten steht eine helle Kante, wo das Licht die - gegenueberliegende Wand trifft. Genau diese beiden Schatten stehen - hier. Die abgeschnittene Ecke nimmt die Sprache der Kacheln auf. */ - padding: 1px 7px 1px 8px; - border-radius: 0; - clip-path: polygon(4px 0, 100% 0, 100% 100%, 0 100%, 0 4px); - color: #d8ecfb; - background: - linear-gradient(180deg, #05080e 0%, #0a1018 100%); - box-shadow: - inset 0 2px 3px -1px rgba(0, 0, 0, .95), - inset 0 -1px 0 rgba(190, 215, 240, .28), - 0 1px 0 rgba(255, 255, 255, .05); - box-shadow: inset 0 0 0 1px rgba(143, 216, 255, .16); -} - -/* Die Glocke in der Kachel wird in start.css gestaltet und nicht hier. - - Grund (07.09.2026, gefunden von pruef-css-klassen): `glocke.js` laeuft - auf ALLEN siebzehn Seiten und setzt die Klasse `glocke--kachel`, - sobald es einen Platz dafuer gibt. Heute gibt es den nur hier -- aber - die Regel gehoert dorthin, wo das Skript sie brauchen KANN, nicht - dorthin, wo es sie heute zufaellig braucht. Sonst sieht die Glocke - auf der naechsten Seite, die einen Platz bekommt, falsch aus, und - niemand sucht den Fehler in der Aufteilung der Stildateien. - - Die Pruefung hat genau das gemeldet: "gestaltet, aber nicht auf ihrer - Seite -- fehlt auf 17 Seiten". */ - -@media (max-width: 620px) { - /* Die Nut trennt Text von Instrumenten -- nebeneinander. Untereinander - traennte sie nichts und stuende quer im Bild. */ - .willkommen__rechts::before { display: none; } - /* Auf dem Handy rückt die Gruppe unter den Gruß und die Uhr wird - kleiner -- 108 px neben einer Überschrift lassen für den Namen - keine Zeile mehr übrig. */ - .willkommen { padding: 18px 16px; } - .willkommen__rechts { margin-left: 0; width: 100%; justify-content: flex-start; } - /* DER STAND RICHTET SICH NACH DER UHR, NICHT NACH DEM RAND. - - Am Rechner sitzt er rechts, weil dort auch die Uhr rechts sitzt. - Auf dem Handy rutscht die Uhr nach LINKS (Zeile darueber) -- der - Stand blieb dabei rechtsbuendig und stand als einziges Bauteil auf - der falschen Seite. Im Bildschirmfoto bei 412 px gesehen, nicht - hergeleitet. - - `align-self` statt `margin-left: 0`: Der Kasten soll auf die volle - Breite gehen duerfen, wenn drei Felder nebeneinander nicht mehr - passen -- dann verteilen sie sich, statt sich zu stauchen. */ - .willkommen__stand { - /* `margin-left: 0` steht hier nicht mehr: Der `auto`-Rand, den es - zuruecknehmen musste, ist seit dem 08.09.2026 aus der Grundregel - verschwunden. Eine Zeile, die nichts mehr aufhebt, sieht aus wie - eine Absicht und wird beim naechsten Mal mitgeschleppt. */ - width: 100%; - justify-content: space-around; - } - .stand { min-width: 0; flex: 1 1 0; padding: 10px 8px 9px; } - .uhr { width: 118px; height: 118px; padding: 4px; } - .uhr__zeit { font-size: 1.3rem; } -} - -@media (prefers-reduced-motion: reduce) { - /* Die Uhr springt ohnehin -- hier gibt es nichts abzuschalten. Die - Zeile steht trotzdem da, damit der nächste, der einen weichen - Zeiger einbaut, sie findet. */ - .uhr__stunde, .uhr__minute, .uhr__sekunde { transition: none; } -} -@media (forced-colors: active) { - .uhr { background: Canvas; box-shadow: none; border: 1px solid CanvasText; } - .uhr__skala, .uhr__glas { display: none; } -} - -/* ===================================================================== - GRUPPEN LASSEN SICH ZUKLAPPEN (07.09.2026) - - Wunsch Filipe: "diese kategorien soll man auch zu und aufmachen - koennen so wie es einem lieb ist gerade." - - DIE UEBERSCHRIFT IST DER SCHALTER. Ein kleines Dreieck daneben waere - ein Ziel von 14 Pixeln; die ganze Zeile ist eines von 40. Deshalb ist - der Kopf ein