/* =====================================================================
Der Benachrichtigungs-Knopf — auf JEDER Seite, in der Kopfleiste.
Wunsch Filipe (05.09.2026), mit dem Bild eines "Benachrichtigungen
aus"-Knopfes: *"ich will auch sowas und dass es perfekt funktioniert
auf der seite fuer jeden."*
---------------------------------------------------------------------
WARUM IN DER KOPFLEISTE UND NICHT AUF EINER EINSTELLUNGSSEITE
Dasselbe Argument wie bei der Suche, die hier schon so gebaut ist:
Was man nur auf einer Extraseite findet, benutzt niemand. Der Knopf
sagt jederzeit, woran man ist -- Glocke an oder durchgestrichen.
---------------------------------------------------------------------
DER KNOPF SAGT IMMER DIE WAHRHEIT, auch wenn sie unbequem ist
Es gibt mehr Zustaende als "an" und "aus", und jeder braucht einen
anderen Satz. Ein Knopf, der bei einem abgelehnten Recht einfach
nichts tut, ist der sichere Weg zu "das funktioniert nicht":
geht nicht Der Browser kann kein Push (aeltere Fassungen).
iPhone Safari kann es NUR nach "Zum Home-Bildschirm".
Im Reiter gibt es die Berechtigung ueberhaupt nicht.
verboten Schon einmal abgelehnt. Das laesst sich per
JavaScript NICHT zuruecknehmen -- man muss es in den
Browsereinstellungen tun. Also wird genau das gesagt.
aus / an der Normalfall.
===================================================================== */
(() => {
'use strict';
/* Nur im Workspace, und nicht auf der Anmeldeseite -- dort ist
niemand angemeldet, den man benachrichtigen koennte. */
if (!location.pathname.startsWith('/workspace/')) return;
if (/\/workspace\/(index\.html)?$/.test(location.pathname)) return;
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
const kannPush = 'serviceWorker' in navigator && 'PushManager' in window
&& 'Notification' in window;
/* Laeuft die Seite als installierte App? Auf dem iPhone ist das die
Bedingung dafuer, dass es ueberhaupt geht. */
const alsApp = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
const istApple = /iPad|iPhone|iPod/.test(navigator.userAgent)
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
let anmeldung = null;
let knopf = null;
const b64uZuBytes = (s) => {
const roh = atob(String(s).replace(/-/g, '+').replace(/_/g, '/')
+ '='.repeat((4 - String(s).length % 4) % 4));
return Uint8Array.from(roh, (c) => c.charCodeAt(0));
};
const bytesZuB64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/** Ein Name, an dem man sein Geraet in der Liste wiedererkennt. */
function geraetName() {
const u = navigator.userAgent;
const os = /Windows/.test(u) ? 'Windows'
: /Android/.test(u) ? 'Android'
: istApple ? (/iPad/.test(u) ? 'iPad' : 'iPhone')
: /Mac/.test(u) ? 'Mac' : /Linux/.test(u) ? 'Linux' : 'Gerät';
const browser = /Edg\//.test(u) ? 'Edge'
: /Chrome\//.test(u) ? 'Chrome'
: /Firefox\//.test(u) ? 'Firefox'
: /Safari\//.test(u) ? 'Safari' : 'Browser';
return `${os} · ${browser}${alsApp ? ' (App)' : ''}`;
}
/** Was ist gerade der Fall? */
function lage() {
if (!kannPush) return 'geht-nicht';
if (istApple && !alsApp) return 'iphone';
if (Notification.permission === 'denied') return 'verboten';
return anmeldung ? 'an' : 'aus';
}
const TEXTE = {
'geht-nicht': { text: 'Hier nicht möglich', titel: 'Dieser Browser kann keine Benachrichtigungen.' },
iphone: {
text: 'Erst zum Home-Bildschirm',
titel: 'Auf dem iPhone gehen Benachrichtigungen nur, wenn die Seite '
+ 'zum Home-Bildschirm hinzugefügt wurde: Teilen-Knopf → „Zum Home-Bildschirm“. '
+ 'Danach hier noch einmal antippen.',
},
verboten: {
text: 'Vom Browser blockiert',
titel: 'Du hast Benachrichtigungen für diese Seite einmal abgelehnt. Das lässt sich '
+ 'nur in den Browsereinstellungen wieder erlauben — bei Chrome über das Symbol '
+ 'links in der Adresszeile.',
},
aus: { text: 'Benachrichtigungen aus', titel: 'Antippen, um Erinnerungen zu bekommen.' },
an: { text: 'Benachrichtigungen an', titel: 'Antippen, um sie auf diesem Gerät wieder abzuschalten.' },
};
function zeichnen() {
if (!knopf) return;
const l = lage();
const t = TEXTE[l];
knopf.dataset.lage = l;
knopf.title = t.titel;
knopf.setAttribute('aria-label', t.titel);
knopf.querySelector('.glocke__text').textContent = t.text;
/* aria-pressed sagt einem Vorleseprogramm, dass es ein Schalter
ist und wie er gerade steht. */
knopf.setAttribute('aria-pressed', l === 'an' ? 'true' : 'false');
knopf.disabled = l === 'geht-nicht';
}
async function anmelden() {
const recht = await Notification.requestPermission();
if (recht !== 'granted') { zeichnen(); return; }
const reg = await navigator.serviceWorker.register('/workspace/sw.js', { scope: '/workspace/' });
await navigator.serviceWorker.ready;
const a = await fetch('/workspace/api/push/schluessel', { credentials: 'same-origin' });
const { schluessel } = await a.json();
const abo = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: b64uZuBytes(schluessel),
});
const roh = abo.toJSON();
await fetch('/workspace/api/push/anmelden', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpunkt: abo.endpoint,
p256dh: roh.keys?.p256dh || bytesZuB64u(abo.getKey('p256dh')),
auth: roh.keys?.auth || bytesZuB64u(abo.getKey('auth')),
geraet: geraetName(),
}),
});
anmeldung = abo;
zeichnen();
/* Sofort eine Probe. Das ist der Unterschied zwischen "sollte
gehen" und "geht": Wer den Knopf drueckt, will WISSEN, dass es
ankommt -- und nicht bis morgen warten. */
await fetch('/workspace/api/push/probe', {
method: 'POST', credentials: 'same-origin',
}).catch(() => {});
}
async function abmelden() {
try {
if (anmeldung) {
await fetch('/workspace/api/push/abmelden', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpunkt: anmeldung.endpoint }),
});
await anmeldung.unsubscribe();
}
} catch { /* der Servereintrag ist weg, das genuegt */ }
anmeldung = null;
zeichnen();
}
async function bauen() {
/* WO DIE GLOCKE HINGEHÖRT (07.09.2026, Wunsch Filipe: "der
benachrichtigungs button soll auch von der leiste in diese kachel
gesetzt werden").
Gibt es auf der Seite einen ausgewiesenen Platz, kommt sie
dorthin -- auf der Startseite in die Begrüßungskachel, neben die
Uhr. Das ist der richtige Ort: Benachrichtigungen sind eine
Einstellung zum eigenen Tag, keine Navigation.
Auf allen anderen Seiten gibt es diesen Platz nicht, und dort
bleibt die Leiste. Deshalb ein Rückfall und keine Umstellung:
Eine Glocke, die auf zwölf Seiten verschwindet, weil sie auf
einer umgezogen ist, wäre keine Verbesserung. Und weil die
Kopfleiste am 06.09. schon einmal an einem sechsten Element
zerbrochen ist, wird sie hier nebenbei um eines leichter. */
/* SEIT DEM 09.09.2026 GIBT ES ZWEI PLAETZE (screen2).
Die Glocke geht nach LINKS neben den Ring, der Tagesruf bleibt
rechts neben der Uhr. Welcher wohin, folgt der Bedeutung: Der
Wecker ist eine Uhrzeit und gehoert zur Uhr; die Glocke
entscheidet, ob man ueberhaupt etwas ueber sein Team erfaehrt --
und der Ring links zeigt genau das.
Gibt es den linken Platz nicht (jede Seite ausser der
Startseite), faellt die Glocke wie bisher auf den rechten
zurueck und von dort auf die Kopfleiste. Zwei Rueckfaelle, kein
Sonderfall: Ein Knopf, der auf zwoelf Seiten verschwindet, weil
er auf einer umgezogen ist, waere keine Verbesserung. */
const platzLinks = document.getElementById('glocke-platz-links');
const platz = platzLinks || document.getElementById('glocke-platz');
const kopf = platz || document.querySelector('.kopfleiste__rechts');
if (!kopf || document.getElementById('glocke')) return;
knopf = el('button', 'glocke');
knopf.type = 'button';
knopf.id = 'glocke';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'glocke__zeichen');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = ''
/* "kloeppel" ohne Umlaut: Ein ö in einem Klassennamen ist erlaubt,
aber es überlebt nicht jede Datei, die zwischendurch in einer
anderen Kodierung gespeichert wird -- und im ganzen Haus wird
ohnehin "oe"/"ue" geschrieben. */
+ ''
+ '';
knopf.append(svg);
knopf.append(el('span', 'glocke__text', '…'));
/* VOR dem Abmelden-Knopf, nicht dahinter: Abmelden ist die letzte
Handlung auf einer Seite und bleibt ganz rechts. Im eigenen Platz
gibt es nichts, wovor sie stehen müsste. */
const abmeldenKnopf = platz ? null : kopf.querySelector('.abmelden');
if (abmeldenKnopf) kopf.insertBefore(knopf, abmeldenKnopf);
else kopf.append(knopf);
if (platz) knopf.classList.add('glocke--kachel');
knopf.addEventListener('click', async () => {
const l = lage();
if (l === 'geht-nicht') return;
/* Bei 'iphone' und 'verboten' nichts tun ausser erklaeren -- der
Titel steht schon dran, und ein Klick, der nichts bewirkt und
nichts sagt, wirkt kaputt. */
if (l === 'iphone' || l === 'verboten') { window.alert(TEXTE[l].titel); return; }
knopf.disabled = true;
try {
if (l === 'an') await abmelden(); else await anmelden();
} catch (f) {
console.warn('[glocke]', f);
zeichnen();
}
knopf.disabled = false;
});
/* Steht hier schon eine Anmeldung? */
if (kannPush && Notification.permission === 'granted') {
try {
const reg = await navigator.serviceWorker.getRegistration('/workspace/');
anmeldung = reg ? await reg.pushManager.getSubscription() : null;
} catch { anmeldung = null; }
}
zeichnen();
/* Der Tagesruf gehoert IMMER an den rechten Platz -- auch wenn die
Glocke links sitzt. `platz` zeigt jetzt auf den linken, deshalb
wird der rechte hier eigens geholt. */
const platzRechts = document.getElementById('glocke-platz');
if (platzRechts) await tagesrufBauen(platzRechts);
}
/* =====================================================================
DER TAGESRUF -- der Wecker fuer offene Aufgaben (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."
WARUM IN DIESER DATEI UND NICHT IN EINER EIGENEN. Er braucht genau
dasselbe Vorwissen wie die Glocke: Kann der Browser ueberhaupt
Push? Laeuft die Seite auf einem iPhone ausserhalb der App? Ist
die Berechtigung abgelehnt? Eine zweite Datei muesste das alles
noch einmal feststellen -- und beim naechsten Browser, der etwas
anders macht, wuerde eine der beiden Fassungen nachgezogen und die
andere vergessen. Dieselbe Frage gehoert an eine Stelle.
NUR IN DER KACHEL, nicht in der Kopfleiste. "neben diesem kreis"
war ausdruecklich der Ort -- und die Kopfleiste ist am 06.09.2026
schon einmal an einem sechsten Element zerbrochen. Wo es den Platz
nicht gibt, gibt es den Knopf nicht.
================================================================== */
let rufKnopf = null, rufFeld = null, rufAn = false, rufZeit = '09:00';
let rufRuhe = null; /* { ab, bis } -- die Ruhezeit des Hauses */
/* Faellt die gewaehlte Uhrzeit in die Ruhezeit?
Die Grenzen kommen vom Server (`/stand`), nicht aus einer hier
getippten Zahl: Sie stehen in einer Umgebungsvariablen und koennen
sich aendern. Eine zweite Zahl im Browser waere spaetestens dann
falsch, ohne dass es jemand merkt. */
function inRuhezeit(t) {
if (!rufRuhe || !/^\d{2}:\d{2}$/.test(t)) return false;
const h = Number(t.slice(0, 2));
/* Die Ruhezeit laeuft ueber Mitternacht (22 bis 7): "ab ODER bis",
nicht "ab UND bis". Mit UND waere sie leer. */
return rufRuhe.ab > rufRuhe.bis ? (h >= rufRuhe.ab || h < rufRuhe.bis)
: (h >= rufRuhe.ab && h < rufRuhe.bis);
}
function rufZeichnen() {
if (!rufKnopf) return;
const l = lage();
/* NUR 'an' UND 'aus' SIND BENUTZBARE LAGEN.
Im ersten Anlauf stand hier `l !== 'geht-nicht' && l !== 'iphone'`
-- damit galt 'verboten' als brauchbar, und der Knopf trug den
Satz "Einmal am Tag melden, wenn noch Aufgaben offen sind."
Beim Antippen kam dann die Absage. Gemessen im Browser: Chromium
meldet `Notification.permission === 'denied'`, der Knopf sah
einladend aus und tat nichts.
Ein Bedienelement, das erst NACH dem Klick sagt, dass es nicht
geht, ist die schlechtere Haelfte einer Fehlermeldung. Der Grund
gehoert davor -- in den Titel, den man beim Zeigen liest. */
const brauchbar = l === 'an' || l === 'aus';
rufKnopf.disabled = l === 'geht-nicht';
rufKnopf.dataset.an = rufAn && brauchbar ? 'ja' : 'nein';
rufKnopf.dataset.lage = l;
rufKnopf.setAttribute('aria-pressed', rufAn && brauchbar ? 'true' : 'false');
const satz = l === 'geht-nicht'
? 'Der tägliche Ruf braucht Benachrichtigungen — dieser Browser kann keine.'
: l === 'iphone'
? 'Der tägliche Ruf braucht Benachrichtigungen. Auf dem iPhone gehen die nur, '
+ 'wenn die Seite zum Home-Bildschirm hinzugefügt wurde: Teilen → „Zum Home-Bildschirm“.'
: l === 'verboten'
? 'Der tägliche Ruf braucht Benachrichtigungen — die sind für diese Seite im '
+ 'Browser blockiert. Das lässt sich nur in den Browsereinstellungen wieder erlauben.'
: rufAn
? `Täglich um ${rufZeit}, wenn noch Aufgaben offen sind. Antippen zum Abschalten.`
: 'Einmal am Tag melden, wenn noch Aufgaben offen sind.';
/* EINE UHRZEIT IN DER RUHEZEIT IST EIN STILLES NICHTS.
Der Server laesst zwischen 22 und 7 Uhr nichts durch -- das ist
richtig so. Aber wer 23:00 einstellt, bekaeme nie etwas und
saehe nur einen Knopf, der auf "an" steht. Genau die Sorte
Fehler, die niemand meldet, weil nichts kaputt aussieht.
Also wird es gesagt, an der Stelle, an der man es einstellt. */
const warnt = brauchbar && rufAn && inRuhezeit(rufZeit);
if (rufFeld) {
let hinweis = rufFeld.querySelector('.tagesruf__warnung');
if (warnt && !hinweis) {
hinweis = el('span', 'tagesruf__warnung');
rufFeld.append(hinweis);
}
if (hinweis) {
hinweis.hidden = !warnt;
if (warnt) {
hinweis.textContent = `Zwischen ${String(rufRuhe.ab).padStart(2, '0')}:00 und `
+ `${String(rufRuhe.bis).padStart(2, '0')}:00 ist Ruhezeit — dann kommt nichts an.`;
}
}
}
rufKnopf.title = satz;
rufKnopf.setAttribute('aria-label', satz);
rufKnopf.querySelector('.tagesruf__text').textContent = rufAn && brauchbar ? rufZeit : 'Tagesruf';
/* Das Feld steht im normalen Fluss unter dem Knopf, nicht als
schwebendes Fenster: Ein Aufklappmenue braucht eine Position, ein
Schliessen, einen Klick nach draussen und eine Tastaturfalle --
vier Gelegenheiten fuer Fehler, damit eine Uhrzeit eingestellt
werden kann. */
if (rufFeld) rufFeld.hidden = !(rufAn && brauchbar);
}
async function rufSetzen(an, wert) {
const koerper = { art: 'tagesruf', an };
/* `wert` NUR mitschicken, wenn er sich aendert -- der Server laesst
ihn sonst stehen. Beide Seiten muessen sich darin einig sein,
sonst loescht ein Ein-/Ausschalten die eingestellte Uhrzeit. */
if (wert !== undefined) koerper.wert = wert;
const a = await fetch('/workspace/api/push/art', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(koerper),
});
if (!a.ok) throw new Error('Konnte nicht gespeichert werden.');
const e = await a.json();
rufAn = !!e.an;
if (e.wert) rufZeit = e.wert;
rufZeichnen();
}
async function tagesrufBauen(platz) {
if (document.getElementById('tagesruf')) return;
rufKnopf = el('button', 'tagesruf');
rufKnopf.type = 'button';
rufKnopf.id = 'tagesruf';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'tagesruf__zeichen');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
/* Ein WECKER, keine zweite Glocke: Zwei gleiche Zeichen
nebeneinander sind zwei Knoepfe, von denen man einen falsch
drueckt. Runder Koerper, zwei Fuesse, zwei Ohren, Zeiger auf
kurz vor zwoelf. */
svg.innerHTML = ''
+ ''
+ ''
+ '';
rufKnopf.append(svg);
rufKnopf.append(el('span', 'tagesruf__text', 'Tagesruf'));
rufFeld = el('label', 'tagesruf__feld');
rufFeld.hidden = true;
rufFeld.append(el('span', 'tagesruf__feld-schild', 'Uhrzeit'));
const eingabe = document.createElement('input');
eingabe.type = 'time';
eingabe.className = 'tagesruf__zeit';
eingabe.step = '300';
rufFeld.append(eingabe);
platz.append(rufKnopf, rufFeld);
rufKnopf.addEventListener('click', async () => {
const l = lage();
if (l === 'geht-nicht') return;
if (l === 'iphone' || l === 'verboten') { window.alert(TEXTE[l].titel); return; }
rufKnopf.disabled = true;
try { await rufSetzen(!rufAn); } catch (f) { console.warn('[tagesruf]', f); }
rufKnopf.disabled = false;
});
/* `change`, nicht `input`: Ein Zeitfeld meldet bei jedem Tastendruck
-- "0", "09", "09:0", "09:00" waeren vier Anfragen fuer eine
Einstellung, und die drei ersten sind ungueltig. */
eingabe.addEventListener('change', async () => {
if (!eingabe.value) return;
try { await rufSetzen(true, eingabe.value); } catch (f) { console.warn('[tagesruf]', f); }
});
/* Den Stand holen. Schlaegt das fehl, bleibt der Knopf auf "aus"
stehen -- er behauptet dann nichts Falsches, und ein Klick
richtet ihn wieder. */
try {
const a = await fetch('/workspace/api/push/stand');
if (a.ok) {
const stand = await a.json();
const art = (stand.arten || []).find((x) => x.schluessel === 'tagesruf');
if (art) { rufAn = !!art.an; if (art.wert) rufZeit = art.wert; }
if (stand.ruhe) rufRuhe = { ab: Number(stand.ruhe.ab), bis: Number(stand.ruhe.bis) };
}
} catch { /* offline -- der Knopf steht auf aus */ }
eingabe.value = rufZeit;
rufZeichnen();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bauen);
} else bauen();
})();