Termine, die von allein weiterlaufen -- bis man sie abstellt
Wunsch: "ich will dass ich da im kalender auch sachen machen die jede
woche jeden monat automatisiert immer weiter laufen so wie ich es
auswaehle bis ich es selber deaktiviere. aktiviere diese option fuer
jeden."
Acht Rhythmen (taeglich, werktags, woechentlich, alle zwei Wochen,
monatlich am Datum, am Monatsletzten, am n-ten Wochentag, jaehrlich),
wahlweise mit Enddatum oder ohne Ende. Fuer JEDE Rolle -- Creator und
Scouts legen sie fuer sich selbst an, mit denselben Grenzen wie beim
einzelnen Termin.
WARUM ECHTE TERMINE UND KEINE GERECHNETEN AUSPRAEGUNGEN
Der naheliegende Weg waere gewesen, die Wiederholung nur beim Anzeigen
auszurechnen. Das waere ein stiller Ausfall geworden: ACHT andere Stellen
lesen die Tabelle termine direkt -- Calls, Protokolle, Berichte,
Hinweise/Ampel, Suche, Aufgaben ("naechster Termin"), Uebersicht und die
Startseite. In all diesen Ansichten haette schlicht nichts gestanden, und
aufgefallen waere es erst Monate spaeter. Stattdessen macht ein
Nachfueller aus der Regel echte Zeilen (Horizont 180 Tage) -- jedes andere
Modul sieht sie mit, ohne dass dort eine Zeile geaendert werden musste.
Beim geplanten ICS-Abo gilt dasselbe.
KEIN ZEITGEBER. Der Nachfueller laeuft beim Serverstart, beim Anlegen und
Aendern und beim Oeffnen des Kalenders (dort hoechstens alle fuenf
Minuten). Er ist idempotent. Ein Cron waere eine weitere Sache gewesen,
die stillschweigend ausfallen kann.
WAS BEIM ABSTELLEN PASSIERT: Vergangene Termine und alles, was jemand von
Hand angefasst hat (verschoben, umbenannt, abgehakt), bleibt stehen. Nur
die unberuehrte Zukunft wird aus dem Kalender genommen. Wer einen
einzelnen Tag loescht, streicht nur diesen einen -- der Tag wird als
Ausnahme vermerkt, sonst legte der Nachfueller ihn wieder an.
ZEITUMSTELLUNG: Gerechnet wird auf reinen Datumstexten in UTC-Mittag, die
Uhrzeit wird als Text angehaengt. 18:00 bleibt dadurch 18:00 und wandert
im Oktober nicht auf 17:00.
DIE SICHTBARKEITSREGEL STEHT JETZT NUR NOCH EINMAL (workspace.js,
termineSichtbar) statt zweimal fast gleich. Zwei Fassungen waeren
auseinandergelaufen -- und dann haette eine Wiederholung jemandem etwas
gezeigt, was der einzelne Termin ihm verbirgt.
IN DER OBERFLAECHE beschriften sich die Rhythmen nach dem gewaehlten
Datum ("jeden Dienstag", "jeden Monat am letzten Dienstag") statt
abstrakt ("woechentlich"), inklusive des ehrlichen Hinweises, dass "jeden
31." den Februar auslaesst. Darunter steht der ganze Satz in Worten. Eine
neue Leiste "Laeuft von allein" zeigt alle laufenden Wiederholungen mit
Abstell-Schalter -- was von selbst weiterlaeuft, muss man sehen koennen.
GEPRUEFT: 67 Pruefungen (server/pruef-serien.mjs), darunter von Hand
nachgeschlagene Datumsangaben fuer jeden Takt (der letzte Dienstag im
Dezember 2026 ist der 29., nicht der 22.; der 29. Februar nur in
Schaltjahren) und vier GEGENPROBEN, die beweisen, dass die Pruefungen
auch "nicht in Ordnung" sagen koennen. Die Anzahl der Pruefungen steht in
der Bedingung, nicht nur im Meldetext. Bestehende Pruefungen unveraendert
gruen: Kalender 84, Sicht 48, Startansicht 133, Protokoll 52, Ampel 47,
Handy 50, Aufgabenbrett 44, Sprung 43, Personen-Loeschen 40, Uebersicht
33, Workspace-Seiten 32, Formulare 19, Betreuung 18, Grosscheck 15,
Lesbarkeit 14.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -18,6 +18,7 @@ import { aufgabenRouter } from "./workspace-aufgaben.js";
|
||||
import { personenRouter } from "./workspace-personen.js";
|
||||
import { profilRouter } from "./workspace-profil.js";
|
||||
import { kalenderRouter } from "./workspace-kalender.js";
|
||||
import { serienRouter, serienStarten } from "./workspace-serien.js";
|
||||
import { dateienRouter } from "./workspace-dateien.js";
|
||||
import { bereicheRouter } from "./workspace-bereiche.js";
|
||||
import { reportRouter } from "./workspace-reports.js";
|
||||
@@ -120,6 +121,7 @@ app.use(aufgabenRouter);
|
||||
app.use(personenRouter);
|
||||
app.use(profilRouter);
|
||||
app.use(kalenderRouter);
|
||||
app.use(serienRouter);
|
||||
app.use(dateienRouter);
|
||||
app.use(bereicheRouter);
|
||||
app.use(reportRouter);
|
||||
@@ -137,6 +139,14 @@ app.use(contentRouter);
|
||||
Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */
|
||||
sicherungStarten();
|
||||
|
||||
/* Wiederkehrende Termine einmal auffuellen. Kein Takt, kein Dienst --
|
||||
siehe workspace-serien.js: Der Nachfueller legt nur an, was fehlt, und
|
||||
laeuft ausserdem bei jedem Oeffnen des Kalenders. Der Lauf beim Start
|
||||
sorgt dafuer, dass die Termine auch dann bereitliegen, wenn an einem
|
||||
Tag niemand den Kalender oeffnet, aber jemand die Startseite, die
|
||||
Call-Liste oder die Suche benutzt -- die lesen dieselbe Tabelle. */
|
||||
serienStarten();
|
||||
|
||||
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
|
||||
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
|
||||
ohne diese Sperre daher nicht nur die eigentliche Website aus, sondern auch den Server-
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
/* =====================================================================
|
||||
Prüfung der wiederkehrenden Termine (02.09.2026).
|
||||
|
||||
Drei Teile, und der erste ist der wichtigste:
|
||||
|
||||
1. DIE RECHNUNG, gegen von Hand nachgeschlagene Datumsangaben.
|
||||
Eine Terminserie ist genau die Sorte Sache, die still falsch sein
|
||||
kann: "jeden letzten Freitag" um eine Woche daneben fällt beim
|
||||
Ansehen niemandem auf, sondern erst, wenn jemand vor einem leeren
|
||||
Meeting sitzt. Deshalb werden hier feste Daten geprüft und nicht
|
||||
"es kommt irgendetwas heraus".
|
||||
|
||||
2. DER WEG DURCH DIE ANWENDUNG: anlegen, nachfüllen, einen einzelnen
|
||||
Tag streichen, abstellen, wieder anstellen.
|
||||
|
||||
3. DIE OBERFLÄCHE im echten Browser, samt der Frage, ob Formular und
|
||||
Serverliste DENSELBEN Satz sagen.
|
||||
|
||||
Zu jeder Prüfung, die "alles in Ordnung" melden kann, gehört eine
|
||||
GEGENPROBE, die beweist, dass sie auch "nicht in Ordnung" sagen kann
|
||||
(Projektregel vom 01.09.2026). Sie stehen unten ausdrücklich als
|
||||
solche gekennzeichnet. Und die ANZAHL der gelaufenen Prüfungen wird
|
||||
am Ende gegen eine Erwartung gehalten -- ein grüner Lauf mit der
|
||||
halben Menge Prüfungen ist kein grüner Lauf.
|
||||
===================================================================== */
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-serien-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
process.env.PORT = "4189";
|
||||
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:4189";
|
||||
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.();
|
||||
|
||||
const tagText = (d = new Date()) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
|
||||
+ `-${String(d.getDate()).padStart(2, "0")}`;
|
||||
|
||||
let fehler = 0;
|
||||
let geprueft = 0;
|
||||
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
const gleich = (a, b, t) => ok(JSON.stringify(a) === JSON.stringify(b),
|
||||
`${t}${JSON.stringify(a) === JSON.stringify(b) ? "" : `\n erwartet ${JSON.stringify(b)}\n bekommen ${JSON.stringify(a)}`}`);
|
||||
|
||||
/* ===================================================================
|
||||
TEIL 1 — Die Rechnung
|
||||
===================================================================
|
||||
Alle Daten hier sind von Hand nachgeschlagen. 2026 ist KEIN
|
||||
Schaltjahr, der 1. September 2026 ist ein Dienstag. */
|
||||
|
||||
const { serienTage, taktText, serienText } = await import("./workspace-serien.js");
|
||||
const regel = (o) => ({ uhrzeit: "18:00", ende_tag: null, ...o });
|
||||
|
||||
console.log("\n— Rechnung: Takte —");
|
||||
|
||||
gleich(serienTage(regel({ takt: "woechentlich", start_tag: "2026-09-01" }), "2026-09-01", "2026-09-30"),
|
||||
["2026-09-01", "2026-09-08", "2026-09-15", "2026-09-22", "2026-09-29"],
|
||||
"wöchentlich ab Dienstag, 01.09.2026 → alle fünf Dienstage im September");
|
||||
|
||||
gleich(serienTage(regel({ takt: "zweiwoechentlich", start_tag: "2026-09-01" }), "2026-09-01", "2026-10-15"),
|
||||
["2026-09-01", "2026-09-15", "2026-09-29", "2026-10-13"],
|
||||
"alle zwei Wochen → jeder zweite Dienstag, über den Monatswechsel hinweg");
|
||||
|
||||
gleich(serienTage(regel({ takt: "werktags", start_tag: "2026-09-04" }), "2026-09-04", "2026-09-11"),
|
||||
["2026-09-04", "2026-09-07", "2026-09-08", "2026-09-09", "2026-09-10", "2026-09-11"],
|
||||
"werktags → das Wochenende 05./06.09. fällt aus, wird nicht verschoben");
|
||||
|
||||
gleich(serienTage(regel({ takt: "taeglich", start_tag: "2026-09-01" }), "2026-09-01", "2026-09-05"),
|
||||
["2026-09-01", "2026-09-02", "2026-09-03", "2026-09-04", "2026-09-05"],
|
||||
"täglich → fünf Tage sind fünf Termine");
|
||||
|
||||
console.log("\n— Rechnung: Monatstakte, die still falsch sein können —");
|
||||
|
||||
/* Der 31.: Februar, April, Juni, September und November haben ihn
|
||||
nicht. Ausgelassen, NICHT auf den 28./30. vorgezogen. */
|
||||
gleich(serienTage(regel({ takt: "monatlich_datum", start_tag: "2026-01-31" }), "2026-01-01", "2026-12-31"),
|
||||
["2026-01-31", "2026-03-31", "2026-05-31", "2026-07-31", "2026-08-31",
|
||||
"2026-10-31", "2026-12-31"],
|
||||
"jeden Monat am 31. → sieben Termine 2026, Monate ohne 31. fallen aus");
|
||||
|
||||
gleich(serienTage(regel({ takt: "monatlich_letzter", start_tag: "2026-01-31" }), "2026-01-01", "2026-05-31"),
|
||||
["2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30", "2026-05-31"],
|
||||
"jeden Monatsletzten → 28.02. (2026 ist kein Schaltjahr), 30.04.");
|
||||
|
||||
gleich(serienTage(regel({ takt: "monatlich_letzter", start_tag: "2028-01-31" }), "2028-02-01", "2028-02-29"),
|
||||
["2028-02-29"],
|
||||
"Monatsletzter im Schaltjahr 2028 → der 29. Februar");
|
||||
|
||||
/* 08.09.2026 ist der ZWEITE Dienstag. Danach: 13.10. (Okt. 1 = Do,
|
||||
erster Di = 6.), 10.11. (Nov. 1 = So, erster Di = 3.). */
|
||||
gleich(serienTage(regel({ takt: "monatlich_wochentag", start_tag: "2026-09-08" }), "2026-09-01", "2026-11-30"),
|
||||
["2026-09-08", "2026-10-13", "2026-11-10"],
|
||||
"jeden zweiten Dienstag im Monat → 08.09., 13.10., 10.11.");
|
||||
|
||||
/* 29.09.2026 ist der LETZTE Dienstag im September (der 30. ist ein
|
||||
Mittwoch). Rückwärts gerechnet: Oktober 27., November 24. Wer
|
||||
vorwärts zählte ("der vierte"), läge im Oktober richtig und im
|
||||
Dezember falsch -- deshalb steht der Dezember mit in der Prüfung:
|
||||
Dezember 2026 hat FÜNF Dienstage (1., 8., 15., 22., 29.). */
|
||||
gleich(serienTage(regel({ takt: "monatlich_wochentag", start_tag: "2026-09-29" }), "2026-09-01", "2026-12-31"),
|
||||
["2026-09-29", "2026-10-27", "2026-11-24", "2026-12-29"],
|
||||
"jeden LETZTEN Dienstag im Monat → im Dezember der 29., nicht der 22.");
|
||||
|
||||
gleich(serienTage(regel({ takt: "jaehrlich", start_tag: "2028-02-29" }), "2028-01-01", "2033-12-31"),
|
||||
["2028-02-29", "2032-02-29"],
|
||||
"jährlich am 29. Februar → nur in Schaltjahren, 2029–2031 fallen aus");
|
||||
|
||||
console.log("\n— Rechnung: Grenzen —");
|
||||
|
||||
gleich(serienTage(regel({ takt: "woechentlich", start_tag: "2026-09-01", ende_tag: "2026-09-16" }),
|
||||
"2026-09-01", "2026-12-31"),
|
||||
["2026-09-01", "2026-09-08", "2026-09-15"],
|
||||
"Enddatum → nach dem 16.09. kommt nichts mehr");
|
||||
|
||||
gleich(serienTage(regel({ takt: "woechentlich", start_tag: "2026-09-22" }), "2026-09-01", "2026-09-30"),
|
||||
["2026-09-22", "2026-09-29"],
|
||||
"vor dem Start liegt nichts, auch wenn der Zeitraum früher beginnt");
|
||||
|
||||
ok(serienTage(regel({ takt: "taeglich", start_tag: "2020-01-01" }), "2026-09-01", "2026-09-03").length === 3,
|
||||
"eine Serie von 2020 kostet keine tausend Durchläufe (vorgespult) → drei Tage");
|
||||
|
||||
ok(serienTage(regel({ takt: "unsinn", start_tag: "2026-09-01" }), "2026-09-01", "2026-09-30").length === 0,
|
||||
"ein unbekannter Takt liefert nichts, statt etwas zu erfinden");
|
||||
|
||||
console.log("\n— Rechnung: der Klartext —");
|
||||
|
||||
gleich(taktText({ takt: "woechentlich", start_tag: "2026-09-01" }), "jeden Dienstag",
|
||||
"Klartext wöchentlich");
|
||||
gleich(taktText({ takt: "monatlich_wochentag", start_tag: "2026-09-29" }),
|
||||
"jeden Monat am letzten Dienstag", "Klartext letzter Wochentag");
|
||||
gleich(taktText({ takt: "monatlich_wochentag", start_tag: "2026-09-08" }),
|
||||
"jeden Monat am zweiten Dienstag", "Klartext n-ter Wochentag");
|
||||
gleich(serienText({ takt: "woechentlich", start_tag: "2026-09-01", uhrzeit: "18:00", ende_tag: null }),
|
||||
"jeden Dienstag um 18:00 Uhr, bis du sie abstellst",
|
||||
"der ganze Satz nennt die Uhrzeit UND dass die Serie ohne Ende läuft");
|
||||
|
||||
/* ===================================================================
|
||||
TEIL 2 — Der Weg durch die Anwendung
|
||||
=================================================================== */
|
||||
|
||||
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();
|
||||
|
||||
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 idTili = anlegen("Tili", "creator", "CODE-TILI-0001");
|
||||
|
||||
const zahl = (sql, ...w) => d.prepare(sql).get(...w)?.n ?? 0;
|
||||
|
||||
const { chromium } = await import(
|
||||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await chromium.launch();
|
||||
|
||||
async function anmelden(rolle, code) {
|
||||
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
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;
|
||||
}
|
||||
|
||||
/** Ein API-Aufruf aus der angemeldeten Seite heraus -- mit echter
|
||||
* Sitzung, echtem Origin, echter Schranke. */
|
||||
const api = (seite, pfad, optionen) => seite.evaluate(async ([p, o]) => {
|
||||
const a = await fetch(p, {
|
||||
credentials: "same-origin",
|
||||
...(o || {}),
|
||||
headers: o?.koerper ? { "Content-Type": "application/json" } : undefined,
|
||||
body: o?.koerper ? JSON.stringify(o.koerper) : undefined,
|
||||
});
|
||||
return { status: a.status, daten: await a.json().catch(() => ({})) };
|
||||
}, [pfad, optionen]);
|
||||
|
||||
const seiteDogi = await anmelden("admin", "CODE-DOGI-0001");
|
||||
|
||||
console.log("\n— Anlegen über das Terminformular —");
|
||||
|
||||
/* Start morgen, damit die Serie ganz in der Zukunft liegt und die
|
||||
Aufräum-Regeln ("was noch bevorsteht") überhaupt greifen können. */
|
||||
const morgen = tagText(new Date(Date.now() + 86400_000));
|
||||
const antwort = await api(seiteDogi, "/workspace/api/termine", {
|
||||
method: "POST",
|
||||
koerper: {
|
||||
titel: "Wochen-Call mit Tili", art: "call", beginn: morgen + "T18:00",
|
||||
dauer_min: 45, creator_id: idTili, teilnehmer_id: idTili,
|
||||
wiederholung: { takt: "woechentlich", ende_tag: null },
|
||||
},
|
||||
});
|
||||
ok(antwort.status === 201 && antwort.daten.serie_id > 0,
|
||||
"ein Termin mit Rhythmus legt eine Serie an (201 + serie_id)");
|
||||
const serieId = antwort.daten.serie_id;
|
||||
|
||||
const erwarteteAnzahl = serienTage(
|
||||
{ takt: "woechentlich", start_tag: morgen, uhrzeit: "18:00", ende_tag: null },
|
||||
tagText(), tagText(new Date(Date.now() + 180 * 86400_000))).length;
|
||||
const angelegt = zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ?", serieId);
|
||||
ok(erwarteteAnzahl > 20, `der Horizont von 180 Tagen ergibt ${erwarteteAnzahl} Termine (mehr als 20)`);
|
||||
ok(angelegt === erwarteteAnzahl,
|
||||
`die Serie hat genau diese ${erwarteteAnzahl} Termine wirklich angelegt (${angelegt})`);
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id IS NULL") === 0,
|
||||
"und KEINEN einzelnen Termin daneben – der erste stünde sonst doppelt da");
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND beginn LIKE '%T18:00'", serieId) === angelegt,
|
||||
"alle Ausprägungen stehen auf 18:00 – auch über die Zeitumstellung hinweg");
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND creator_id = ? AND dauer_min = 45",
|
||||
serieId, idTili) === angelegt,
|
||||
"Zuordnung und Dauer sind auf jeder Ausprägung angekommen");
|
||||
|
||||
console.log("\n— Nachfüllen legt nichts doppelt an —");
|
||||
|
||||
const { serieNachfuellen, nachfuellenAlle } = await import("./workspace-serien.js");
|
||||
const nochmal = serieNachfuellen(serieId);
|
||||
ok(nochmal === 0, "ein zweiter Lauf des Nachfüllers legt 0 neue Termine an");
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ?", serieId) === angelegt,
|
||||
"die Anzahl ist unverändert");
|
||||
|
||||
console.log("\n— Einen einzelnen Tag streichen —");
|
||||
|
||||
const zweiter = d.prepare(
|
||||
"SELECT id, serie_tag FROM termine WHERE serie_id = ? ORDER BY beginn LIMIT 1 OFFSET 1").get(serieId);
|
||||
const geloescht = await api(seiteDogi, "/workspace/api/termine/" + zweiter.id, { method: "DELETE" });
|
||||
ok(geloescht.status === 200, "ein einzelner Serientermin lässt sich löschen");
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termin_serien_aus WHERE serie_id = ? AND tag = ?",
|
||||
serieId, zweiter.serie_tag) === 1,
|
||||
"der Tag ist als Ausnahme vermerkt");
|
||||
serieNachfuellen(serieId);
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_tag = ?",
|
||||
serieId, zweiter.serie_tag) === 0,
|
||||
"der Nachfüller bringt ihn NICHT zurück");
|
||||
|
||||
/* GEGENPROBE. Ohne sie wäre die Zeile darüber wertlos: Sie wäre auch
|
||||
dann grün, wenn der Nachfüller überhaupt nichts täte. Wird die
|
||||
Ausnahme entfernt, MUSS der Termin wiederkommen. */
|
||||
d.prepare("DELETE FROM termin_serien_aus WHERE serie_id = ? AND tag = ?").run(serieId, zweiter.serie_tag);
|
||||
serieNachfuellen(serieId);
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_tag = ?",
|
||||
serieId, zweiter.serie_tag) === 1,
|
||||
"GEGENPROBE: ohne die Ausnahme legt der Nachfüller ihn sehr wohl wieder an");
|
||||
|
||||
console.log("\n— Abstellen räumt die Zukunft, aber nicht die Arbeit —");
|
||||
|
||||
/* Einen Termin von Hand anfassen: Er gilt danach als berührt und darf
|
||||
beim Abstellen nicht verschwinden. */
|
||||
const dritter = d.prepare(
|
||||
"SELECT id FROM termine WHERE serie_id = ? ORDER BY beginn LIMIT 1 OFFSET 2").get(serieId);
|
||||
await api(seiteDogi, "/workspace/api/termine/" + dritter.id, {
|
||||
method: "PATCH", koerper: { titel: "Von Hand umbenannt" },
|
||||
});
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE id = ? AND serie_beruehrt = 1", dritter.id) === 1,
|
||||
"ein von Hand geänderter Serientermin gilt als berührt");
|
||||
|
||||
const aus = await api(seiteDogi, "/workspace/api/serien/" + serieId, {
|
||||
method: "PATCH", koerper: { aktiv: 0 },
|
||||
});
|
||||
ok(aus.status === 200, "die Serie lässt sich abstellen");
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termin_serien WHERE id = ? AND aktiv = 0", serieId) === 1,
|
||||
"sie steht als abgestellt in der Datenbank");
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_beruehrt = 0", serieId) === 0,
|
||||
"alle unberührten künftigen Termine sind aus dem Kalender genommen");
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE id = ?", dritter.id) === 1,
|
||||
"der von Hand bearbeitete Termin steht weiterhin da");
|
||||
|
||||
/* GEGENPROBE zum Abstellen: Der Nachfüller darf für eine abgestellte
|
||||
Serie nichts mehr tun -- sonst wäre "deaktiviert" nur ein Wort. */
|
||||
const trotzdem = serieNachfuellen(serieId);
|
||||
ok(trotzdem === 0, "GEGENPROBE: der Nachfüller legt für eine abgestellte Serie nichts nach");
|
||||
nachfuellenAlle(true);
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND serie_beruehrt = 0", serieId) === 0,
|
||||
"auch der Lauf über alle Serien lässt sie in Ruhe");
|
||||
|
||||
console.log("\n— Wieder anstellen —");
|
||||
|
||||
const an = await api(seiteDogi, "/workspace/api/serien/" + serieId, {
|
||||
method: "PATCH", koerper: { aktiv: 1 },
|
||||
});
|
||||
ok(an.status === 200 && an.daten.angelegt > 20,
|
||||
`wieder angestellt legt die Termine neu an (${an.daten.angelegt})`);
|
||||
|
||||
console.log("\n— Fuer jeden: auch ein Creator darf eine Wiederholung anlegen —");
|
||||
|
||||
const seiteTili = await anmelden("creator", "CODE-TILI-0001");
|
||||
const vonTili = await api(seiteTili, "/workspace/api/termine", {
|
||||
method: "POST",
|
||||
koerper: {
|
||||
titel: "Eigener Drehtag", art: "termin", beginn: morgen + "T09:00",
|
||||
dauer_min: 60, wiederholung: { takt: "monatlich_wochentag", ende_tag: null },
|
||||
},
|
||||
});
|
||||
ok(vonTili.status === 201 && vonTili.daten.serie_id > 0,
|
||||
"ein Creator legt eine eigene Wiederholung an");
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termin_serien WHERE id = ? AND creator_id = ? AND teilnehmer_id = ?",
|
||||
vonTili.daten.serie_id, idTili, idTili) === 1,
|
||||
"sie ist zwangsweise ihm selbst zugeordnet – die Serie umgeht die Regel nicht");
|
||||
|
||||
/* Sichtbarkeit. Tili sieht ZWEI Serien -- ihre eigene und den
|
||||
Wochen-Call, der mit ihr stattfindet. Das ist richtig so: Die Regel
|
||||
lautet "woran ich beteiligt bin", und beim Wochen-Call ist sie
|
||||
creator_id und teilnehmer_id. Ändern darf sie ihn aber nicht, sonst
|
||||
könnte sie einen Call abstellen, den das Management angesetzt hat. */
|
||||
const sichtTili = await api(seiteTili, "/workspace/api/serien");
|
||||
ok(sichtTili.daten.serien.length === 2,
|
||||
`Tili sieht ihre eigene UND den Call, an dem sie teilnimmt (${sichtTili.daten.serien?.length})`);
|
||||
ok(sichtTili.daten.serien.filter((s) => s.darf_aendern).length === 1,
|
||||
"ändern darf sie aber nur ihre eigene – nicht die des Managements");
|
||||
|
||||
const fremd = await api(seiteTili, "/workspace/api/serien/" + serieId, {
|
||||
method: "PATCH", koerper: { aktiv: 0 },
|
||||
});
|
||||
ok(fremd.status === 403, "GEGENPROBE: der Versuch, die fremde Serie abzustellen, wird abgewiesen");
|
||||
ok(zahl("SELECT COUNT(*) AS n FROM termin_serien WHERE id = ? AND aktiv = 1", serieId) === 1,
|
||||
"und sie läuft danach unverändert weiter");
|
||||
|
||||
/* Eine Person, die mit beiden nichts zu tun hat, sieht nichts. Ohne
|
||||
diese Prüfung wäre die Zeile darüber grün, selbst wenn jeder alles
|
||||
sähe -- Tili ist an beiden Serien beteiligt. */
|
||||
const idScout = anlegen("Ben", "scout", "CODE-BEN-00001");
|
||||
const seiteBen = await anmelden("scout", "CODE-BEN-00001");
|
||||
const sichtBen = await api(seiteBen, "/workspace/api/serien");
|
||||
ok(sichtBen.daten.serien.length === 0,
|
||||
`ein unbeteiligter Scout sieht keine der beiden Serien (${sichtBen.daten.serien?.length})`);
|
||||
ok(idScout > 0, "der Scout ist dabei wirklich angelegt worden – sonst prüfte die Zeile davor nichts");
|
||||
|
||||
console.log("\n— Fehleingaben —");
|
||||
|
||||
for (const [koerper, was] of [
|
||||
[{ takt: "hackerei", start_tag: morgen, uhrzeit: "10:00", titel: "X" }, "unbekannter Takt"],
|
||||
[{ takt: "woechentlich", start_tag: "morgen", uhrzeit: "10:00", titel: "X" }, "Datum als Wort"],
|
||||
[{ takt: "woechentlich", start_tag: morgen, uhrzeit: "25:00", titel: "X" }, "Uhrzeit 25:00"],
|
||||
[{ takt: "woechentlich", start_tag: morgen, uhrzeit: "10:00", titel: "X", ende_tag: "2020-01-01" },
|
||||
"Ende vor dem Start"],
|
||||
]) {
|
||||
const a = await api(seiteDogi, "/workspace/api/serien", { method: "POST", koerper });
|
||||
ok(a.status === 400, `abgelehnt: ${was}`);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
TEIL 3 — Die Oberfläche
|
||||
=================================================================== */
|
||||
|
||||
console.log("\n— Der Kalender im Browser —");
|
||||
|
||||
await seiteDogi.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" });
|
||||
await seiteDogi.waitForSelector(".k-serie", { timeout: 15000 });
|
||||
|
||||
const liste = await seiteDogi.evaluate(() => ({
|
||||
serien: [...document.querySelectorAll(".k-serie")].map((k) => ({
|
||||
titel: k.querySelector(".k-serie__titel")?.textContent,
|
||||
satz: k.querySelector(".k-serie__satz")?.textContent,
|
||||
zustand: k.querySelector(".k-serie__zustand")?.textContent,
|
||||
naechster: k.querySelector(".k-serie__naechster")?.textContent,
|
||||
knoepfe: [...k.querySelectorAll(".k-serie__knoepfe button")].map((b) => b.textContent),
|
||||
})),
|
||||
zeichen: document.querySelectorAll(".k-pille .k-wdh-zeichen").length,
|
||||
pillen: document.querySelectorAll(".k-pille").length,
|
||||
sichtbar: !document.getElementById("serien-block").hidden,
|
||||
/* Abgeschnittener Text ist der häufigste stille Fehler -- er sieht
|
||||
auf den ersten Blick nach Absicht aus. */
|
||||
abgeschnitten: [...document.querySelectorAll(".k-serie__titel, .k-serie__satz, .k-serie__naechster")]
|
||||
.filter((e) => e.scrollWidth > e.clientWidth + 1).map((e) => e.textContent),
|
||||
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
}));
|
||||
|
||||
ok(liste.sichtbar, "die Leiste „Läuft von allein\" ist sichtbar");
|
||||
ok(liste.serien.length === 2, `beide Serien stehen darin (${liste.serien.length})`);
|
||||
ok(liste.serien.some((s) => s.satz === "jeden Dienstag um 18:00 Uhr, bis du sie abstellst")
|
||||
|| liste.serien.some((s) => /^jeden \w+ um 18:00 Uhr, bis du sie abstellst$/.test(s.satz)),
|
||||
"der Satz nennt Rhythmus, Uhrzeit und dass sie ohne Ende läuft");
|
||||
ok(liste.serien.every((s) => /Nächster Termin: /.test(s.naechster)),
|
||||
"zu jeder Serie steht, wann sie das nächste Mal zuschlägt");
|
||||
ok(liste.serien.every((s) => s.zustand === "läuft"),
|
||||
"der Zustand steht als WORT da, nicht nur als Farbe");
|
||||
ok(liste.serien.every((s) => s.knoepfe.includes("abstellen") && s.knoepfe.includes("löschen")),
|
||||
"abstellen und löschen sind erreichbar");
|
||||
ok(liste.pillen > 0 && liste.zeichen === liste.pillen,
|
||||
`jeder Termin im Raster trägt das Wiederhol-Zeichen (${liste.zeichen}/${liste.pillen})`);
|
||||
ok(!liste.abgeschnitten.length,
|
||||
`kein Text ist abgeschnitten${liste.abgeschnitten.length ? ": " + liste.abgeschnitten.join(" | ") : ""}`);
|
||||
ok(liste.ueber <= 0, "nichts ragt seitlich aus der Seite");
|
||||
|
||||
/* Die Liste im Bild -- sie ist der Teil, den man beim Abstellen sucht. */
|
||||
await seiteDogi.evaluate(() =>
|
||||
document.getElementById("serien-block").scrollIntoView({ block: "center" }));
|
||||
await seiteDogi.screenshot({ path: "pruef-serien-liste.png", fullPage: false });
|
||||
|
||||
console.log("\n— Formular: die Auswahl beschriftet sich nach dem Datum —");
|
||||
|
||||
await seiteDogi.click("#neu-oeffnen");
|
||||
await seiteDogi.fill("#f-beginn", "2026-09-29T18:00");
|
||||
await seiteDogi.dispatchEvent("#f-beginn", "change");
|
||||
const auswahl = await seiteDogi.evaluate(() =>
|
||||
[...document.querySelectorAll("#f-takt option")].map((o) => o.textContent));
|
||||
ok(auswahl.includes("jeden Dienstag"), "„jeden Dienstag\" statt „wöchentlich\"");
|
||||
ok(auswahl.includes("jeden Monat am letzten Dienstag"),
|
||||
"der 29.09. ist der letzte Dienstag – die Auswahl sagt es");
|
||||
ok(auswahl.some((t) => t.startsWith("jeden Monat am 29.")),
|
||||
"„jeden Monat am 29.\" ist als eigene Möglichkeit da");
|
||||
ok(auswahl.some((t) => t.includes("(Monate ohne den 29. fallen aus)")),
|
||||
"und der Hinweis, dass der Februar dabei ausfällt, steht dran");
|
||||
|
||||
/* Der Vorschausatz im Formular MUSS derselbe sein wie der, den der
|
||||
Server später in die Liste schreibt. Formular und Liste dürfen nicht
|
||||
Verschiedenes behaupten -- sonst glaubt man der falschen Stelle. */
|
||||
await seiteDogi.selectOption("#f-takt", "monatlich_wochentag");
|
||||
const vorschau = await seiteDogi.textContent("#wdh-satz");
|
||||
ok(/^Läuft jeden Monat am letzten Dienstag um 18:00 Uhr, bis du sie abstellst\.$/.test(vorschau.trim()),
|
||||
`die Vorschau sagt den ganzen Satz: „${vorschau.trim()}"`);
|
||||
gleich(vorschau.trim(), "Läuft " + serienText(
|
||||
{ takt: "monatlich_wochentag", start_tag: "2026-09-29", uhrzeit: "18:00", ende_tag: null }) + ".",
|
||||
"Formular und Server formulieren WORTGLEICH – sonst laufen sie auseinander");
|
||||
|
||||
await seiteDogi.screenshot({ path: "pruef-serien-computer.png", fullPage: false });
|
||||
const handy = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const seiteHandy = await handy.newPage();
|
||||
await seiteHandy.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seiteHandy.click('.rolle[data-rolle="admin"]');
|
||||
await seiteHandy.fill("#code", "CODE-DOGI-0001");
|
||||
await seiteHandy.click("button[type=submit]");
|
||||
await seiteHandy.waitForURL("**/start.html", { timeout: 15000 });
|
||||
await seiteHandy.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" });
|
||||
await seiteHandy.waitForSelector(".k-serie", { timeout: 15000 });
|
||||
const engUeber = await seiteHandy.evaluate(() =>
|
||||
document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
ok(engUeber <= 0, "auch auf dem Handy ragt nichts heraus");
|
||||
await seiteHandy.screenshot({ path: "pruef-serien-handy.png", fullPage: false });
|
||||
|
||||
/* ===================================================================
|
||||
Abschluss — die ANZAHL zählt mit
|
||||
===================================================================
|
||||
Ein grüner Lauf mit weniger Prüfungen als beim letzten Mal ist kein
|
||||
grüner Lauf: Genau so fallen Prüfungen still weg, wenn sich der
|
||||
Datenbestand ändert (Projektregel vom 28.08.2026). */
|
||||
/* Stand 02.09.2026: 66 Prüfungen vor dieser Zeile. Sinkt die Zahl, ist
|
||||
eine Prüfung stillschweigend weggefallen -- dann gehört das erklärt,
|
||||
bevor der Lauf als grün gilt. */
|
||||
const ERWARTET = 66;
|
||||
console.log("");
|
||||
ok(geprueft >= ERWARTET,
|
||||
`es sind ${geprueft} Prüfungen gelaufen (erwartet mindestens ${ERWARTET})`);
|
||||
|
||||
await browser.close();
|
||||
d.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -13,8 +13,11 @@
|
||||
|
||||
import express from "express";
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, betreutWo, istLeitung, istDogFather, heuteLokal,
|
||||
db, protokolliere, echteIp, sitzungLesen, istLeitung, termineSichtbar, heuteLokal,
|
||||
} from "./workspace.js";
|
||||
import {
|
||||
nachfuellenAlle, serienPruefen, serieAnlegen, zuordnungErzwingen,
|
||||
} from "./workspace-serien.js";
|
||||
|
||||
export const kalenderRouter = express.Router();
|
||||
|
||||
@@ -43,30 +46,22 @@ function gleicheHerkunft(req, res, next) {
|
||||
|
||||
kalenderRouter.use("/workspace/api/termine", angemeldet);
|
||||
|
||||
/* Wer sieht welchen Termin? Gleiche Logik wie bei den Aufgaben:
|
||||
Management alles, sonst nur, woran man selbst beteiligt ist. */
|
||||
export function sichtbar(person) {
|
||||
/* NUR DogFather sieht alles (01.09.2026). Vorher stand hier
|
||||
istLeitung() -- damit sah auch jeder Manager jeden Creator. Ein
|
||||
Manager faellt jetzt in dieselbe Regel wie ein Scout: nur die
|
||||
Creator, die ihm zugeteilt sind. Ausdruecklicher Wunsch:
|
||||
"NUR DIE ROLLE DOGFATHER SOLL WIRKLICH ALLEINE ALLES SEHEN."
|
||||
/* Wer sieht welchen Termin?
|
||||
|
||||
Geaendert wird ausschliesslich, wer was SIEHT. Was ein Manager
|
||||
darf (freigeben, aendern, Personen verwalten), haengt weiterhin an
|
||||
istLeitung und bleibt unveraendert -- sonst haette dieser eine
|
||||
Wunsch stillschweigend seine halben Rechte mitgenommen. */
|
||||
if (istDogFather(person)) return { wo: "1=1", werte: [] };
|
||||
const eigen = "(t.creator_id = ? OR t.teilnehmer_id = ? OR t.erstellt_von = ?)";
|
||||
const werte = [person.id, person.id, person.id];
|
||||
const b = betreutWo(person, "t.creator_id");
|
||||
if (!b) return { wo: eigen, werte };
|
||||
return { wo: `(${eigen} OR ${b.wo})`, werte: [...werte, ...b.werte] };
|
||||
}
|
||||
Die Regel selbst steht seit dem 02.09.2026 in workspace.js, weil die
|
||||
Wiederholungen (workspace-serien.js) genau dieselbe brauchen -- nur
|
||||
auf einer anderen Tabelle. Zwei fast gleiche Fassungen wären früher
|
||||
oder später auseinandergelaufen, und dann hätte eine Wiederholung
|
||||
jemandem etwas gezeigt, was der einzelne Termin ihm verbirgt.
|
||||
|
||||
Der Name bleibt hier stehen: workspace-calls.js, -hinweise.js und
|
||||
-suche.js holen ihn von hier. */
|
||||
export const sichtbar = (person) => termineSichtbar(person, "t");
|
||||
|
||||
const SPALTEN = `
|
||||
t.id, t.titel, t.beschreibung, t.art, t.beginn, t.dauer_min, t.ort,
|
||||
t.creator_id, t.teilnehmer_id, t.erledigt, t.erstellt, t.erstellt_von,
|
||||
t.serie_id, t.serie_tag,
|
||||
pc.name AS creator_name, pt.name AS teilnehmer_name`;
|
||||
|
||||
const VERBUND = `
|
||||
@@ -78,6 +73,11 @@ const VERBUND = `
|
||||
|
||||
kalenderRouter.get("/workspace/api/termine", (req, res) => {
|
||||
try {
|
||||
/* Wiederkehrende Termine nachlegen, BEVOR gelesen wird -- sonst
|
||||
fehlte im Kalender genau der Termin, für den man ihn öffnet.
|
||||
Der Nachfüller bremst sich selbst (höchstens alle fünf Minuten)
|
||||
und legt nur an, was noch fehlt. */
|
||||
nachfuellenAlle();
|
||||
const { wo, werte } = sichtbar(req.sicht || req.person);
|
||||
|
||||
/* Zeitraum. Ohne Angabe: ab heute 00:00, 90 Tage nach vorn -- das
|
||||
@@ -189,6 +189,31 @@ kalenderRouter.post("/workspace/api/termine", gleicheHerkunft, (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
/* Wurde im Formular ein Rhythmus gewählt, entsteht KEIN einzelner
|
||||
Termin, sondern eine Regel -- und aus ihr sofort die Termine des
|
||||
Horizonts, den ersten eingeschlossen. Beides anzulegen wäre der
|
||||
naheliegende Fehler gewesen: Der erste Termin stünde dann doppelt
|
||||
da, einmal von Hand und einmal aus der Serie.
|
||||
|
||||
Der Weg führt bewusst über dieselbe Prüfung und dieselbe
|
||||
Anlege-Funktion wie die eigene Serien-Schnittstelle. Eine zweite,
|
||||
verkürzte Fassung hier hätte irgendwann andere Grenzen gehabt. */
|
||||
const w = req.body?.wiederholung;
|
||||
if (w && w.takt) {
|
||||
const serie = {
|
||||
...aus,
|
||||
takt: w.takt,
|
||||
start_tag: aus.beginn.slice(0, 10),
|
||||
uhrzeit: aus.beginn.slice(11, 16),
|
||||
ende_tag: w.ende_tag ?? null,
|
||||
};
|
||||
const gepruft = serienPruefen(serie, { neu: true });
|
||||
if (gepruft.fehler.length) return res.status(400).json({ fehler: gepruft.fehler.join(" ") });
|
||||
zuordnungErzwingen(gepruft.aus, req.person);
|
||||
const { id, angelegt } = serieAnlegen(gepruft.aus, req.person);
|
||||
return res.status(201).json({ serie_id: id, angelegt });
|
||||
}
|
||||
|
||||
const { lastInsertRowid } = db().prepare(`
|
||||
INSERT INTO termine
|
||||
(titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id,
|
||||
@@ -227,8 +252,14 @@ kalenderRouter.patch("/workspace/api/termine/:id", gleicheHerkunft, (req, res) =
|
||||
const felder = Object.keys(aus);
|
||||
if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" });
|
||||
|
||||
db().prepare(`UPDATE termine SET ${felder.map((f) => `${f} = ?`).join(", ")} WHERE id = ?`)
|
||||
.run(...felder.map((f) => aus[f]), id);
|
||||
/* Stammt der Termin aus einer Wiederholung, gilt er ab jetzt als
|
||||
"angefasst". Das entscheidet später zweierlei: Beim Abstellen der
|
||||
Serie bleibt er stehen, und beim Ändern der Regel wird er nicht
|
||||
neu gebaut. Was jemand verschoben, umbenannt oder abgehakt hat,
|
||||
räumt die Automatik ihm nicht weg. */
|
||||
const beruehrt = termin.serie_id ? ", serie_beruehrt = 1" : "";
|
||||
db().prepare(`UPDATE termine SET ${felder.map((f) => `${f} = ?`).join(", ")}${beruehrt}
|
||||
WHERE id = ?`).run(...felder.map((f) => aus[f]), id);
|
||||
|
||||
protokolliere("termin_geaendert", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
@@ -247,7 +278,8 @@ kalenderRouter.delete("/workspace/api/termine/:id", gleicheHerkunft, (req, res)
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const { wo, werte } = sichtbar(req.person);
|
||||
const termin = db().prepare(`SELECT t.id, t.titel, t.erstellt_von ${VERBUND} WHERE ${wo} AND t.id = ?`)
|
||||
const termin = db().prepare(
|
||||
`SELECT t.id, t.titel, t.erstellt_von, t.serie_id, t.serie_tag ${VERBUND} WHERE ${wo} AND t.id = ?`)
|
||||
.get(...werte, id);
|
||||
if (!termin) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
@@ -258,6 +290,17 @@ kalenderRouter.delete("/workspace/api/termine/:id", gleicheHerkunft, (req, res)
|
||||
return res.status(403).json({ fehler: "nicht_erlaubt" });
|
||||
}
|
||||
|
||||
/* Eine einzelne Ausprägung zu löschen heisst "dieses eine Mal
|
||||
nicht" -- nicht "die ganze Serie weg". Ohne diese Vormerkung
|
||||
legte der Nachfüller den Termin beim nächsten Öffnen des
|
||||
Kalenders wieder an, und der gelöschte Termin wäre kommentarlos
|
||||
zurück. Der Sinn der Serie bleibt erhalten: Sie läuft weiter, nur
|
||||
dieser Tag fällt aus. */
|
||||
if (termin.serie_id && termin.serie_tag) {
|
||||
db().prepare("INSERT OR IGNORE INTO termin_serien_aus (serie_id, tag) VALUES (?,?)")
|
||||
.run(termin.serie_id, termin.serie_tag);
|
||||
}
|
||||
|
||||
db().prepare("DELETE FROM termine WHERE id = ?").run(id);
|
||||
protokolliere("termin_geloescht", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
/* =====================================================================
|
||||
workspace-serien.js — Termine, die von allein weiterlaufen.
|
||||
|
||||
Wunsch vom 02.09.2026: "ich will dass ich da im kalender auch sachen
|
||||
machen die jede woche jeden monat automatisiert immer weiter laufen
|
||||
so wie ich es auswähle bis ich es selber deaktiviere."
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WARUM ECHTE TERMINE UND KEINE GERECHNETEN AUSPRÄGUNGEN
|
||||
|
||||
Der naheliegende Weg wäre gewesen, die Wiederholung nur beim Anzeigen
|
||||
auszurechnen: eine Regel in der Datenbank, und der Kalender malt die
|
||||
Termine dazu. Das wäre falsch gewesen. ACHT andere Stellen lesen die
|
||||
Tabelle `termine` direkt:
|
||||
|
||||
workspace-calls.js die Call-Liste und die Protokolle
|
||||
workspace-reports.js "Termine gewesen / stehen an"
|
||||
workspace-hinweise.js die Ampel und die Hinweise
|
||||
workspace-suche.js die Suche über alles
|
||||
workspace-aufgaben.js "nächster Termin" an einer Aufgabe
|
||||
workspace-personen.js was an einer Person hängt
|
||||
workspace-content.js Veröffentlichungstermine
|
||||
die Startseite/Übersicht "heute steht an"
|
||||
|
||||
Eine nur im Kalender gerechnete Wiederholung wäre in all diesen
|
||||
Ansichten unsichtbar geblieben — und niemandem wäre es aufgefallen,
|
||||
weil dort einfach nichts gestanden hätte. Genau die Sorte stiller
|
||||
Ausfall, die man erst Monate später bemerkt.
|
||||
|
||||
Deshalb: Die Serie ist die Regel, der Nachfüller macht daraus echte
|
||||
Zeilen in `termine`, und jedes andere Modul sieht sie mit, ohne dass
|
||||
dort eine einzige Zeile geändert werden musste. Beim geplanten
|
||||
ICS-Abo (Perfektionsplan 3.2) gilt dasselbe.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
KEIN ZEITGEBER, KEIN DIENST
|
||||
|
||||
Der Nachfüller hängt an keinem Cron und an keinem Timer. Er läuft beim
|
||||
Start, beim Anlegen/Ändern einer Serie und beim Öffnen des Kalenders
|
||||
(dort höchstens alle fünf Minuten). Er ist idempotent: Was es schon
|
||||
gibt, legt er nicht noch einmal an. Ein Zeitgeber hätte nichts
|
||||
hinzugefügt, aber eine weitere Sache, die stillschweigend ausfallen
|
||||
kann.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
GERECHNET WIRD AUF DATUMSTEXTEN, IN UTC-MITTAG
|
||||
|
||||
Dieselbe Regel wie im Kalender-Frontend: 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.
|
||||
|
||||
Die Uhrzeit wird NICHT mitgerechnet, sondern als Text angehängt.
|
||||
Dadurch bleibt "18:00" über die Zeitumstellung hinweg 18:00 und
|
||||
wandert nicht auf 17:00 — was bei einem wöchentlichen Call genau das
|
||||
Falsche wäre.
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, istLeitung, termineSichtbar,
|
||||
heuteLokal, tagLokal,
|
||||
} from "./workspace.js";
|
||||
|
||||
export const serienRouter = express.Router();
|
||||
|
||||
/* Wie weit im Voraus echte Termine bereitliegen. 180 Tage decken den
|
||||
90-Tage-Plan aus dem Konzept mit Reserve ab und halten die Tabelle
|
||||
klein: täglich sind das 180 Zeilen je Serie, monatlich sechs. */
|
||||
export const HORIZONT_TAGE = 180;
|
||||
|
||||
/* Notbremse gegen eine Regel, die versehentlich Unmengen erzeugt. 400
|
||||
Ausprägungen sind mehr als der Horizont je hergibt (täglich = 180) --
|
||||
greift die Grenze trotzdem, stimmt etwas an der Rechnung nicht. */
|
||||
const GRENZE = 400;
|
||||
|
||||
const TITEL_MAX = 160;
|
||||
const TEXT_MAX = 4000;
|
||||
const ORT_MAX = 400;
|
||||
const ARTEN = ["termin", "call", "review"];
|
||||
const TAKTE = [
|
||||
"taeglich", "werktags", "woechentlich", "zweiwoechentlich",
|
||||
"monatlich_datum", "monatlich_letzter", "monatlich_wochentag", "jaehrlich",
|
||||
];
|
||||
|
||||
const jetztStempel = () => new Date().toISOString();
|
||||
|
||||
/* "Jetzt" in derselben Schreibweise wie `beginn` (Ortszeit, auf die
|
||||
Minute). Damit lässt sich "liegt noch vor uns" mit einem
|
||||
Text-Vergleich beantworten -- dasselbe Format, dieselbe Sortierung. */
|
||||
function jetztLokal() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
||||
+ `T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/* ---------- Datumsrechnung (UTC-Mittag, siehe Kopf) ---------------------- */
|
||||
|
||||
const alsText = (d) => d.toISOString().slice(0, 10);
|
||||
const ausText = (t) => new Date(t + "T12:00:00Z");
|
||||
const plusTage = (d, n) => { const k = new Date(d); k.setUTCDate(k.getUTCDate() + n); return k; };
|
||||
const tagImMonat = (j, m, tag) => new Date(Date.UTC(j, m, tag, 12));
|
||||
const letzterImMonat = (j, m) => new Date(Date.UTC(j, m + 1, 0, 12)).getUTCDate();
|
||||
|
||||
const WOCHENTAGE = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
|
||||
const ORDNUNG = ["", "ersten", "zweiten", "dritten", "vierten", "fünften"];
|
||||
|
||||
/** Was die Regel eines Starttags über den Monat aussagt: das wievielte
|
||||
* Vorkommen dieses Wochentags es ist, und ob es das letzte im Monat
|
||||
* ist. Beides braucht sowohl die Rechnung als auch der Klartext. */
|
||||
function monatslage(startTag) {
|
||||
const d = ausText(startTag);
|
||||
const j = d.getUTCFullYear(), m = d.getUTCMonth(), tag = d.getUTCDate();
|
||||
return {
|
||||
jahr: j, monat: m, tag, wochentag: d.getUTCDay(),
|
||||
nter: Math.ceil(tag / 7),
|
||||
letzterWochentag: tag + 7 > letzterImMonat(j, m),
|
||||
letzterTag: tag === letzterImMonat(j, m),
|
||||
};
|
||||
}
|
||||
|
||||
/** Alle Tage, an denen diese Serie zwischen `von` und `bis` stattfindet
|
||||
* (beide einschliesslich), als 'JJJJ-MM-TT'.
|
||||
*
|
||||
* Kein Termin liegt je vor dem Starttag oder nach `ende_tag` -- diese
|
||||
* beiden Grenzen werden hier durchgesetzt und nicht beim Aufrufer:
|
||||
* Eine Grenze, die jeder Aufrufer selbst ziehen muss, wird irgendwo
|
||||
* vergessen. */
|
||||
export function serienTage(serie, von, bis, grenze = GRENZE) {
|
||||
const raus = [];
|
||||
if (!serie?.start_tag || !TAKTE.includes(serie.takt)) return raus;
|
||||
|
||||
/* Der frühere der beiden Endpunkte gewinnt, der spätere der beiden
|
||||
Anfänge -- so bleibt der Zeitraum immer innerhalb der Serie. */
|
||||
const ende = serie.ende_tag && serie.ende_tag < bis ? serie.ende_tag : bis;
|
||||
const anfang = von > serie.start_tag ? von : serie.start_tag;
|
||||
if (anfang > ende) return raus;
|
||||
|
||||
/* --- Takte mit festem Tagesabstand -------------------------------- */
|
||||
const schritt = { taeglich: 1, werktags: 1, woechentlich: 7, zweiwoechentlich: 14 }[serie.takt];
|
||||
if (schritt) {
|
||||
const start = ausText(serie.start_tag);
|
||||
/* Vorspulen statt Tag für Tag zählen: Eine Serie, die vor drei
|
||||
Jahren begann, soll nicht tausend Schleifendurchläufe kosten. */
|
||||
const abstand = Math.round((ausText(anfang) - start) / 86400000);
|
||||
let d = abstand > 0 ? plusTage(start, Math.floor(abstand / schritt) * schritt) : start;
|
||||
while (raus.length < grenze) {
|
||||
const t = alsText(d);
|
||||
if (t > ende) break;
|
||||
/* "Werktags" ist täglich ohne Samstag und Sonntag. Ein Wochenende
|
||||
wird ausgelassen, nicht verschoben -- ein Montagstermin, der
|
||||
plötzlich am Sonntag steht, wäre schlimmer als keiner. */
|
||||
const werktag = d.getUTCDay() >= 1 && d.getUTCDay() <= 5;
|
||||
if (t >= anfang && (serie.takt !== "werktags" || werktag)) raus.push(t);
|
||||
d = plusTage(d, schritt);
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/* --- Takte, die in Monaten oder Jahren springen -------------------- */
|
||||
const lage = monatslage(serie.start_tag);
|
||||
const schrittM = serie.takt === "jaehrlich" ? 12 : 1;
|
||||
const vonD = ausText(anfang);
|
||||
const abstandM = (vonD.getUTCFullYear() - lage.jahr) * 12 + (vonD.getUTCMonth() - lage.monat);
|
||||
let i = abstandM > 0 ? Math.floor(abstandM / schrittM) : 0;
|
||||
|
||||
for (let n = 0; raus.length < grenze && n < 700; n++) {
|
||||
const versatz = (i + n) * schrittM;
|
||||
const j = lage.jahr + Math.floor((lage.monat + versatz) / 12);
|
||||
const m = (lage.monat + versatz) % 12;
|
||||
const letzter = letzterImMonat(j, m);
|
||||
|
||||
/* Sind wir am Ende vorbei? Der Erste des Monats ist der früheste
|
||||
Tag, den dieser Durchlauf noch liefern könnte. */
|
||||
if (alsText(tagImMonat(j, m, 1)) > ende) break;
|
||||
|
||||
let tag = null;
|
||||
if (serie.takt === "monatlich_letzter") {
|
||||
tag = letzter;
|
||||
} else if (serie.takt === "monatlich_wochentag") {
|
||||
if (lage.letzterWochentag) {
|
||||
/* "Jeden letzten Freitag im Monat" -- rückwärts vom Monatsende.
|
||||
Vorwärts zu zählen wäre in Monaten mit fünf Freitagen um eine
|
||||
Woche daneben. */
|
||||
const letzterD = tagImMonat(j, m, letzter);
|
||||
tag = letzter - ((letzterD.getUTCDay() - lage.wochentag + 7) % 7);
|
||||
} else {
|
||||
const erster = tagImMonat(j, m, 1);
|
||||
const z = 1 + ((lage.wochentag - erster.getUTCDay() + 7) % 7) + (lage.nter - 1) * 7;
|
||||
if (z <= letzter) tag = z;
|
||||
}
|
||||
} else {
|
||||
/* Gleiches Datum. Monate, die diesen Tag nicht haben, werden
|
||||
AUSGELASSEN und nicht auf den 28. vorgezogen: "am 31." heisst
|
||||
am 31. Wer den Monatsletzten will, wählt genau das -- die
|
||||
Auswahl im Formular bietet beides getrennt an, damit hier
|
||||
nichts geraten werden muss. */
|
||||
if (lage.tag <= letzter) tag = lage.tag;
|
||||
}
|
||||
|
||||
if (!tag) continue;
|
||||
const t = alsText(tagImMonat(j, m, tag));
|
||||
if (t > ende) break;
|
||||
if (t >= anfang) raus.push(t);
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/** Der Takt in einem Satz, den man vorlesen kann. Steht im Formular als
|
||||
* Vorschau und in der Liste der Wiederholungen -- wer eine Regel
|
||||
* abstellen will, muss sie zuerst wiedererkennen. */
|
||||
export function taktText(serie) {
|
||||
const l = monatslage(serie.start_tag);
|
||||
const wt = WOCHENTAGE[l.wochentag];
|
||||
switch (serie.takt) {
|
||||
case "taeglich": return "jeden Tag";
|
||||
case "werktags": return "jeden Werktag (Mo–Fr)";
|
||||
case "woechentlich": return `jeden ${wt}`;
|
||||
case "zweiwoechentlich": return `alle zwei Wochen, ${wt}s`;
|
||||
case "monatlich_datum": return `jeden Monat am ${l.tag}.`;
|
||||
case "monatlich_letzter": return "jeden Monat am letzten Tag";
|
||||
case "monatlich_wochentag":
|
||||
return l.letzterWochentag
|
||||
? `jeden Monat am letzten ${wt}`
|
||||
: `jeden Monat am ${ORDNUNG[l.nter]} ${wt}`;
|
||||
case "jaehrlich":
|
||||
return `jedes Jahr am ${l.tag}. ${["Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember"][l.monat]}`;
|
||||
default: return serie.takt;
|
||||
}
|
||||
}
|
||||
|
||||
/** Der ganze Satz, inklusive Uhrzeit und Ende. */
|
||||
export function serienText(serie) {
|
||||
const bis = serie.ende_tag
|
||||
? ` bis zum ${serie.ende_tag.slice(8)}.${serie.ende_tag.slice(5, 7)}.${serie.ende_tag.slice(0, 4)}`
|
||||
: ", bis du sie abstellst";
|
||||
return `${taktText(serie)} um ${serie.uhrzeit} Uhr${bis}`;
|
||||
}
|
||||
|
||||
/* ---------- Der Nachfüller ------------------------------------------------
|
||||
Legt fehlende Ausprägungen an. Idempotent: Was schon da ist oder
|
||||
ausdrücklich ausgelassen wurde, bleibt unangetastet. */
|
||||
|
||||
export function serieNachfuellen(serieId) {
|
||||
const d = db();
|
||||
const s = d.prepare("SELECT * FROM termin_serien WHERE id = ?").get(serieId);
|
||||
if (!s || !s.aktiv) return 0;
|
||||
|
||||
const von = heuteLokal();
|
||||
const bis = tagLokal(HORIZONT_TAGE);
|
||||
const tage = serienTage(s, von, bis);
|
||||
if (!tage.length) return 0;
|
||||
|
||||
/* Was es schon gibt und was ausdrücklich ausgelassen wurde -- beides
|
||||
in einem Rutsch, nicht je Tag einzeln abgefragt. */
|
||||
const da = new Set(d.prepare(
|
||||
"SELECT serie_tag FROM termine WHERE serie_id = ? AND serie_tag >= ?")
|
||||
.all(s.id, von).map((r) => r.serie_tag));
|
||||
const aus = new Set(d.prepare(
|
||||
"SELECT tag FROM termin_serien_aus WHERE serie_id = ?")
|
||||
.all(s.id).map((r) => r.tag));
|
||||
|
||||
const einfuegen = d.prepare(`
|
||||
INSERT INTO termine
|
||||
(titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id,
|
||||
erledigt, erstellt, erstellt_von, serie_id, serie_tag, serie_beruehrt)
|
||||
VALUES (?,?,?,?,?,?,?,?,0,?,?,?,?,0)`);
|
||||
|
||||
let neu = 0;
|
||||
const stempel = jetztStempel();
|
||||
for (const tag of tage) {
|
||||
if (da.has(tag) || aus.has(tag)) continue;
|
||||
einfuegen.run(s.titel, s.beschreibung, s.art, `${tag}T${s.uhrzeit}`, s.dauer_min,
|
||||
s.ort, s.creator_id, s.teilnehmer_id, stempel, s.erstellt_von, s.id, tag);
|
||||
neu++;
|
||||
}
|
||||
return neu;
|
||||
}
|
||||
|
||||
/* Wann zuletzt über alle Serien nachgelegt wurde. Der Kalender wird oft
|
||||
geöffnet; ohne diese Bremse liefe die Rechnung bei jedem Aufruf,
|
||||
obwohl sich frühestens am nächsten Tag etwas ändern kann. */
|
||||
let letzterLauf = 0;
|
||||
const BREMSE_MS = 5 * 60_000;
|
||||
|
||||
export function nachfuellenAlle(erzwingen = false) {
|
||||
const jetzt = Date.now();
|
||||
if (!erzwingen && jetzt - letzterLauf < BREMSE_MS) return 0;
|
||||
letzterLauf = jetzt;
|
||||
let neu = 0;
|
||||
try {
|
||||
for (const s of db().prepare("SELECT id FROM termin_serien WHERE aktiv = 1").all()) {
|
||||
neu += serieNachfuellen(s.id);
|
||||
}
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Wiederholungen nachfüllen:", fehler?.message);
|
||||
}
|
||||
return neu;
|
||||
}
|
||||
|
||||
/** Beim Serverstart einmal auffüllen -- damit die Termine auch dann
|
||||
* bereitliegen, wenn an diesem Tag niemand den Kalender öffnet, aber
|
||||
* jemand die Startseite oder die Call-Liste ansieht. */
|
||||
export function serienStarten() {
|
||||
try {
|
||||
const neu = nachfuellenAlle(true);
|
||||
if (neu) console.log(`[workspace] ${neu} wiederkehrende Termine ergänzt.`);
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Wiederholungen beim Start:", fehler?.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Räumt die Zukunft einer Serie ab: alles, was noch bevorsteht und
|
||||
* niemand angefasst hat. Berührte und vergangene Termine bleiben --
|
||||
* ausdrücklicher Wunsch: was geleistet oder bearbeitet wurde, wird
|
||||
* nicht weggeräumt. */
|
||||
function zukunftEntfernen(serieId) {
|
||||
return db().prepare(`
|
||||
DELETE FROM termine
|
||||
WHERE serie_id = ? AND serie_beruehrt = 0 AND erledigt = 0 AND beginn > ?`)
|
||||
.run(serieId, jetztLokal()).changes ?? 0;
|
||||
}
|
||||
|
||||
/* ---------- Prüfen -------------------------------------------------------- */
|
||||
|
||||
export function serienPruefen(körper, { neu }) {
|
||||
const fehler = [];
|
||||
const aus = {};
|
||||
|
||||
if (neu || körper.titel !== undefined) {
|
||||
const t = String(körper.titel ?? "").trim();
|
||||
if (t.length < 2) fehler.push("Titel fehlt.");
|
||||
else if (t.length > TITEL_MAX) fehler.push("Titel ist zu lang.");
|
||||
else aus.titel = t;
|
||||
}
|
||||
if (neu || körper.takt !== undefined) {
|
||||
if (!TAKTE.includes(körper.takt)) fehler.push("Unbekannter Rhythmus.");
|
||||
else aus.takt = körper.takt;
|
||||
}
|
||||
if (neu || körper.start_tag !== undefined) {
|
||||
const t = String(körper.start_tag ?? "").trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(t) || Number.isNaN(Date.parse(t + "T12:00:00Z"))) {
|
||||
fehler.push("Startdatum fehlt oder ist ungültig.");
|
||||
} else aus.start_tag = t;
|
||||
}
|
||||
if (neu || körper.uhrzeit !== undefined) {
|
||||
const u = String(körper.uhrzeit ?? "").trim();
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(u)) fehler.push("Uhrzeit fehlt oder ist ungültig.");
|
||||
else aus.uhrzeit = u;
|
||||
}
|
||||
if (körper.ende_tag !== undefined) {
|
||||
const t = String(körper.ende_tag ?? "").trim();
|
||||
if (!t) aus.ende_tag = null;
|
||||
else if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) fehler.push("Enddatum ist ungültig.");
|
||||
else aus.ende_tag = t;
|
||||
}
|
||||
if (körper.art !== undefined) {
|
||||
if (!ARTEN.includes(körper.art)) fehler.push("Unbekannte Art.");
|
||||
else aus.art = körper.art;
|
||||
}
|
||||
if (körper.dauer_min !== undefined) {
|
||||
const d = Number(körper.dauer_min);
|
||||
if (!Number.isInteger(d) || d < 5 || d > 24 * 60) {
|
||||
fehler.push("Dauer muss zwischen 5 und 1440 Minuten liegen.");
|
||||
} else aus.dauer_min = d;
|
||||
}
|
||||
if (körper.ort !== undefined) {
|
||||
const o = String(körper.ort ?? "").trim();
|
||||
if (o.length > ORT_MAX) fehler.push("Ort/Link ist zu lang.");
|
||||
else aus.ort = o || null;
|
||||
}
|
||||
if (körper.beschreibung !== undefined) {
|
||||
const t = String(körper.beschreibung ?? "").trim();
|
||||
if (t.length > TEXT_MAX) fehler.push("Beschreibung ist zu lang.");
|
||||
else aus.beschreibung = t || null;
|
||||
}
|
||||
for (const feld of ["creator_id", "teilnehmer_id"]) {
|
||||
if (körper[feld] === undefined) continue;
|
||||
const w = körper[feld];
|
||||
if (w === null || w === "") { aus[feld] = null; continue; }
|
||||
const z = Number(w);
|
||||
if (!Number.isInteger(z) || z < 1) fehler.push("Ungültige Zuordnung.");
|
||||
else aus[feld] = z;
|
||||
}
|
||||
|
||||
/* Ein Ende vor dem Anfang ergäbe eine Serie ohne einen einzigen
|
||||
Termin -- sie stünde in der Liste und täte nichts. */
|
||||
const start = aus.start_tag ?? körper.start_tag;
|
||||
if (aus.ende_tag && start && aus.ende_tag < start) {
|
||||
fehler.push("Das Ende liegt vor dem Start.");
|
||||
}
|
||||
return { aus, fehler };
|
||||
}
|
||||
|
||||
/** Legt eine Serie an und füllt sie sofort auf. Wird auch vom
|
||||
* Kalender-Router benutzt, wenn im Terminformular ein Rhythmus gewählt
|
||||
* wurde -- ein Weg, eine Prüfung, ein Protokolleintrag. */
|
||||
export function serieAnlegen(aus, person) {
|
||||
const d = db();
|
||||
const { lastInsertRowid } = d.prepare(`
|
||||
INSERT INTO termin_serien
|
||||
(titel, beschreibung, art, takt, start_tag, uhrzeit, ende_tag, dauer_min, ort,
|
||||
creator_id, teilnehmer_id, aktiv, erstellt, erstellt_von)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?,?)`).run(
|
||||
aus.titel, aus.beschreibung ?? null, aus.art ?? "termin", aus.takt, aus.start_tag,
|
||||
aus.uhrzeit, aus.ende_tag ?? null, aus.dauer_min ?? 30, aus.ort ?? null,
|
||||
aus.creator_id ?? null, aus.teilnehmer_id ?? null, jetztStempel(), person.id);
|
||||
|
||||
const id = Number(lastInsertRowid);
|
||||
const angelegt = serieNachfuellen(id);
|
||||
protokolliere("serie_angelegt", {
|
||||
personId: person.id, rolle: person.rolle, ip: null,
|
||||
detail: `#${id} ${aus.titel} (${aus.takt}, ${angelegt} Termine)`.slice(0, 120),
|
||||
});
|
||||
return { id, angelegt };
|
||||
}
|
||||
|
||||
/** Zuordnung erzwingen für alle, die nicht Leitung sind: Sie legen nur
|
||||
* für sich selbst an. Dieselbe Regel wie beim einzelnen Termin -- sonst
|
||||
* wäre die Wiederholung ein Weg, sie zu umgehen. */
|
||||
export function zuordnungErzwingen(aus, person) {
|
||||
if (istLeitung(person)) return;
|
||||
aus.creator_id = person.rolle === "creator" ? person.id : null;
|
||||
aus.teilnehmer_id = person.id;
|
||||
}
|
||||
|
||||
/* ---------- Schranken ----------------------------------------------------- */
|
||||
|
||||
function angemeldet(req, res, next) {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
req.person = person;
|
||||
next();
|
||||
}
|
||||
|
||||
function gleicheHerkunft(req, res, next) {
|
||||
const herkunft = req.get("origin");
|
||||
if (!herkunft) return next();
|
||||
let erlaubt;
|
||||
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
||||
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
||||
next();
|
||||
}
|
||||
|
||||
serienRouter.use("/workspace/api/serien", angemeldet);
|
||||
|
||||
/** Die Serie holen, die diese Person sehen darf. Gibt es sie nicht oder
|
||||
* darf sie diese Person nicht sehen, ist die Antwort dieselbe (404) --
|
||||
* sonst verriete ein 403, dass es die Serie gibt. */
|
||||
function serieHolen(person, id) {
|
||||
const { wo, werte } = termineSichtbar(person, "s");
|
||||
return db().prepare(`SELECT s.* FROM termin_serien s WHERE ${wo} AND s.id = ?`)
|
||||
.get(...werte, id);
|
||||
}
|
||||
|
||||
/* Ändern und Abstellen darf die Leitung -- und wer die Serie angelegt
|
||||
hat. Sonst könnte ein Creator einen Wochen-Call abstellen, den das
|
||||
Management angesetzt hat. Gleiche Regel wie beim Löschen eines
|
||||
einzelnen Termins. */
|
||||
const darfAendern = (person, serie) => istLeitung(person) || serie.erstellt_von === person.id;
|
||||
|
||||
/* ---------- Lesen ---------------------------------------------------------- */
|
||||
|
||||
serienRouter.get("/workspace/api/serien", (req, res) => {
|
||||
try {
|
||||
nachfuellenAlle();
|
||||
const person = req.sicht || req.person;
|
||||
const { wo, werte } = termineSichtbar(person, "s");
|
||||
const roh = db().prepare(`
|
||||
SELECT s.*, pc.name AS creator_name, pt.name AS teilnehmer_name
|
||||
FROM termin_serien s
|
||||
LEFT JOIN personen pc ON pc.id = s.creator_id
|
||||
LEFT JOIN personen pt ON pt.id = s.teilnehmer_id
|
||||
WHERE ${wo}
|
||||
ORDER BY s.aktiv DESC, s.erstellt DESC`).all(...werte);
|
||||
|
||||
const heute = heuteLokal();
|
||||
const serien = roh.map((s) => {
|
||||
/* Der nächste Termin wird gerechnet, nicht aus der Tabelle
|
||||
gelesen: Eine abgestellte Serie hat dort keine Zukunft mehr,
|
||||
soll aber trotzdem zeigen können, wann sie wieder liefe. */
|
||||
const naechste = s.aktiv ? serienTage(s, heute, tagLokal(HORIZONT_TAGE), 1) : [];
|
||||
return {
|
||||
...s,
|
||||
satz: serienText(s),
|
||||
naechster: naechste[0] ? `${naechste[0]}T${s.uhrzeit}` : null,
|
||||
darf_aendern: darfAendern(req.person, s) ? 1 : 0,
|
||||
};
|
||||
});
|
||||
res.json({ serien });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Serien lesen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Anlegen -------------------------------------------------------- */
|
||||
|
||||
serienRouter.post("/workspace/api/serien", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
const { aus, fehler } = serienPruefen(req.body || {}, { neu: true });
|
||||
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
|
||||
|
||||
zuordnungErzwingen(aus, req.person);
|
||||
for (const feld of ["creator_id", "teilnehmer_id"]) {
|
||||
if (aus[feld] && !db().prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(aus[feld])) {
|
||||
return res.status(400).json({ fehler: "Zugeordnete Person gibt es nicht." });
|
||||
}
|
||||
}
|
||||
const { id, angelegt } = serieAnlegen(aus, req.person);
|
||||
res.status(201).json({ id, angelegt });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Serie anlegen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Ändern, Abstellen, Wieder anstellen ---------------------------- */
|
||||
|
||||
serienRouter.patch("/workspace/api/serien/:id", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
const serie = serieHolen(req.person, id);
|
||||
if (!serie) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!darfAendern(req.person, serie)) return res.status(403).json({ fehler: "nicht_erlaubt" });
|
||||
|
||||
const { aus, fehler } = serienPruefen(req.body || {}, { neu: false });
|
||||
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
|
||||
if (!istLeitung(req.person)) { delete aus.creator_id; delete aus.teilnehmer_id; }
|
||||
if (req.body?.aktiv !== undefined) aus.aktiv = req.body.aktiv ? 1 : 0;
|
||||
|
||||
/* Auch gegen den BESTEHENDEN Start prüfen, nicht nur gegen einen
|
||||
mitgeschickten: Wer nur ein Enddatum nachreicht, könnte es sonst
|
||||
vor den Anfang legen. */
|
||||
if (aus.ende_tag && aus.ende_tag < (aus.start_tag ?? serie.start_tag)) {
|
||||
return res.status(400).json({ fehler: "Das Ende liegt vor dem Start." });
|
||||
}
|
||||
|
||||
const felder = Object.keys(aus);
|
||||
if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" });
|
||||
|
||||
db().prepare(`UPDATE termin_serien SET ${felder.map((f) => `${f} = ?`).join(", ")},
|
||||
geaendert = ? WHERE id = ?`)
|
||||
.run(...felder.map((f) => aus[f]), jetztStempel(), id);
|
||||
|
||||
/* Die noch unberührte Zukunft wird neu gebaut. Sie einfach stehen zu
|
||||
lassen wäre der stille Fehler: Man verschiebt den Wochen-Call auf
|
||||
19:00, die nächsten zwölf Termine stünden weiter auf 18:00, und
|
||||
auffallen würde es erst beim ersten verpassten Call. */
|
||||
const weg = zukunftEntfernen(id);
|
||||
const neu = aus.aktiv === 0 ? 0 : serieNachfuellen(id);
|
||||
|
||||
protokolliere(aus.aktiv === 0 ? "serie_abgestellt"
|
||||
: aus.aktiv === 1 ? "serie_angestellt" : "serie_geaendert", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${id} ${felder.join(",")} (−${weg}/+${neu})`.slice(0, 120),
|
||||
});
|
||||
res.json({ ok: true, entfernt: weg, angelegt: neu });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Serie ändern:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Löschen -------------------------------------------------------- */
|
||||
|
||||
serienRouter.delete("/workspace/api/serien/:id", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
const serie = serieHolen(req.person, id);
|
||||
if (!serie) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!darfAendern(req.person, serie)) return res.status(403).json({ fehler: "nicht_erlaubt" });
|
||||
|
||||
/* Erst die unberührte Zukunft, dann die Regel. Was war und was
|
||||
jemand angefasst hat, bleibt als ganz normaler Termin stehen --
|
||||
serie_id fällt dabei auf NULL (ON DELETE SET NULL). */
|
||||
const weg = zukunftEntfernen(id);
|
||||
db().prepare("DELETE FROM termin_serien WHERE id = ?").run(id);
|
||||
|
||||
protokolliere("serie_geloescht", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${id} ${serie.titel} (−${weg})`.slice(0, 120),
|
||||
});
|
||||
res.json({ ok: true, entfernt: weg });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Serie löschen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
@@ -179,6 +179,21 @@ function umstellungen(d) {
|
||||
["personen", "instagram", "TEXT"],
|
||||
["personen", "youtube", "TEXT"],
|
||||
["personen", "twitch", "TEXT"],
|
||||
|
||||
/* ---- Wiederkehrende Termine (02.09.2026) ----
|
||||
Drei Spalten an `termine`, damit eine Ausprägung weiss, woher sie
|
||||
stammt:
|
||||
serie_id die Regel, aus der sie entstanden ist
|
||||
serie_tag der geplante Tag -- daran erkennt der Nachfüller,
|
||||
dass es diese Ausprägung schon gibt, und legt sie
|
||||
kein zweites Mal an
|
||||
serie_beruehrt jemand hat sie von Hand angefasst (verschoben,
|
||||
umbenannt, abgehakt). Solche Termine bleiben beim
|
||||
Abstellen der Serie stehen -- was jemand
|
||||
bearbeitet hat, wird ihm nicht weggeräumt. */
|
||||
["termine", "serie_id", "INTEGER REFERENCES termin_serien(id) ON DELETE SET NULL"],
|
||||
["termine", "serie_tag", "TEXT"],
|
||||
["termine", "serie_beruehrt", "INTEGER NOT NULL DEFAULT 0"],
|
||||
]) {
|
||||
try {
|
||||
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
|
||||
@@ -191,6 +206,17 @@ function umstellungen(d) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Der Nachfüller fragt bei jedem Lauf "welche Ausprägungen dieser
|
||||
Serie gibt es schon?". Ohne diesen Verbund-Index liest SQLite dafür
|
||||
die ganze Termintabelle. Er steht hier unten und nicht oben im
|
||||
Bauplan, weil die beiden Spalten erst durch die Schleife darüber
|
||||
entstehen -- oben gäbe es sie beim ersten Start noch nicht. */
|
||||
try {
|
||||
d.exec("CREATE INDEX IF NOT EXISTS idx_termine_serie ON termine (serie_id, serie_tag)");
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Index idx_termine_serie:", fehler?.message);
|
||||
}
|
||||
|
||||
/* Content-Saeulen: die drei bis fuenf Themen, aus denen der Kanal
|
||||
besteht. Aus der Recherche: 3-5 Saeulen nach der 70/20/10-Regel
|
||||
(70 % Wert, 20 % Community, 10 % Eigenwerbung); eine Saeule wird
|
||||
@@ -435,6 +461,61 @@ export function db() {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_termine_beginn ON termine (beginn);
|
||||
|
||||
/* Wiederkehrende Termine (02.09.2026) --------------------------------
|
||||
|
||||
Eine Serie ist eine REGEL, kein Termin: "jeden Dienstag um 18:00
|
||||
Uhr, bis ich es abstelle". Aus ihr entstehen echte Zeilen in
|
||||
der Tabelle termine.
|
||||
|
||||
Warum echte Zeilen und nicht bloss gerechnete Ausprägungen:
|
||||
ACHT andere Stellen lesen termine direkt -- Calls, Protokolle,
|
||||
Berichte, Hinweise/Ampel, Suche, Aufgaben ("nächster Termin"),
|
||||
Übersicht, Startseite. Eine nur im Kalender gerechnete
|
||||
Wiederholung wäre in all diesen Ansichten unsichtbar gewesen,
|
||||
ohne dass es jemandem auffällt, und beim geplanten ICS-Abo
|
||||
ebenfalls. Der Nachfüller hält stattdessen einen Horizont echt
|
||||
gefüllt -- damit sieht jedes Modul die Wiederholung, ohne dass
|
||||
dort eine einzige Zeile geändert werden musste.
|
||||
|
||||
uhrzeit statt beginn: Eine Regel kennt keinen Zeitpunkt, nur
|
||||
eine Uhrzeit und einen Takt. Gerechnet wird auf reinen
|
||||
Datumstexten -- deshalb bleibt 18:00 auch über die
|
||||
Zeitumstellung hinweg 18:00 und wandert nicht auf 17:00. */
|
||||
CREATE TABLE IF NOT EXISTS termin_serien (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT,
|
||||
art TEXT NOT NULL DEFAULT 'termin'
|
||||
CHECK (art IN ('termin','call','review')),
|
||||
takt TEXT NOT NULL
|
||||
CHECK (takt IN ('taeglich','werktags','woechentlich',
|
||||
'zweiwoechentlich','monatlich_datum',
|
||||
'monatlich_letzter','monatlich_wochentag',
|
||||
'jaehrlich')),
|
||||
start_tag TEXT NOT NULL,
|
||||
uhrzeit TEXT NOT NULL,
|
||||
ende_tag TEXT,
|
||||
dauer_min INTEGER NOT NULL DEFAULT 30,
|
||||
ort TEXT,
|
||||
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
teilnehmer_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
aktiv INTEGER NOT NULL DEFAULT 1,
|
||||
erstellt TEXT NOT NULL,
|
||||
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
geaendert TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_serien_aktiv ON termin_serien (aktiv);
|
||||
|
||||
/* Ausgelassene Tage einer Serie. Wer eine einzelne Ausprägung
|
||||
löscht, meint "dieses eine Mal nicht" -- ohne diese Tabelle
|
||||
legte der Nachfüller sie beim nächsten Aufruf wieder an, und der
|
||||
gelöschte Termin wäre kommentarlos zurück. */
|
||||
CREATE TABLE IF NOT EXISTS termin_serien_aus (
|
||||
serie_id INTEGER NOT NULL REFERENCES termin_serien(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (serie_id, tag)
|
||||
);
|
||||
|
||||
/* Dateiablage. Auf der Platte traegt jede Datei einen erzeugten
|
||||
Zufallsnamen (name_datei), der Originalname steht nur hier.
|
||||
Dadurch kann ein Dateiname weder Pfade verlassen noch etwas
|
||||
@@ -1069,6 +1150,34 @@ export function betreutWo(person, spalte) {
|
||||
return { wo: `${spalte} IN (${ids.map(() => "?").join(",")})`, werte: ids };
|
||||
}
|
||||
|
||||
/* Wer sieht welchen Termin -- und damit auch: welche Wiederholung.
|
||||
|
||||
NUR DogFather sieht alles (01.09.2026). Vorher stand hier istLeitung()
|
||||
-- damit sah auch jeder Manager jeden Creator. Ein Manager faellt
|
||||
jetzt in dieselbe Regel wie ein Scout: nur die Creator, die ihm
|
||||
zugeteilt sind. Ausdruecklicher Wunsch: "NUR DIE ROLLE DOGFATHER SOLL
|
||||
WIRKLICH ALLEINE ALLES SEHEN."
|
||||
|
||||
Geaendert wird ausschliesslich, wer was SIEHT. Was ein Manager DARF
|
||||
(freigeben, aendern, Personen verwalten), haengt weiterhin an
|
||||
istLeitung und bleibt unveraendert -- sonst haette dieser eine Wunsch
|
||||
stillschweigend seine halben Rechte mitgenommen.
|
||||
|
||||
Der Praefix ist der Tabellenname im jeweiligen SQL: "t" fuer termine,
|
||||
"s" fuer termin_serien. Diese Regel steht bewusst nur EINMAL: Eine
|
||||
zweite, fast gleiche Fassung fuer die Serien waere frueher oder
|
||||
spaeter auseinandergelaufen -- und dann haette eine Wiederholung
|
||||
jemandem etwas gezeigt, was der einzelne Termin ihm verbirgt. */
|
||||
export function termineSichtbar(person, praefix = "t") {
|
||||
if (istDogFather(person)) return { wo: "1=1", werte: [] };
|
||||
const p = praefix;
|
||||
const eigen = `(${p}.creator_id = ? OR ${p}.teilnehmer_id = ? OR ${p}.erstellt_von = ?)`;
|
||||
const werte = [person.id, person.id, person.id];
|
||||
const b = betreutWo(person, `${p}.creator_id`);
|
||||
if (!b) return { wo: eigen, werte };
|
||||
return { wo: `(${eigen} OR ${b.wo})`, werte: [...werte, ...b.werte] };
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
DIE SICHT EINES ANDEREN — nur fuer DogFather.
|
||||
|
||||
|
||||
@@ -544,6 +544,110 @@
|
||||
.k-zeile { grid-template-columns: auto 1fr; }
|
||||
.k-zeile__knoepfe { grid-column: 1 / -1; }
|
||||
.k-anlassreihe { grid-template-columns: 1fr; }
|
||||
.k-wdh { grid-template-columns: 1fr; }
|
||||
.k-serienliste { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
WIEDERHOLUNGEN (02.09.2026)
|
||||
|
||||
Termine, die von allein weiterlaufen. Zwei Stellen:
|
||||
* die Ende-Zeile im Formular samt Vorschausatz
|
||||
* die Liste "Läuft von allein" über dem Kalender
|
||||
|
||||
Gedeckt gehalten wie alles andere hier. Eine Automatik darf nicht
|
||||
lauter sein als das, was ein Mensch eingetragen hat -- sie soll
|
||||
auffindbar sein, nicht auffällig.
|
||||
=================================================================== */
|
||||
|
||||
.k-wdh {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 260px) 1fr;
|
||||
align-items: end;
|
||||
gap: 10px 18px;
|
||||
}
|
||||
.k-wdh__satz {
|
||||
margin: 0 0 4px;
|
||||
font-size: .84rem; line-height: 1.5;
|
||||
color: var(--text-leise);
|
||||
}
|
||||
.k-wdh__satz:not(:empty)::before {
|
||||
content: "↻";
|
||||
margin-right: 7px;
|
||||
color: var(--akzent);
|
||||
}
|
||||
|
||||
/* Das Zeichen an einem einzelnen Termin. Klein, gedeckt, ohne eigenen
|
||||
Platzanspruch -- der Titel bleibt die Hauptsache. */
|
||||
.k-wdh-zeichen {
|
||||
margin-left: 5px;
|
||||
font-size: .78em;
|
||||
opacity: .65;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
/* In der Pille steht es VOR dem Titel (sonst schneidet die schmale
|
||||
Monatszelle es ab) -- dort gehört der Abstand entsprechend nach
|
||||
rechts. Die Pille kürzt mit text-overflow, nicht als Flexbox: Was
|
||||
links steht, bleibt sichtbar. */
|
||||
.k-pille .k-wdh-zeichen { margin: 0 4px 0 0; }
|
||||
|
||||
.k-serienliste {
|
||||
display: grid; gap: 9px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.k-serie {
|
||||
--sfarbe: var(--akzent);
|
||||
position: relative; overflow: hidden;
|
||||
padding: 13px 15px 12px;
|
||||
border: 1px solid var(--rand); border-radius: var(--radius-klein);
|
||||
background: var(--flaeche);
|
||||
}
|
||||
/* Der Farbstreifen links ist derselbe Gedanke wie in der Liste: Die
|
||||
Farbe gehört zur Art des Termins, nicht zum Rang der Serie. */
|
||||
.k-serie::before {
|
||||
content: "";
|
||||
position: absolute; inset: 0 auto 0 0; width: 3px;
|
||||
background: var(--sfarbe);
|
||||
opacity: .75;
|
||||
}
|
||||
/* Pausiert: blasser UND gestrichelt. Nur blasser wäre im Kontrastmodus
|
||||
nicht von "läuft" zu unterscheiden. */
|
||||
.k-serie[data-aktiv="nein"] { opacity: .72; border-style: dashed; }
|
||||
.k-serie[data-aktiv="nein"]::before { opacity: .35; }
|
||||
|
||||
.k-serie__kopf {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.k-serie__titel { font-weight: 600; color: var(--text); }
|
||||
.k-serie__art {
|
||||
padding: 2px 9px; border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--sfarbe) 32%, transparent);
|
||||
color: var(--sfarbe);
|
||||
font-size: .68rem; letter-spacing: .08em; text-transform: uppercase;
|
||||
}
|
||||
.k-serie__zustand {
|
||||
margin-left: auto;
|
||||
font-size: .7rem; letter-spacing: .09em; text-transform: uppercase;
|
||||
color: var(--text-still);
|
||||
}
|
||||
.k-serie[data-aktiv="ja"] .k-serie__zustand { color: #6fbf7a; }
|
||||
|
||||
.k-serie__satz {
|
||||
margin: 0;
|
||||
font-size: .86rem; line-height: 1.5;
|
||||
color: var(--text-leise);
|
||||
}
|
||||
.k-serie__naechster {
|
||||
margin: 4px 0 0;
|
||||
font-size: .78rem; color: var(--text-still);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.k-serie__knoepfe {
|
||||
display: flex; flex-wrap: wrap; gap: 7px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -558,4 +662,9 @@
|
||||
.k-zeile::before, .k-anlasskarte::before { background: CanvasText; }
|
||||
.k-pille, .k-anlass { border: 1px solid CanvasText; }
|
||||
.k-strahl__saeule { background: CanvasText; }
|
||||
.k-serie::before { background: CanvasText; }
|
||||
.k-serie { border: 1px solid CanvasText; }
|
||||
/* Deckkraft trägt im Kontrastmodus nicht -- der gestrichelte Rand
|
||||
bleibt das Unterscheidungsmerkmal einer pausierten Serie. */
|
||||
.k-serie[data-aktiv="nein"] { opacity: 1; border-style: dashed; }
|
||||
}
|
||||
|
||||
@@ -63,9 +63,18 @@
|
||||
const ANLASS_FARBE = '#d8a13a';
|
||||
|
||||
const WOCHENTAGE_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
/* Sonntag zuerst -- das ist die Zählung von getUTCDay(), nicht die
|
||||
deutsche Wochenordnung. Wer hier Montag an den Anfang setzt,
|
||||
verschiebt jeden Wochentagsnamen um eins. */
|
||||
const WOCHENTAGE_LANG = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch',
|
||||
'Donnerstag', 'Freitag', 'Samstag'];
|
||||
const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||
const ORDNUNG = ['', 'ersten', 'zweiten', 'dritten', 'vierten', 'fünften'];
|
||||
|
||||
let ich = null;
|
||||
let daten = null;
|
||||
let serien = [];
|
||||
let ansicht = 'monat';
|
||||
let anker = heuteText(); // Datum, um das herum angezeigt wird
|
||||
const zeigen = { call: true, termin: true, review: true, frist: true };
|
||||
@@ -126,6 +135,8 @@
|
||||
}
|
||||
|
||||
const fmt = (o) => new Intl.DateTimeFormat('de-DE', { timeZone: 'UTC', ...o });
|
||||
const tagLang = (t) =>
|
||||
fmt({ weekday: 'long', day: '2-digit', month: 'long' }).format(ausText(t));
|
||||
|
||||
/* ---------- Anlässe ------------------------------------------------------ */
|
||||
|
||||
@@ -265,6 +276,7 @@
|
||||
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,
|
||||
serie_id: t.serie_id || null,
|
||||
});
|
||||
}
|
||||
for (const f of roh.fristen || []) {
|
||||
@@ -312,12 +324,31 @@
|
||||
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 : ''}`;
|
||||
k.title = `${e.titel}${e.zusatz ? ' — ' + e.zusatz : ''}`
|
||||
+ (e.serie_id ? ' — wiederholt sich' : '');
|
||||
if (e.uhr) k.append(el('span', 'k-pille__zeit', e.uhr));
|
||||
/* Das Zeichen steht im Raster VOR dem Titel, nicht dahinter: Eine
|
||||
Zelle im Monatsraster ist gut 150 px breit, der Titel wird dort
|
||||
abgeschnitten -- ein Zeichen am Ende wäre genau in der Ansicht
|
||||
unsichtbar, in der man den Rhythmus erkennen will. In der Liste
|
||||
(siehe zeile()) ist Platz, dort steht es hinter dem Titel und
|
||||
liest sich natürlicher. */
|
||||
if (e.serie_id) k.append(wiederholZeichen());
|
||||
k.append(document.createTextNode(e.titel));
|
||||
return k;
|
||||
}
|
||||
|
||||
/** Das Wiederhol-Zeichen. Einmal gebaut, überall gleich -- und mit
|
||||
* einer Beschriftung für Vorleseprogramme, die das Zeichen selbst
|
||||
* sonst als "Pfeil im Kreis" oder gar nicht vorlesen. */
|
||||
function wiederholZeichen() {
|
||||
const s = el('span', 'k-wdh-zeichen', '↻');
|
||||
s.setAttribute('role', 'img');
|
||||
s.setAttribute('aria-label', 'wiederholt sich');
|
||||
s.title = 'Gehört zu einer Wiederholung';
|
||||
return s;
|
||||
}
|
||||
|
||||
function anlassMarke(a) {
|
||||
const k = el('span', 'k-anlass' + (a.art === 'feiertag' ? ' feiertag' : ''),
|
||||
a.sym + ' ' + a.name);
|
||||
@@ -440,6 +471,174 @@
|
||||
$('monat-block').hidden = false;
|
||||
}
|
||||
|
||||
/* ---------- Wiederholungen ------------------------------------------------
|
||||
Wunsch vom 02.09.2026: Termine, die "jede Woche, jeden Monat
|
||||
automatisiert immer weiter laufen -- bis ich es selber deaktiviere".
|
||||
|
||||
Zwei Teile: die Auswahl im Formular (was soll sich wiederholen) und
|
||||
die Liste darunter (was läuft gerade und wie stelle ich es ab).
|
||||
|
||||
Die Beschriftungen der Auswahl hängen vom GEWÄHLTEN DATUM ab, nicht
|
||||
von abstrakten Wörtern: "jeden Dienstag" statt "wöchentlich", "jeden
|
||||
Monat am 2." statt "monatlich". Wer "monatlich" liest, weiß nicht,
|
||||
ob der 31. Januar im Februar ausfällt oder auf den 28. rutscht --
|
||||
hier steht es dran. Der Wortlaut ist derselbe wie auf dem Server
|
||||
(workspace-serien.js, taktText); die Liste unten zeigt IMMER die
|
||||
Fassung des Servers, damit im Zweifel eine von beiden gilt. */
|
||||
|
||||
function taktLage(tagText) {
|
||||
const d = ausText(tagText);
|
||||
const j = d.getUTCFullYear(), m = d.getUTCMonth(), tag = d.getUTCDate();
|
||||
const letzter = new Date(Date.UTC(j, m + 1, 0, 12)).getUTCDate();
|
||||
return {
|
||||
tag, monat: m, wochentag: d.getUTCDay(),
|
||||
nter: Math.ceil(tag / 7),
|
||||
letzterWochentag: tag + 7 > letzter,
|
||||
};
|
||||
}
|
||||
|
||||
/** Die möglichen Rhythmen für ein bestimmtes Startdatum, jeweils mit
|
||||
* dem Satz, der davon in der Vorschau steht. */
|
||||
function taktListe(tagText) {
|
||||
const l = taktLage(tagText);
|
||||
const wt = WOCHENTAGE_LANG[l.wochentag];
|
||||
return [
|
||||
{ wert: 'taeglich', text: 'jeden Tag' },
|
||||
{ wert: 'werktags', text: 'jeden Werktag (Mo–Fr)' },
|
||||
{ wert: 'woechentlich', text: `jeden ${wt}` },
|
||||
{ wert: 'zweiwoechentlich', text: `alle zwei Wochen, ${wt}s` },
|
||||
{
|
||||
wert: 'monatlich_datum',
|
||||
text: `jeden Monat am ${l.tag}.`,
|
||||
/* Ehrlich bleiben: Ein 29./30./31. gibt es nicht in jedem Monat.
|
||||
Ausgelassen wird er dann, nicht vorgezogen -- und das gehört
|
||||
in die Auswahl, nicht in eine Fußnote, die niemand liest. */
|
||||
hinweis: l.tag > 28 ? ` (Monate ohne den ${l.tag}. fallen aus)` : '',
|
||||
},
|
||||
{ wert: 'monatlich_letzter', text: 'jeden Monat am letzten Tag' },
|
||||
{
|
||||
wert: 'monatlich_wochentag',
|
||||
text: l.letzterWochentag
|
||||
? `jeden Monat am letzten ${wt}`
|
||||
: `jeden Monat am ${ORDNUNG[l.nter]} ${wt}`,
|
||||
},
|
||||
{ wert: 'jaehrlich', text: `jedes Jahr am ${l.tag}. ${MONATE[l.monat]}` },
|
||||
];
|
||||
}
|
||||
|
||||
/** Füllt die Auswahl neu -- bei jedem Datumswechsel, denn "jeden
|
||||
* Dienstag" wird zu "jeden Mittwoch", sobald man das Datum ändert.
|
||||
* Die getroffene Wahl bleibt dabei erhalten. */
|
||||
function taktAuswahlFuellen() {
|
||||
const feld = $('f-takt');
|
||||
const tag = ($('f-beginn').value || '').slice(0, 10);
|
||||
const gewaehlt = feld.value;
|
||||
feld.replaceChildren();
|
||||
const leer = document.createElement('option');
|
||||
leer.value = ''; leer.textContent = 'Einmalig – wiederholt sich nicht';
|
||||
feld.append(leer);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(tag)) return;
|
||||
for (const t of taktListe(tag)) {
|
||||
const o = document.createElement('option');
|
||||
o.value = t.wert;
|
||||
o.textContent = t.text + (t.hinweis || '');
|
||||
feld.append(o);
|
||||
}
|
||||
feld.value = gewaehlt;
|
||||
/* War der alte Wert nicht mehr dabei, fällt die Auswahl auf
|
||||
"Einmalig" zurück -- lieber nichts wiederholen als etwas
|
||||
anderes als angezeigt. */
|
||||
if (feld.value !== gewaehlt) feld.value = '';
|
||||
wdhVorschau();
|
||||
}
|
||||
|
||||
/** Der Satz unter der Auswahl: was gleich entsteht, in Worten. */
|
||||
function wdhVorschau() {
|
||||
const takt = $('f-takt').value;
|
||||
$('feld-wdh').hidden = !takt;
|
||||
if (!takt) { $('wdh-satz').textContent = ''; return; }
|
||||
const tag = ($('f-beginn').value || '').slice(0, 10);
|
||||
const uhr = ($('f-beginn').value || '').slice(11, 16);
|
||||
const eintrag = taktListe(tag).find((t) => t.wert === takt);
|
||||
const ende = $('f-ende').value;
|
||||
$('wdh-satz').textContent = 'Läuft ' + (eintrag ? eintrag.text : takt)
|
||||
+ (uhr ? ` um ${uhr} Uhr` : '')
|
||||
+ (ende ? ` bis zum ${tagLang(ende)}` : ', bis du sie abstellst')
|
||||
+ '.';
|
||||
}
|
||||
|
||||
/* ---------- Die Liste der laufenden Wiederholungen ------------------------ */
|
||||
|
||||
async function serienLaden() {
|
||||
try {
|
||||
const a = await hole('/workspace/api/serien');
|
||||
if (!a.ok) { serien = []; return; }
|
||||
serien = (await a.json()).serien || [];
|
||||
} catch { serien = []; }
|
||||
}
|
||||
|
||||
function baueSerien() {
|
||||
const ziel = $('serien');
|
||||
ziel.replaceChildren();
|
||||
$('serien-block').hidden = !serien.length;
|
||||
if (!serien.length) return;
|
||||
|
||||
for (const s of serien) {
|
||||
const k = el('div', 'k-serie');
|
||||
k.dataset.aktiv = s.aktiv ? 'ja' : 'nein';
|
||||
const farbe = ARTFARBE[s.art] || ARTFARBE.termin;
|
||||
k.style.setProperty('--sfarbe', farbe);
|
||||
k.style.setProperty('--ton', farbe);
|
||||
|
||||
const kopf = el('div', 'k-serie__kopf');
|
||||
kopf.append(el('span', 'k-serie__titel', s.titel));
|
||||
kopf.append(el('span', 'k-serie__art', ARTNAME[s.art] || s.art));
|
||||
/* Der Zustand als Wort, nicht nur als Farbe -- "pausiert" muss man
|
||||
lesen können, ohne Farben unterscheiden zu müssen. */
|
||||
kopf.append(el('span', 'k-serie__zustand', s.aktiv ? 'läuft' : 'pausiert'));
|
||||
k.append(kopf);
|
||||
|
||||
/* Der Satz kommt vom Server. Bewusst nicht hier noch einmal
|
||||
gebaut: Wenn zwei Stellen denselben Satz formulieren, sagen sie
|
||||
irgendwann Verschiedenes -- und dann glaubt man der falschen. */
|
||||
k.append(el('p', 'k-serie__satz', s.satz));
|
||||
k.append(el('p', 'k-serie__naechster', s.naechster
|
||||
? 'Nächster Termin: ' + tagLang(s.naechster.slice(0, 10))
|
||||
+ ', ' + s.naechster.slice(11, 16) + ' Uhr'
|
||||
: s.aktiv ? 'Kein weiterer Termin – die Serie ist ausgelaufen.'
|
||||
: 'Pausiert – es kommt nichts nach.'));
|
||||
|
||||
if (s.darf_aendern) {
|
||||
const knoepfe = el('div', 'k-serie__knoepfe');
|
||||
knoepfe.append(tuKnopf(s.aktiv ? 'abstellen' : 'wieder starten', async () => {
|
||||
if (s.aktiv && !confirm(
|
||||
'Wiederholung „' + s.titel + '" abstellen?\n\n'
|
||||
+ 'Kommende Termine, die noch niemand angefasst hat, werden aus dem '
|
||||
+ 'Kalender genommen. Vergangene und bearbeitete Termine bleiben stehen.\n\n'
|
||||
+ 'Du kannst sie jederzeit wieder starten.')) return;
|
||||
const a = await hole('/workspace/api/serien/' + s.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aktiv: s.aktiv ? 0 : 1 }),
|
||||
});
|
||||
if (!a.ok) { melde('Umschalten ging nicht.'); return; }
|
||||
await laden();
|
||||
}));
|
||||
knoepfe.append(tuKnopf('löschen', async () => {
|
||||
if (!confirm('Wiederholung „' + s.titel + '" ganz löschen?\n\n'
|
||||
+ 'Die Regel ist damit weg. Kommende, unangetastete Termine werden '
|
||||
+ 'entfernt; vergangene und bearbeitete bleiben als normale Termine '
|
||||
+ 'stehen.')) return;
|
||||
const a = await hole('/workspace/api/serien/' + s.id, { method: 'DELETE' });
|
||||
if (!a.ok) { melde('Löschen ging nicht.'); return; }
|
||||
await laden();
|
||||
}));
|
||||
k.append(knoepfe);
|
||||
}
|
||||
ziel.append(k);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Filter -------------------------------------------------------- */
|
||||
|
||||
function baueFilter() {
|
||||
@@ -570,7 +769,9 @@
|
||||
k.style.setProperty('--ton', e.farbe);
|
||||
|
||||
k.append(el('span', 'k-zeile__zeit', e.uhr ? e.uhr + ' Uhr' : '—'));
|
||||
k.append(el('span', 'k-zeile__titel', e.titel));
|
||||
const titel = el('span', 'k-zeile__titel', e.titel);
|
||||
if (e.serie_id) titel.append(wiederholZeichen());
|
||||
k.append(titel);
|
||||
|
||||
const rechts = el('div', 'k-zeile__knoepfe');
|
||||
rechts.append(el('span', 'k-zeile__art', ARTNAME[e.art] || e.art));
|
||||
@@ -591,7 +792,16 @@
|
||||
}));
|
||||
if (LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id) {
|
||||
rechts.append(tuKnopf('löschen', async () => {
|
||||
if (!confirm('Termin „' + e.titel + '" löschen?')) return;
|
||||
/* Bei einem Serientermin muss unmissverständlich dastehen,
|
||||
dass NUR dieser eine Tag entfällt. Sonst löscht jemand ihn
|
||||
in der Annahme, die Wiederholung sei damit abgestellt --
|
||||
und wundert sich nächste Woche. */
|
||||
const frage = e.serie_id
|
||||
? 'Nur diesen einen Termin am ' + tagLang(e.tag) + ' streichen?\n\n'
|
||||
+ 'Die Wiederholung „' + e.titel + '" läuft weiter. Zum Abstellen '
|
||||
+ 'den Schalter unter „Läuft von allein" benutzen.'
|
||||
: 'Termin „' + e.titel + '" löschen?';
|
||||
if (!confirm(frage)) return;
|
||||
const a = await hole('/workspace/api/termine/' + e.id, { method: 'DELETE' });
|
||||
if (!a.ok) { melde('Löschen ging nicht.'); return; }
|
||||
await laden();
|
||||
@@ -765,8 +975,13 @@
|
||||
if (!a.ok) { melde('Kalender konnte nicht geladen werden.'); return; }
|
||||
const roh = await a.json();
|
||||
daten = { eintraege: vereinheitlichen(roh), heute: heuteText() };
|
||||
/* Die Wiederholungen NACH den Terminen holen: Der Serveraufruf
|
||||
oben legt fehlende Ausprägungen an, und erst danach stimmt der
|
||||
"nächste Termin" in der Liste darunter. */
|
||||
await serienLaden();
|
||||
baueSaison();
|
||||
baueMonatsanlaesse();
|
||||
baueSerien();
|
||||
zeichnen();
|
||||
/* Sprung vom Bericht -- erst NACH dem Zeichnen, vorher gibt es die
|
||||
Tagesbloecke nicht. Nur beim ersten Mal: Sonst reisst es die
|
||||
@@ -839,6 +1054,9 @@
|
||||
if (!tag) d.setHours(18, 0, 0, 0);
|
||||
$('f-beginn').value = new Date(d.getTime() - d.getTimezoneOffset() * 60000)
|
||||
.toISOString().slice(0, 16);
|
||||
/* Die Auswahl der Rhythmen hängt am Datum ("jeden Dienstag") und
|
||||
muss deshalb bei jedem Öffnen neu beschriftet werden. */
|
||||
taktAuswahlFuellen();
|
||||
umschalten(true);
|
||||
}
|
||||
|
||||
@@ -849,9 +1067,21 @@
|
||||
};
|
||||
$('neu-oeffnen').addEventListener('click', () => formularOeffnen(null));
|
||||
$('neu-abbrechen').addEventListener('click', () => {
|
||||
$('neu').reset(); $('neu-fehler').textContent = ''; umschalten(false);
|
||||
$('neu').reset();
|
||||
$('neu-fehler').textContent = '';
|
||||
/* reset() setzt die Auswahl auf ihren ersten Eintrag zurück, aber
|
||||
nicht den Vorschausatz und die Ende-Zeile -- die blieben sonst
|
||||
stehen und behaupteten beim nächsten Öffnen eine Wiederholung,
|
||||
die gar nicht gewählt ist. */
|
||||
$('f-takt').value = '';
|
||||
wdhVorschau();
|
||||
umschalten(false);
|
||||
});
|
||||
|
||||
$('f-beginn').addEventListener('change', taktAuswahlFuellen);
|
||||
$('f-takt').addEventListener('change', wdhVorschau);
|
||||
$('f-ende').addEventListener('change', wdhVorschau);
|
||||
|
||||
$('neu').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
$('neu-fehler').textContent = '';
|
||||
@@ -869,6 +1099,16 @@
|
||||
koerper.teilnehmer_id = wahl || null;
|
||||
koerper.creator_id = wahl || null;
|
||||
}
|
||||
/* Mit Rhythmus entsteht keine einzelne Zeile, sondern eine Regel --
|
||||
der Server legt daraus die Termine an, den ersten eingeschlossen.
|
||||
Deshalb wird hier nichts zusätzlich abgeschickt: Sonst stünde der
|
||||
erste Termin doppelt im Kalender. */
|
||||
if ($('f-takt').value) {
|
||||
koerper.wiederholung = {
|
||||
takt: $('f-takt').value,
|
||||
ende_tag: $('f-ende').value || null,
|
||||
};
|
||||
}
|
||||
knopf.disabled = true;
|
||||
try {
|
||||
const a = await hole('/workspace/api/termine', {
|
||||
@@ -880,7 +1120,10 @@
|
||||
$('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
|
||||
return;
|
||||
}
|
||||
$('neu').reset(); umschalten(false);
|
||||
$('neu').reset();
|
||||
$('f-takt').value = '';
|
||||
wdhVorschau();
|
||||
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);
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -152,9 +152,9 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -132,9 +132,9 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/automation.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/automation.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+12
-12
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -115,13 +115,13 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -54,10 +54,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/calls.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/calls.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-10
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -164,11 +164,11 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/content.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/vorlagen.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/content.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -93,9 +93,9 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
type="image/webp" media="(max-width: 899px)" />
|
||||
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="gate">
|
||||
@@ -143,6 +143,6 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/gate.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/gate.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+39
-8
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -89,11 +89,33 @@
|
||||
<label class="feld-schild" for="f-dauer">Dauer <span class="feld-schild__still">Minuten</span></label>
|
||||
<input id="f-dauer" type="number" min="5" max="1440" step="5" value="30" />
|
||||
</div>
|
||||
<!-- Wiederholen. Die Auswahl wird erst beim Öffnen gefüllt, weil
|
||||
ihre Beschriftungen vom gewählten Datum abhängen: "jeden
|
||||
Dienstag", "jeden Monat am 2." – so muss niemand raten, was
|
||||
ein Rhythmus im eigenen Fall bedeutet. -->
|
||||
<div>
|
||||
<label class="feld-schild" for="f-takt">Wiederholen</label>
|
||||
<select id="f-takt">
|
||||
<option value="">Einmalig</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="feld-teilnehmer" hidden>
|
||||
<label class="feld-schild" for="f-teilnehmer">Mit wem</label>
|
||||
<select id="f-teilnehmer"><option value="">—</option></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erscheint nur, wenn ein Rhythmus gewählt ist. Der Satz darunter
|
||||
sagt in Worten, was gleich passiert – eine Regel, die man nicht
|
||||
vorlesen kann, stellt man später nicht mehr richtig ab. -->
|
||||
<div class="neu__zeile k-wdh" id="feld-wdh" hidden>
|
||||
<div class="k-wdh__ende">
|
||||
<label class="feld-schild" for="f-ende">Läuft bis
|
||||
<span class="leise">(leer = bis du sie abstellst)</span></label>
|
||||
<input id="f-ende" type="date" />
|
||||
</div>
|
||||
<p class="k-wdh__satz" id="wdh-satz" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="neu__zeile">
|
||||
<label class="feld-schild" for="f-ort">Ort oder Link <span class="leise">(optional)</span></label>
|
||||
<input id="f-ort" maxlength="400" placeholder="Discord, Meet-Link, Adresse …" />
|
||||
@@ -109,6 +131,15 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Die laufenden Wiederholungen. Steht bewusst über dem Kalender und
|
||||
nicht in einem Menü versteckt: Was von allein weiterläuft, muss
|
||||
man sehen können, sonst weiß nach drei Monaten niemand mehr,
|
||||
warum jeden Dienstag ein Call im Kalender steht. -->
|
||||
<section class="k-leiste k-serien" id="serien-block" hidden>
|
||||
<p class="feldschild">Läuft von allein</p>
|
||||
<div class="k-serienliste" id="serien"></div>
|
||||
</section>
|
||||
|
||||
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
|
||||
|
||||
<!-- Vier Ansichten auf DENSELBEN Daten. Der Server liefert einen
|
||||
@@ -147,9 +178,9 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -121,9 +121,9 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/personen.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/personen.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -193,10 +193,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/profil.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/profil.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -91,10 +91,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/report.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/report.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -124,9 +124,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -99,9 +99,9 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/start.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/start.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -70,10 +70,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/ki.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -111,9 +111,9 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -57,9 +57,9 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609021605" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609021711" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609021711" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -175,9 +175,9 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202609021605" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202609021711" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202609021711" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user