BTV Portal

Changelog — BTV Portal

Alle Änderungen mit Bereichs-Präfix (core:, später <modul>:). SemVer;

app_version() liest die erste ## [x.y.z]-Überschrift.

[0.108.0] — 2026-07-31

Durchsicht des in dieser Runde Gebauten: Was mehr als ein Modul brauchen wird,

gehört in den Kern — und was doppelt dieselbe Datenbank fragt, gehört

zusammengefasst.

stand schon zweimal im Anliegen-Modul (Board und Priorisierung); ein

drittes Modul hätte es ein drittes Mal abgeschrieben. Beides liegt jetzt in

app/helpers.php, beide digi-Ansichten benutzen es. Die Auswertung der Suche

bleibt beim Modul — nur es kennt seine Spalten.

ruft sie über content_default() bei jedem Text auf; ohne

Zwischenspeicher lief das auf einer Seite mit zwanzig Texten zwanzigmal durch

alle Module.

Zwei Ursachen: repo_roles_all() zählt je Rolle die Konten mit — eine

Unterabfrage, die hier niemand braucht —, und die Kopfzeile fragt zweimal,

apply_view_as() ein drittes Mal. Jetzt schlanke Abfrage plus

Zwischenspeicher.

Aufwandssumme holte den Sprintinhalt ein zweites Mal aus der Datenbank,

obwohl die Ansicht ihn längst geladen hatte — eine Abfrage je Spalte ohne

Erkenntnisgewinn. Neu digi_aufwand_summe($liste); digi_sprint_fortschritt()

nimmt den Inhalt optional entgegen.

verbindlich dokumentiert — Arbeitslisten (Filter, Suche, Spaltenwahl,

gemerkte Ansichtszustände samt der data-*-Tabelle), Texte und Mailvorlagen

über <modul>_content_registry(), Einstellungen mit Unterreitern **samt der

Falle, dass Teilformulare die übrigen Bereiche leeren**, und die Übernahme aus

einem Altsystem („schon da" heißt nicht „vollständig"). Die Fertig-Checkliste

führt die Punkte auf.

Prüfung, dass ein Modul die Suche nicht abweichend nachbaut und dass jeder

Modul-Text einen Präfix und eine Gruppe hat.

[0.107.0] — 2026-07-31

Die Auswahl war fest verdrahtet (Referent, Ehrenamt, BTV Hauptberuf,

GS-Admin, Admin) und stammte aus der Zeit vor den Fachmodulen. Wer prüfen

wollte, was jemand mit „Anliegen: Bearbeitung" oder „Newsletter-Sync —

Ausführen" sieht, konnte das nicht. Die Liste kommt jetzt aus der

Rollentabelle; später angelegte Rollen sind automatisch dabei.

Die Rolle Admin konnte schon immer umschalten (is_real_admin() prüft

admin *oder* super_admin) — was fehlte, war die Auswahl.

angeboten, und angeboten wird überhaupt nur, was die fragende Person

rechtlich ohnehin darf. Das ist kein Beiwerk: Die Umschaltung setzt die

effektiven Rollen auf die gewählte, und admin wie super_admin tragen

bewusst keine Modulrechte. Ohne diese Regel verschaffte „als Anliegen:

Verwaltung" genau diese Rechte — samt Schreibzugriff auf fremde Fachdaten,

per Auswahlfeld und ohne Spur in der Rollenvergabe. Wer eine Modulansicht

prüfen will, gibt sich die Modulrolle: sichtbar, protokolliert und wieder

entziehbar.

Sechs Testfälle halten das fest, darunter die Gegenprobe, dass jede

angebotene Rolle eine Teilmenge der eigenen Rechte ist.

[0.106.2] — 2026-07-31

einreichen → sichten (Boards) → bewerten (Priorisierung) → einplanen

(Sprintplanung) → abarbeiten (Sprintboard) → verwalten.

Vorher stand das Sprintboard vor Priorisierung und Sprintplanung, also der

letzte Schritt vor den beiden, die ihn vorbereiten. Ein Menü, das den Ablauf

abbildet, muss man nicht erklären: Man liest es einmal von oben nach unten und

weiß, wie das Modul gedacht ist. Ein Testfall hält die Reihenfolge fest.

[0.106.1] — 2026-07-31

Übernahme sprang bei einer bereits vorhandenen Planungszeile ganz heraus

(„schon da"). Dadurch blieben genau die Zeilen leer, die ein früherer Lauf

angelegt hatte, bevor die Sprints da waren: 10 von 11 Sprint-Zuordnungen

und der Großteil von Aufwand, Priorität und Zieldatum fehlten.

Dasselbe Muster wie zuvor bei den Anhängen — „schon da" heißt nicht

„vollständig". Der Abgleich füllt jetzt Leerstellen auf: Was im Portal

einen Wert hat, bleibt unangetastet (dort hat jemand bewusst etwas

eingetragen); was leer ist, wird nachgetragen — auch an alten Zeilen.

Auf btv-portal.de nachgezogen: 12 Zeilen ergänzt. Sprint 9 hat jetzt 3,

Sprint 10 fünf und Sprint 11 drei Anliegen — deckungsgleich mit dem

Altsystem. Eine Gegenprüfung über alle 70 Altzeilen meldet nichts Offenes

mehr bei Sprint, Aufwand, Priorität und Zieldatum.

[0.106.0] — 2026-07-31

Texte in einer einzigen, sehr langen Liste — Rechtstexte neben Mail-Signatur.

Jetzt gruppiert (Startseite, Registrierung, Hilfe, Rechtstexte, Konto &

Auskunft, Mails) und über die Adresse verlinkbar (?bereich=…), ohne

JavaScript. Nach dem Speichern landet man wieder im selben Reiter.

<modul>_content_registry()). Sie erscheinen als eigene Gruppe im Editor und

können keine Kern-Schlüssel überschreiben — ein Modul soll die Rechtstexte

des Portals nicht umschreiben können.

Statusänderung, neue Nachricht sowie die beiden internen Meldungen, je Betreff

und Text. Bewusst ein Satz mit Platzhaltern ({titel}, {nr}, {status},

{objekt}, {team}, {link} …) statt je Board eigener Fassungen: Das

Altportal hatte 24 Vorlagen, drei Kopien desselben Satzes laufen unweigerlich

auseinander — und was sie unterschied (Objektname, Team), leistet genau ein

Platzhalter. Unbekannte Platzhalter bleiben stehen, damit ein Tippfehler in

der Vorlage auffällt statt still eine Lücke zu hinterlassen.

und Ziel lagen in einer Zelle mit colspan="3", die Kopfzeile hatte dort

drei Spalten. Jetzt sitzt jedes Feld in seiner eigenen Spalte; das Formular

steht einmal je Zeile und die Felder verweisen über das form-Attribut

darauf.

priorisiert") und Volltext. **Das Verschieben funktioniert bei aktivem

Filter**: Getauscht wird mit dem vorherigen bzw. nächsten *sichtbaren*

Eintrag, die Reihenfolge aller übrigen bleibt unangetastet — ein blosses „eine

Position hoch" hätte die ausgeblendeten Einträge übersprungen und ihre

Reihenfolge stillschweigend verwürfelt. Nach dem Speichern kehrt die Ansicht

in dieselbe gefilterte Liste zurück.

klappt jetzt fest nach links auf; bei den übrigen entscheidet das beim Öffnen,

weil ihre Position von Fensterbreite und Zeilenumbruch abhängt — das lässt

sich in CSS allein nicht bestimmen.

[0.105.0] — 2026-07-31

aus dem Altportal sind zurück — samt Datenmodell (digi_planung,

digi_sprints, Migration 0034) und Übernahme der Altdaten aus

planning_items/sprints über die Altnummern.

Priorität, Aufwand (XS–XL mit fibonacci-nahen Punkten) und Zieldatum je

Vorgang.

Aufwandssumme je Sprint. Eine Kapazitätsgrenze gibt es bewusst nicht — sie

wäre eine Zahl, die niemand belastbar festlegen kann.

Arbeitsstufen (Geplant → In Umsetzung → In Test → In Review →

Erledigt), mit Fortschritt und Aufwand im Kopf.

Verschoben wird überall über Knöpfe und Auswahlfelder, nicht per Ziehen:

bedienbar mit der Tastatur (WCAG 2.1.1), benutzbar auf Touchgeräten und ohne

JavaScript — dieselbe Linie wie bei den Spalten des Boards.

Status sagt der einreichenden Person, wo ihr Anliegen steht („In

Spezifikation"); die Stufe sagt dem Team, was zu tun ist („In Test"). Beides

in ein Feld zu pressen zwingt eine der beiden Seiten in die Sprache der

anderen. Ebenso ist „blockiert" ein Feld samt Grund statt eines Vermerks

im Titel.

Auswahllisten, Planung, Board-Freigaben. Sie laufen über die Adresse

(?unter=…), sind also verlinkbar und funktionieren ohne JavaScript.

Wichtig dabei: Jedes Teilformular schickt seine Bereichs-Kennung mit.

Ohne die löschte ein Speichern im Reiter „Auswahllisten" die Status-Mails

aller Boards — deren Felder fehlten im Formular und sähen aus wie „bewusst

geleert". Ein Testfall hält das fest.

Priorisierungs-Stufen, „Anfrage aus zuständigem Bereich" und

„Bereichsleitung informiert" (deren Funktionen gab es hier schon, nur ohne

Oberfläche: Die Werte ließen sich nicht mehr ändern, obwohl sie in jedem

Formular auftauchen), dazu neu die Boards mit Planung, die Status für die

Planung und die Stufen des Sprintboards.

kannte zwei Quellen: die pflegbaren Listen und feste Einträge in seiner

config.php (roles.admin, roles.pm, board_access[…]). Wer nur die

Listen übernimmt, verliert ausgerechnet die Personen, die das Altportal von

Anfang an verwaltet haben — sie standen nie in einer Liste, weil sie sie nicht

brauchten. Die Anker kommen jetzt aus digi.legacy.anker in der config.php

dieses Portals; drei Testfälle sichern die Zusammenführung.

[0.104.0] — 2026-07-31

wurde das Feld „Einreichende Person" mal mit dem Namen, mal mit der

E-Mail-Adresse gefüllt. Folge: „Philipp Deininger" und

„philipp.deininger@…" nebeneinander in jedem Filter — und ein Filter auf den

Namen übersah die übrigen Vorgänge derselben Person.

Korrigiert wird jetzt der Wert selbst, nicht nur die Anzeige: Der Filter

vergleicht genau dieses Feld, eine Anzeigekosmetik hätte das eigentliche

Problem stehen lassen. Der Name kommt aus dem Portalkonto zur Adresse, sonst

aus einem anderen Vorgang derselben Adresse, der schon einen Namen trägt.

Ist keine der beiden Quellen ergiebig, bleibt die Adresse stehen — lieber

eine Adresse in der Liste als ein geratener Name an einem Vorgang. Sobald die

Person ein Portalkonto hat, räumt der nächste Abgleich den Rest von selbst auf.

updated_at bleibt dabei unangetastet, sonst hielte der nächste Abgleich

jeden berührten Vorgang für „im Portal bearbeitet" und meldete ihn als

Konflikt. Der Schritt läuft bei jedem Abgleich mit (der Probelauf zeigt ihn

nur an) und ist wiederholbar.

Auf btv-portal.de angewendet: 6 von 12 Adressen aufgelöst, 30 Zeilen

korrigiert; 6 Adressen ohne Portalkonto bleiben vorerst stehen.

[0.103.0] — 2026-07-31

vollen Namen; der volle Name steht als Kurzinfo an der Zelle. Die Spalte

schrumpft dadurch von 7,5 auf 4,2 rem.

jetzt alle Spalten mit Pfeilen zum Verschieben — oben in der Liste = links in

der Tabelle; dazu „Standard wiederherstellen". Die Reihenfolge liegt wie die

Sichtbarkeit im Browser, je Person und Gerät.

Bewusst Knöpfe statt Ziehen: Das ist mit der Tastatur bedienbar (WCAG 2.1.1)

und kollidiert auf Touchgeräten nicht mit dem Scrollen — dieselbe Begründung

wie beim Anordnen der Profilblöcke.

Dabei behoben: Der Tabellen-Sortierer merkte sich den **Spaltenindex beim

Start**. Nach einem Verschieben hätte er die falsche Spalte sortiert; er

bestimmt ihn jetzt bei jedem Klick neu. Eine gemerkte Reihenfolge verträgt

außerdem entfernte und neu hinzugekommene Spalten, statt die Tabelle

unbrauchbar zu machen, bis jemand den Browserspeicher leert.

Dringlichkeit, Fachbereich, Personen und die Archivansicht reisen über die

Reiter mit. Nicht mit reist die Kategorie: Sie steht je Board in einer

anderen Spalte und ist mit ganz anderen Werten belegt — mitgenommen ergäbe sie

dort verlässlich null Treffer, und ein Filter, der beim Wechsel still alles

wegräumt, sieht wie ein Fehler aus.

Die Einträge der linken Navigation bleiben absichtlich filterfrei: Sie sind

Navigation, keine Umschaltung innerhalb derselben Ansicht.

Person** — wie schon beim Filterbereich, damit an einem gemeinsam genutzten

Rechner nicht der eine die Einstellung des anderen vorfindet. Bestehende

Spaltenwahlen beginnen dadurch einmalig von vorn.

[0.102.0] — 2026-07-31

Fachbereich und Kategorie / Art ausgeblendet — beide sind je Board

anders belegt und stiften dort mehr Verwirrung als Nutzen. In einem einzelnen

Board bleiben sie an. Über „Spalten" jederzeit einblendbar, und die Wahl wird

gemerkt: Der gespeicherte Zustand kann eine Spalte jetzt auch einschalten,

nicht nur ausschalten — sonst wäre eine ab Werk versteckte Spalte nach dem

Neuladen wieder weg gewesen.

bereits ein Link auf denselben Vorgang; zwei Wege nebeneinander kosteten nur

Breite. Wichtig dabei: Die Regel „letzte Spalte nur so breit wie ihr Knopf"

musste mit weg, sonst hätte sie nach dem Entfernen die Spalte

„Aktualisiert" auf 1 % gequetscht.

Volltextsuche mehrere Wörter, Phrasen, Ausschlüsse und Nummern versteht und

Vorschläge liefert, standen dort zwei Suchfelder mit unterschiedlicher

Reichweite nebeneinander — erklärungsbedürftig, ohne dass das zweite noch

etwas beigetragen hätte.

[0.101.1] — 2026-07-31

Textspalte, dem Titel fehlte der Platz. Ursache war die allgemeine Regel

.check-table td,th{min-width:5rem}: Sie gibt jeder Zelle dieselbe

Untergrenze — auch der, in der eine drei- bis vierstellige Zahl steht. Der

Titel, die einzige Spalte, die wirklich Platz braucht, bekam den Rest.

Jetzt haben die schmalen Spalten feste Anhaltswerte (Nr. 3,2 rem,

Dringlichkeit 5,5 rem, Prozessschritt 10,5 rem, Board 7,5 rem, Aktualisiert

8,5 rem), der Titel bekommt den größten Anteil und fällt nie unter 13 rem.

Die übrigen Textspalten haben bewusst keine Breite, nur eine Untergrenze:

Feste Werte und Prozente zusammen überbestimmen die Tabelle, und der Browser

löst das dann mit Seitwärts-Scroll auf, statt sinnvoll zu verteilen. So liegt

der Mindestbedarf bei rund 1120 px in der Gesamtsicht und 1000 px in einem

einzelnen Board — darunter greift wie bisher der Scroll-Rahmen, und Spalten

lassen sich weiterhin ausblenden.

Gilt nur in der Tabellenansicht; sobald gestapelt wird, sind Breiten

gegenstandslos.

[0.101.0] — 2026-07-31

und Syntax-Hinweis standen darüber und darunter und machten das Feld höher als

die Knöpfe daneben — dadurch brach die Zeile um. Die Beschriftung steht jetzt

nur noch vorlesbar im <label> (sichtbar sagt es der Platzhalter), die

Erklärung im Hilfeblock: „Mehrere Wörter grenzen ein. `"in

Anführungszeichen" sucht die Wortfolge, -wort` schließt aus. Die Nummer

eines Anliegens findet es direkt." Damit lässt sie sich über die Inline-Hilfe

abschalten — ein Hinweis direkt am Feld (feld_hilfe()) ist laut Konvention

immer sichtbar und wäre dafür das falsche Mittel gewesen.

Recht digi.bearbeiten geht /digi direkt ins Board (bei genau einem

freigegebenen Board gleich in dieses), sonst wie bisher zur Auswahl der

Anliegen-Arten.

Die Einreichen-Auswahl hat dafür eine eigene Adresse bekommen

(/digi/einreichen), auf die Navigation und Dashboard-Kachel zeigen — sonst

käme ein Konto mit beiden Rechten gar nicht mehr zum Formular, weil /digi

es ins Board schickt. Zwei Tests halten die Verdrahtung fest.

Nebeneffekt: Die Krume des Formulars nennt jetzt die Zwischenstufe

(„Anliegen › Einreichen › Digitalisierungs-Anliegen einreichen").

[0.100.0] — 2026-07-31

(„Fachbereichliche Begutachtung") liefen über den nächsten Eintrag. Ursache:

Die Optionsliste ist eine Flex-Spalte mit max-height — darin schrumpfen die

Kinder unter ihre Inhaltshöhe, sobald die Liste länger ist als der Platz.

Mit flex:0 0 auto behalten sie ihre Höhe; Ankreuzfeld und Text stehen jetzt

oben bündig, die Zahl rechts in Ziffernbreite.

über der Liste ein Suchfeld, das ab drei Zeichen filtert — darunter bliebe

ohnehin fast alles stehen und die Liste zappelte bei jedem Anschlag. Bereits

Angekreuztes bleibt sichtbar, auch wenn es nicht zur Eingabe passt, sonst

ginge eine getroffene Auswahl beim Weitertippen still verloren.

der ganze Eingabetext als ein Stück in den Feldern gesucht: „urlaub

antrag" fand „Urlaubsantrag digitalisieren" nicht, und nach der Nummer eines

Anliegens ließ sich gar nicht suchen — obwohl in Mails und Besprechungen genau

darüber geredet wird. Neu:

Reihenfolge und beliebigem Feld.

Fließtext, ein Titelanfang schwerer als ein Treffer mitten im Text, die

Nummer schlägt alles. Ohne Sucheingabe bleibt es bei der bisherigen

Reihenfolge.

Anliegen vor und springt auf Auswahl direkt hin, statt erst eine Trefferliste

zu bauen. Bedienbar mit der Tastatur (Pfeile, Enter, Esc) und über

aria-expanded/aria-activedescendant auch für die Vorlesefunktion

nachvollziehbar. Der Endpunkt liegt hinter demselben Recht **und derselben

Board-Freigabe** wie die Liste — ein Vorschlag darf nichts verraten, was die

Person im Board nicht sähe; die Antwort ist no-store.

Zugabe, kein Ersatz: Ohne JavaScript bleibt es ein gewöhnliches Suchfeld.

Nicht enthalten ist Tippfehler-Toleranz — dafür bräuchte es einen Index

(Trigramm o. ä.), das wäre ein eigener Schritt.

[0.99.0] — 2026-07-31

als Leiste aus Link-Chips darüber — eine zweite Bedienart für dieselbe Sache,

die eine ganze Zeile belegte. Die Zahlen je Status gehen nicht verloren: Sie

stehen jetzt hinter den Werten in der Auswahl, auch die 0 („da liegt

nichts" ist eine Aussage; ein fehlender Wert sähe nach Fehler aus).

„Einschließen / ausschließen" kommt damit aus demselben Baukasten wie bei den

anderen Feldern.

wie unformatierte Browser-Felder: Das Formular trägt bewusst nicht die Klasse

.form, weil deren Blocklayout (Label über Feld, volle Breite) die einzeilige

Filterreihe aufbräche — damit griffen aber auch deren Feldstile nicht. Jetzt

eigene Regeln für genau diese Felder, samt sichtbarem Fokusrahmen.

Board-Statusleiste (v0.97.0) kam eine eigene .chip-Regel dazu — die Klasse

gibt es im Portal aber schon für Statusangaben in Profil und

Benutzerverwaltung, und die Regel überschrieb sie portalweit. Die

board-eigenen Angaben heißen jetzt nach ihrem Ort.

Erstens kennt die Navigation nur „Modul → Kind", also genau eine Stufe —

seit die Boards einzeln in der Navigation stehen, fiel /digi/board

zwischen /digi und /digi/board/<zweig> heraus. Das Elternteil ist jetzt

die längste bekannte Route, die echter Präfix ist, und gilt damit für jede

Modultiefe.

Zweitens übergab das Modul „digi" seine Zwischenstufen als

['Beschriftung', '/pfad'] statt ['label' => …, 'href' => …]; die Krume las

nur die zweite Form und verwarf die erste stillschweigend. Vom einzelnen

Vorgang führte deshalb kein Weg zurück aufs Board. Beide Schreibweisen gelten

jetzt, und digi benutzt die dokumentierte.

Drittens prüft die Entdopplung nun gegen die ganze Kette statt nur gegen die

letzte Stufe — sonst stünde „Anliegen › Alle Boards › Anliegen › Boards" da.

Drei Testfälle halten das fest.

[0.98.0] — 2026-07-31

gleicher Höhe der Schalter „Filter & Ansicht". Alles, was die Ansicht

steuert — Aktiv/Archiv, Excel-Export, Statusleiste und Filter —, liegt jetzt

hinter diesem einen Schalter. Zugeklappt bleiben genau zwei Zeilen stehen

(Überschrift und Boardauswahl), darunter beginnt sofort die Liste; vorher

belegten Umschalter, Statusleiste und Filterkarte drei eigene Blöcke.

Der Schalter liegt absolut in der Reiterzeile, damit er beim Auf- und

Zuklappen nicht die Zeile wechselt. Die Zahl an ihm nennt, wie viele Filter

greifen — der Status zählt jetzt mit, weil auch er dort drin liegt.

in Höhe und Innenabstand verkleinert. Nur für Maus und Trackpad: Wo mit

dem Finger getippt wird (pointer:coarse), wachsen die Ziele wieder auf

Fingergröße — Treffsicherheit geht dort vor Platzersparnis.

auf jeder Seite eines Vorschau-Moduls ein Hinweisblock über die volle

Breite und schob den Inhalt nach unten. Jetzt dasselbe kompakte „Preview"

wie auf den Dashboard-Kacheln, freischwebend unter der Kopfzeile: dauerhaft

sichtbar, ohne Platz zu nehmen, und beim Scrollen mitlaufend — der Hinweis

gilt für die ganze Seite, nicht nur für ihren Anfang.

Die Erklärung geht nicht verloren: Sie steckt als Kurzinfo am Zeiger und im

vorlesbaren Text, denn ein blosses „Preview" sagt nicht, warum die Seite für

andere unsichtbar ist. Beim Drucken läuft das Label wieder im Text mit.

[0.97.2] — 2026-07-31

eingeklappt — das Board beginnt jetzt mit der Liste statt mit

Bedienelementen. Der Zustand gilt je Person und je Gerät: am

Büro-Rechner aufgeklappt, am Handy zu.

Deshalb im Browser gespeichert und nicht in der Datenbank — die gälte für

alle Geräte. Die Kennung der Person steckt im Schlüssel, damit an einem

gemeinsam genutzten Rechner nicht der eine den Zustand des anderen vorfindet.

Ein Zustand für alle drei Boards, nicht je Board: Zwischen den Reitern wird

ständig gewechselt, ein dabei mal auf- und mal zuklappender Filter wirkte

wie ein Fehler.

Ist etwas gefiltert, nennt der zugeklappte Titel die Anzahl („2 aktiv"),

damit ein aktiver Filter nicht unbemerkt bleibt. Der Status zählt dort nicht

mit — er wird oben in der Statusleiste gesetzt und ist auch dort sichtbar.

Ohne JavaScript bleibt es ein gewöhnliches <details>: zugeklappt startend,

per Klick zu öffnen. Das Merken läuft über data-klapp-merken und steht

damit auch künftigen Modulen zur Verfügung.

[0.97.1] — 2026-07-30

nicht die Reihenfolge, sondern .card:hover{transform:…}: Ein transform

erzeugt einen eigenen Stapelkontext, und darin blieb das z-index des

Klappfelds *innerhalb* der Filterkarte gefangen — die spätere Tabellenkarte

zeichnete darüber. Sichtbar wurde das genau dann, wenn der Zeiger über der

Filterkarte stand, also immer bei geöffnetem Filter. Beide Karten haben jetzt

einen ausdrücklichen z-index (Filter 40, Tabelle 1) statt sich auf die

Zeichenreihenfolge zu verlassen.

Vorher öffnete sich jedes Feld mit aktiver Auswahl beim Laden von selbst;

mehrere offene Felder lagen dann übereinander. Dass gefiltert wird, zeigt

weiterhin die Zahl im Titel. Ein Klick daneben oder Esc schließt (Esc setzt

den Fokus zurück auf das Feld); ohne JavaScript bleiben es normale <details>.

[0.97.0] — 2026-07-30

war gegenüber der Vorgängeranwendung deutlich karger — das fiel beim ersten

Arbeiten mit den übernommenen Anliegen sofort auf. Neu bzw. zurück:

Statusleiste, die mit einem Klick filtert und je Status die Anzahl nennt;

Aktiv/Archiv als Umschalter mit beiden Zahlen, statt eines Kästchens, das

erst abgeschickt werden musste; Klappfilter mit Ankreuzkästchen statt

Mehrfachauswahl-Listen (ohne Strg-Taste bedienbar, also auch mobil); Spalte

Prozessschritt mit Punktkette und „Schritt 3/9"; Excel-Export jetzt

auch in der Gesamtsicht.

Die Statusleiste sind bewusst **Links, keine sich selbst absendenden

Ankreuzfelder**: Ein Bedienelement, das beim Ändern die Seite wechselt,

verstößt gegen WCAG 3.2.2 — genau dafür wurde data-autosubmit im Portal

schon einmal entfernt. Als Link ist der Wechsel erwartbar, und er

funktioniert ohne JavaScript.

Zählung noch Auswahlwerte übergeben — der Filterbereich blieb deshalb leer,

während er in den Einzelboards funktionierte. Werte werden jetzt über alle

sichtbaren Boards vereinigt.

sichtbaren Boards nur ein Sammelpunkt „Boards" auf die Gesamtsicht. Wer täglich

in einem bestimmten Board arbeitet, kommt jetzt direkt hin.

angezeigten Zeilen beim Tippen ein (die Volltextsuche im Filter durchsucht

weiterhin den ganzen Bestand), die Spaltenwahl merkt sich je Board im Browser.

Beides bleibt ohne JavaScript verborgen statt wirkungslos.

Aufgefallen, weil migrierte SyBi-Anliegen für ein Konto unsichtbar blieben:

Die Einschränkung über user_scopes war zwar dokumentiert, ließ sich aber

nur per Datenbank setzen — der Einstellungstext beschrieb eine Verwaltung,

die es nicht gab. Neu im Reiter „Anliegen" eine Freigabe-Tabelle je Person.

Bewusst mit Auswahlknopf „alle Boards / nur diese" statt bloßer Kästchen: Kein

Eintrag bedeutet im Datenmodell „keine Einschränkung", ein leeres Formular

hätte also das Gegenteil dessen bewirkt, was es anzeigt. Eine leere Auswahl

wird abgelehnt und gemeldet, statt still alles zu öffnen.

schreiben Kerntabellen nur über Core-Funktionen (4.5); für Scopes gab es

bisher nur den Leser.

unverändert ist.** digi_migration_vorgang() stieg bei „unverändert" aus,

bevor die Anhänge an der Reihe waren. Wird das Verzeichnis der Altdateien

— wie in docs/DIGI-UMZUG.md vorgesehen — erst später eingetragen, hat sich

im Altportal nichts geändert: Jeder Vorgang galt als unverändert, und das

zugesagte Nachziehen der Dateien konnte nie greifen. Die Übernahme der Anhänge

läuft jetzt bei jedem Lauf; doppelte Anlagen verhindert weiterhin external_id.

nach Fortschritt statt nach Text — „Schritt 2/9" landete sonst vor

„Schritt 10/11".

[0.96.0] — 2026-07-30

Im Betrieb aufgefallen: Ein Super-Admin stellt ein Modul auf „Vorschau", sieht

es in den Einstellungen — und danach passiert nichts. Weder Menü noch

Dashboard zeigen es. Der Grund ist nicht der Zustand, sondern ein fehlendes

Fachrecht: <modul>_nav() und <modul>_dashboard_tiles() fragen danach,

und die Rolle super_admin trägt bewusst keine Modul-Rechte — sonst hätte sie

automatisch Zugriff auf die Daten aller Module.

Die Modul-Karte in den Einstellungen sagt das jetzt: was fehlt, warum die

Trennung so gewollt ist, und wo man es behebt. Der Hinweis steht **dort, wo

der Zustand umgestellt wird** — in einer Anleitung würde ihn niemand lesen.

Zwei Tests halten die Regel fest, dazu ein Absatz in Konzept 18.1 (v1.20),

damit künftige Module es nicht neu herausfinden müssen.

[0.95.0] — 2026-07-30

Konvention „neue Sicherheitslogik ohne Testfall gilt als unfertig" (14.8) war

beim Modul noch offen. akkreditierung_fachlogik.test.php prüft die

SAFE-SPORT-Pflichtansicht (Haken ohne Sitzungsvermerk zählt nicht), die

serverseitige Übernahme der Veranstaltungsdaten gegen manipulierte Felder,

die Abweisung deaktivierter/abgelaufener Veranstaltungen, Pflichtfelder,

Zeitraumregeln, den jahresweisen Nummernzähler, Statuswechsel samt Verlauf,

die Einteilung der Sammelaktionen und die **Datensparsamkeit der

Wettkampfleitungs-Ansicht** (keine E-Mail, keine Telefonnummer, keine internen

Bemerkungen nach außen). akkreditierung_aufbewahrung.test.php deckt Badge,

Prüf- und Nachreichen-Token sowie beide Löschstufen ab — inklusive der

Gegenproben, dass laufende Veranstaltungen unangetastet bleiben und die

anonymen Kennzahlen die Löschung überleben.

Das Modul rief start_session() — eine Funktion aus der Standalone-App, die es

im Portal gar nicht gibt. Im Web fiel das nie auf, weil die Sitzung dort längst

läuft und der Zweig nie erreicht wurde; auf der CLI (Cron, Tests) wäre es ein

Fatal gewesen. Jetzt die zentrale, gehärtete session_boot(). Die reine

Leseprüfung „Merkblatt gesehen?" startet die Sitzung nur noch, wenn das

überhaupt möglich ist, und zerstört nie eine laufende Ausgabe.

[0.94.0] — 2026-07-30

ursprünglichen Planung gab es documents noch nicht; die Entscheidung lautete

darum ausdrücklich „vorerst eine eigene Tabelle". Mit v0.91.0 ist die zentrale

Ablage da und für alle Module verbindlich — also umgestellt, solange es nichts

zu übernehmen gab (digi_attachments war nie in Betrieb). Das Modul gewinnt

damit, was es vorher selbst nicht mitbrachte: MIME-Prüfung am Dateiinhalt

statt an der Endung, sha256 je Datei, Ablage außerhalb des Webroots und die

zentrale Auslieferung mit nosniff und no-store.

Ohne diese Prüfung wäre /digi/anhang/{id} ein Zugang zu jeder Datei im

Portal gewesen, auch denen anderer Module: die Rechteprüfung dahinter fragt

„darf diese Person dieses Anliegen sehen" und liefe bei einer fremden Datei

ins Leere. Testfall vorhanden.

beim Ablegen der Dateien bereits gespeichert; scheitert eine Datei, wird sie

einzeln gemeldet, statt die Einreichung zu verlieren.

nur, wenn sie leer ist. Die Datei läuft auch dort, wo womöglich schon

Anhänge erfasst wurden; ein bedingungsloses DROP löschte dort Dateiverweise,

ohne dass es jemand merkt.

Zuordnung nahm /it-anliegen/12 und /sybi-anliegen/12 an — so heißen im

Altportal aber die Infoseiten. Die Vorgänge liegen unter …-anfrage (Sicht

der einreichenden Person) und …-board (Sicht des Teams). Zwei von drei Boards

hätten ins Leere geführt. Alle sechs Pfade sind jetzt abgedeckt und einzeln

geprüft.

einreichenden Person entstanden sind (Übernahme, Einreichung für Dritte),

bekommen ihre Kontoverknüpfung automatisch, sobald das Konto existiert.

setzte die Modulzustände um und löschte module_status beim Aufräumen,

statt den Vorzustand wiederherzustellen. Auf einer Installation mit echten

Modulen war danach jedes davon „aus" — keine Routen, keine Kacheln, keine

Jobs, dauerhaft und lautlos. Unentdeckt, weil module_status in der

Dev-Datenbank bisher gar nicht existierte; aufgefallen, als mit digi ein

zweites Modul dazukam. Jetzt wird der Vorzustand gesichert und

zurückgeschrieben, samt Testfall, der genau das prüft.

Übernahme, Wiederholung, Delta und Konflikt. Bestätigt: Der Probelauf schreibt

keine Zeile; ein zweiter Lauf legt nichts doppelt an; Eingangsdatum, Verlauf,

Nachrichten, Bearbeitungsverlauf, Nutzen, Links und Anhänge kommen vollständig

mit; ein Status, den das Portal nicht kennt, wird in die Liste aufgenommen statt

verbogen; und Arbeit, die inzwischen im Portal passiert ist, wird als Konflikt

gemeldet statt überschrieben.

erkennbare Test-Altdatenbank**. Auf dem Server steht in digi.legacy die

echte Datenbank des Altportals; ohne diesen Riegel veränderte ein Testlauf

auf Staging deren Produktivdaten.

benutzte audit_log() — die Portal-Funktion heißt audit(). Im Betrieb hätte

das beim Einreichen, bei jedem Statuswechsel, beim Verschieben, beim Löschen und

beim Speichern der Einstellungen einen Fatal Error geworfen. Kein bestehender

Test konnte das finden: Lint prüft nur Syntax, und die Handler lassen sich ohne

Request-Kontext nicht aufrufen. Behoben — und ein Testfall geht jetzt mit dem

PHP-Tokenizer durch alle Modul- und Ansichtsdateien und prüft, dass jede

aufgerufene Funktion auch existiert.

Abgleich nur die Vorgänge — wer welches Board sehen darf, hätte danach von Hand

nachgebaut werden müssen. Jetzt wird das Zugriffsmodell des Altportals

mitübersetzt: „Admin" und „Geschäftsführung" sehen alle Boards, Einzelfreigaben

(board_access_<zweig>) behalten genau ihr Board — abgebildet auf die Rollen

digi.admin/digi.bearbeit plus den Scope digi_board.

Eintrag schränkt ein, statt zu berechtigen. Ein Testfall hält das fest.

in der Konfigurationsdatei; Adminrechte gehören in die Benutzerverwaltung des

Portals und nicht in einen Datenabgleich.

Abgleich nachgezogen — still übergehen wäre die schlechtere Variante.

von den Zugangsdaten über Probelauf und Übernahme bis zum Umlegen der

Weiterleitung. Wichtig ist die Reihenfolge: Bis auf den letzten Schritt ist

alles umkehrbar, und niemand steht zwischendurch ohne Zugriff da.

[0.93.0] — 2026-07-30

der Standalone-App presse.btv-intern.de (v1.4.0) als Modul nach Konvention 4.5.

Externe Medienvertretende beantragen ohne Portalkonto unter

/akkreditierung/beantragen; der Fachbereich prüft, entscheidet und gibt Badges

aus. Startet im Zustand vorschau — Freigabe nur durch den Super-Admin.

Spam-Schutz über die Core-API (Honeypot, Zeitfalle, Rate-Limit),

Unterlagen-Nachreichen per Einmal-Link, Badge-Prüfung per QR und die

Live-Seite der Wettkampfleitung (Token, kein Konto nötig).

Webroots und wird nur über eine Route ausgeliefert, die den Aufruf in der

Sitzung vermerkt. Die Kenntnisnahme akzeptiert der Server nur mit diesem

Vermerk — ein gesetzter Haken ohne Öffnen wird abgewiesen, auch ohne JavaScript.

sichtbarer Hinweis mit Direktlink vor dem Absenden; Hinweis „nicht benötigte

Angaben dürfen geschwärzt werden" am Upload.

(Genehmigung, Badge-Mail) — jeweils mit Vorschau, wer was bekommt. Vorbefüllter

Mail-Editor; automatisch gehen nur Eingangsbestätigung und Eingangs-Info raus.

Badge-PDF (tFPDF) mit Verify-QR, Sammel-Badges und Druckliste je Veranstaltung.

das Modul hat keine eigene Upload-Tabelle.

Badges und die Links darauf 7 Tage nach Veranstaltungsende, der ganze Antrag

nach 6 Monaten. Anonyme Aggregatzahlen wandern vorher in metrics; einzeln

löschen lässt sich außerdem direkt im Antrag.

akkreditierung.bearbeiten / akkreditierung.verwalten.

public/assets/<modul>.js ab, bindet das Layout es **nur auf den Seiten dieses

Moduls** ein — kein globaler Ballast, keine Registrierung nötig.

[0.92.0] — 2026-07-30

documents-Tabelle stand seit dem MVP im Schema, die API dazu fehlte — sie

entsteht laut Konvention mit dem ersten Modul, das Dateien braucht (jetzt:

Medienakkreditierung). Enthält Ablegen von Uploads und erzeugten Artefakten

(Badge-PDF), Zufallsname außerhalb des Webroots, MIME-Prüfung am Inhalt

statt an der Endung, sha256 je Datei mit Integritätsprüfung, Ausliefern nur

über eine rechteprüfende Route und Löschen in zwei Stufen (Datei weg, Zeile

bleibt als Nachweis mit deleted_at).

['anhaenge' => [<documents.id|public_id>, …]]. Anhänge sind **Verweise auf

documents, keine Kopien** — kein DB-Wachstum, und die Aufbewahrung behält

eine einzige Löschgeschichte. Geprüft wird beim Einreihen (Existenz,

Lesbarkeit, sha256, Anzahl ≤ 5, Summe ≤ mail.attachment_max_bytes = 3 MB);

schlägt etwas fehl, wird die Mail nicht eingereiht. Fehlt eine Datei später,

scheitert der Versandversuch mit sprechendem Grund — **es geht nie eine Mail

ohne ihren Anhang raus** (eine Badge-Mail ohne Badge ist schlimmer als keine).

Graph nutzt fileAttachment, SMTP wurde auf multipart/mixed umgebaut (konnte

vorher keine Anhänge), simulate protokolliert Name, Größe und MIME.

eigener Fachadresse (Akkreditierung: presse@btv-turnen.de, damit Antworten

beim Fachbereich landen). Neue Spalte mails.mail_from (NULL = Portal-Absender).

Verbindlich mit Allowlist mail.absender_erlaubt: Ein frei setzbarer

Absender wäre eine Spoofing-Schnittstelle; nicht freigegebene oder ungültige

Adressen werden abgewiesen (kein stiller Rückfall auf den Portal-Absender) und

landen im Audit. Der Versand-Deckel (B9) bleibt unverändert je Empfänger.

abweichenden Absender — ohne Download, Dateien nur hinter der Dokument-Route.

[0.91.0] — 2026-07-30

Digitalisierungsportal (digitalportal.btv-intern.de) zieht als Modul ins

Portal — mit allen drei Boards (Digitalisierung, IT-Anliegen, SyBi-Anliegen),

Einreichformularen, Steuerungs-Boards, Nachrichten, Anhängen, CSV-Ausgabe und

dem Verschieben zwischen Boards. **Eine Tabelle mit Zweig-Spalte statt dreier

Tabellen:** Im Altsystem existierte jede Board-Funktion dreimal, und genau

daraus sind dort die meisten Fehler entstanden. Hier steht der Unterschied

zwischen den Zweigen an einer Stelle (digi_zweig()), der Rest ist einmal

vorhanden — und das Verschieben eines Vorgangs ist ein Feld-Update statt einer

Datenwanderung.

digi.verwalten). Weil das Altportal noch einige Tage parallel läuft, ist die

Übernahme kein einmaliger Umzug, sondern ein wiederholbarer Abgleich:

Probelauf zuerst, dann echte Übernahme; schon Übernommenes wird an der alten

Nummer wiedererkannt (uq_digi_legacy); Vorgänge, die inzwischen im Portal

bearbeitet wurden, bleiben unangetastet und werden als Konflikt gemeldet

(legacy_synced_at). Die alten Nummern bleiben erhalten, alte Lesezeichen

führen über /digi/alt/<board>/<nr> auf den neuen Vorgang. Planner-Verweise,

die das Portal nicht kennt, landen in den internen Notizen statt im Nichts.

beim Abgleich mit app/oidc.php verworfen: Der SSO-Login löscht

unbestätigte Rumpfkonten und legt ein frisches an — die Verknüpfung hätte

danach ins Leere gezeigt. Stattdessen findet jede Person ihre Altvorgänge über

die E-Mail-Adresse, und digi_owner_nachziehen() setzt die Verknüpfung, sobald

das Konto wirklich existiert.

repo_sql_run_file() trennte an ; bevor es Kommentarzeilen entfernte.

Ein Semikolon im Fließtext eines Kommentars zerlegte damit das folgende

CREATE TABLE mitten im Spaltenblock — mit einem Syntaxfehler, dessen Ursache

im Statement selbst nirgends zu sehen war. Aufgefallen beim Einspielen von

migrate_0029. Jetzt fliegen Kommentare zuerst raus; Testfall vorhanden.

wurden als NULL in NOT NULL-Spalten geschrieben (jedes Anliegen ohne

Angabe zu „Führungskraft informiert" wäre am Speichern gescheitert), und eine

in den Einstellungen bewusst geleerte Liste fiel still auf den Code-Standard

zurück — Status-Mails ließen sich dadurch nicht abschalten.

je Zweig in einer anderen Spalte (it_type bei IT, sonst category). In der

Sicht „Alle" wurde nur category geprüft — jedes IT-Anliegen fiel damit still

durch den Filter, obwohl sein Wert in der Auswahl stand. Zusätzlich riss ein

Ausschlussfilter Zeilen mit, die in der geprüften Spalte gar nichts stehen

hatten (NOT IN ist bei NULL weder wahr noch falsch). Beides behoben, Testfall

über alle drei Boards und die Gesamtsicht vorhanden.

Statuskette, Eingabeprüfung, CSP-Sauberkeit der Ansichten, Rendering aller

Ansichten, Wiederholbarkeit der Übernahme). Gesamt 475 grün.

migrate_0030_digi_migration_marker.sql (Marker für den Abgleich).

[0.90.0] — 2026-07-30

bisher nur hinter „angemeldet + aktuelles Passwort". Das reicht nicht: Wer eine

offene Sitzung übernimmt und das Passwort kennt (geteilt, geleakt), hätte den

Zugang an sich ziehen können, ohne den zweiten Faktor je gesehen zu haben.

Jetzt hinter require_twofa() — für Konten mit eingerichteter 2FA folgenlos

(sie haben ihn beim Anmelden erbracht), wirksam nur bei aufgeschobener 2FA.

Aufgefallen beim Abgleich mit der Datenschutz-Rückmeldung von Projekt 29.

Passwortänderung und — als eigene Zeile — die Stammdaten. Dort steht jetzt

ausdrücklich, dass das Ändern das Gate trägt, das Ansehen bewusst

nicht, samt Begründung: Name und Anschrift sind keine besonderen Kategorien

nach Art. 9 DSGVO, und ein Gate auf das Ansehen höbe den 2FA-Aufschub

(Entscheidung 2026-07-16) praktisch auf. Ebenso festgehalten: Die erstmalige

Eingabe in Etappe 5 der Registrierung trägt kein Gate.

gegen den Codestand, jetzt mit fertigen Textbausteinen für den

Ergänzungstext der Datenschutzerklärung (2FA-Absatz als Ersatz, Newsletter-

Absatz als Ergänzung).

[0.89.0] — 2026-07-29

es jetzt eine: ankreuzen, was man bekommen möchte, abschicken — **was nicht

angekreuzt ist, wird abbestellt**. Der Unterschied bleibt sichtbar:

Anmeldungen laufen über die Bestätigungsmail, Abmeldungen wirken sofort.

Dazu ein ausdrücklicher Knopf „Von allem abmelden"; nichts anzukreuzen führt

nicht stillschweigend dahin — das wäre zu folgenreich für ein Versehen.

/newsletter/abmelden/<slug> bleibt als reine Abmeldeseite bestehen, weil

dieser Link in bereits verschickten Newslettern steht.

Ein Formular, mehrere Absende-Knöpfe (name="modus") — reines HTML, ohne

JavaScript bedienbar.

Wer wissen will, wofür er angemeldet ist, lässt sich einen Link an seine

Adresse schicken. Erst dieser Nachweis erlaubt die Anzeige: Stünde der Stand

direkt nach Eingabe einer E-Mail da, könnte **jeder mit einer fremden Adresse

nachsehen, wo diese Person eingetragen ist**. Die Antwort auf die Anforderung

ist deshalb immer dieselbe, egal ob die Adresse bekannt ist.

Auf der verlinkten Seite sind die Kästchen mit dem echten Stand vorbelegt;

Änderungen wirken sofort, auch Anmeldungen — der Klick auf den Link aus

dem eigenen Postfach ist derselbe Nachweis, den eine Bestätigungsmail

einholen würde.

Token nur als Hash, Adresse verschlüsselt (sie wird gebraucht, um den Stand

zu lesen), Frist 2 Stunden, mehrfach nutzbar bis dahin, Aufräum-Job.

GET /groups.json/<Liste>/receivers/<E-Mail> statt bis zu 25.000 Datensätze

zu lesen, nur um eine Adresse nachzusehen. Unterscheidet **404 („steht nicht

drin", eine Auskunft) von jedem anderen Fehler** (ein Irrtum bei uns).

(Betriebsart simulate) kennen wir den Stand nicht — die Seite sagt das jetzt

(„Stand konnte nicht gelesen werden") statt „nicht angemeldet" zu behaupten.

darunter der Rundlauf „Stand einlesen, unverändert speichern, nichts geht

verloren" und der Negativtest, dass ein manipuliertes Formular weder in eine

fremde Liste einträgt noch aus einer nicht angebotenen austrägt. 419 grün.

[0.88.0] — 2026-07-29

(TECHNISCHES-KONZEPT.md v1.19). Es bündelt, was beim Bau des ersten

Fachmoduls gelernt wurde, damit jedes weitere Modul es von Anfang an erfüllt:

Modul-Zustand aus/vorschau/freigegeben (Freigabe nur durch Super-Admin) ·

Navigationspfad bis in die Detailebene · Einstellungen als Reiter · Modulbild ·

Inline-Hilfe als Pflicht mit der Frage „woher kommen die Daten" ·

Knopf-Sprache · Mobil-Vorgaben · öffentliche Modul-Routen · vier Regeln für

Fremdsysteme · Test- und Abnahmepflichten · Fertig-Checkliste. Jede Vorgabe

stammt aus einem konkreten Fehler, nicht aus der Theorie.

Nachgezogen: 4.5 (module_status statt modules_enabled), Routen-Tabelle 4.4

(/system mit vier Reitern, Alt-Routen leiten um), Anhang A, Querverweise aus

13.1 und 14.8.

Modul-Autoren, mit Codebeispielen für Inline-Hilfe, Navigationspfad,

Einstellungs-Reiter, gestapelte Tabellen und öffentliche Routen.

prüft den Quelltext aller Views — Kern und Module —, sodass ein neues

Fachmodul automatisch miterfasst wird. Beim ersten Lauf hat er 17 Views

gefunden, die der Rendering-Durchgang nicht erwischt hatte, weil dort mangels

Daten gar keine Tabelle erschien. Alle nachgestapelt.

Der Test unterscheidet dabei bewusst: Wertepaar-Listen ohne Kopfzeile stehen

schon wie eine Karte da und brauchen nichts; Aktions- und Auswahlspalten haben

keinen Spaltennamen und bekommen keinen erfundenen.

automatisierten Fälle nach Bereichen, und was sie nicht abdecken (sechs

Befunde, die live waren, während alle Tests grün standen; vier Prüfrezepte).

[0.87.0] — 2026-07-29

Vollständiger Durchgang über alle 29 Portalseiten (angemeldet als Admin, jede

Seite einzeln gemessen bei 390 px und 1280 px).

Umleitungen gewollt (Re-Auth bei E-Mail/Passwort, /verwaltung/rollen

Einstellungen, /selfcheck/system, Impressum extern).

ein Sicherheitsnetz, das den Überlauf nicht verhindert, sondern abschneidet

— zu breiter Inhalt verschwand lautlos nach rechts. Im Changelog betraf das

200 px: technische Ketten wie Auth/2FA/Krypto/Tokens/OIDC/Web/DSGVO brechen

in keinem Browser um. Jetzt overflow-wrap:break-word auf Inhaltsflächen

(bricht nur, wenn es sonst nicht passt) und anywhere in code/kbd/samp.

Gilt portalweit, also auch für jedes künftige Modul.

Mail-Versand, Kennzahlen (3), Rollenverwaltung, Konto-Verlauf, Nachrichten und

Prüfung — Spaltennamen mechanisch aus dem Tabellenkopf übernommen. Damit

scrollt keine Tabelle des Portals mehr seitwärts.

„unsichtbar" gestellt, behielt aber seine Breite und ragte gemessen 492 px

hinaus → jetzt ausgeblendet (die Spaltennamen stehen ohnehin vor jedem Wert).

Lange Schlüssel in Zellen brechen um.

Anschrift, Beim BTV, Zugang & Sicherheit, Bankverbindung, Verknüpfung) und

Benutzerverwaltung (Kontostatus, Stammdaten, Rollen, Mitgliederverwaltung,

Eingriffe, Nachrichten, Verlauf, Löschen, Prüfung) erklären jetzt in jedem

Block, woher die Daten kommen und was das für Änderungen bedeutet — etwa

dass Bankdaten nie aus der Mitgliederverwaltung stammen, dass eine angefragte

Rolle keinerlei Rechte verschafft, dass eine Korrektur an übernommenen Werten

beim nächsten Abgleich wieder auseinanderläuft, und dass das Protokoll

festhält *dass* etwas geändert wurde, nie *welcher Wert*.

[0.86.0] — 2026-07-29

Nachbesserung der Mobil-Durchsicht anhand eines echten Geräts — die

Kopfzeile war zwar überlauffrei, aber immer noch unbrauchbar dreizeilig, und

das Benutzermenü klappte aus dem Bildschirm.

der Erklärungen-Schalter, die Admin-Einstellungen und der Rollen-Umschalter

liegen jetzt hinter einem Menü. Oben bleiben **Logo, dieses Menü und das

Profilbild** — beide Klappelemente am rechten Rand. Die Kopfzeile ist damit

auf schmalen Geräten wieder einzeilig: 61 px statt 198. Am Desktop

unverändert (Zusammenfassung per CSS unsichtbar, Inhalt als Reihe). Ungelesene

Nachrichten werden am geschlossenen Menü angezeigt — sonst wäre die Anzahl

hinter einem Klick verschwunden.

vom Gerät: Das Benutzermenü war links verankert und ging nach links auf. Beide

Fenster hängen jetzt am rechten Rand der Kopfzeile — nicht am jeweiligen

Knopf, der bei einem Nachbarn rechts von sich mitten im Bild steht (gemessen

ragte das Funktionsfenster so 16 px hinaus) — und bekommen die Bildschirm-

breite als Höchstmaß. Ein Tipp daneben schließt sie.

Waagrechtes Scrollen in einer Tabelle ist auf dem Handy die schlechteste

Lösung — man sieht nie Zeile und Spaltenkopf gleichzeitig. Jede Zeile wird

deshalb zu einer Karte, in der vor jedem Wert sein Spaltenname steht.

Allgemeines Muster für Kern und Module: data-stapeln an der Tabelle,

data-spalte an den Zellen (CSS kann den Tabellenkopf nicht auslesen). Ohne

data-spalte bleibt die Zelle lesbar, nur ohne Beschriftung.

Regeln zurückgenommen, die aus der Tabellenansicht stammen und in der Karte

schadeten: width:1px der Aktionsspalte (machte die ganze Karte 80 px breit

und quetschte Auswahl- und Notizfeld auf 80 px) und die starre Eingabereihe

(ragte 10 px heraus).

ein toggle-Ereignis aus — und zwar verzögert. Ohne Unterscheidung hätte sich

jedes Menü beim ersten Zuklappen als „vom Nutzer bedient" markiert und danach

nie wieder auf eine geänderte Bildschirmbreite reagiert (Gerät drehen,

Fenster ziehen). Betraf auch die Seitenleiste aus v0.85.0.

Startseite 285 → 147, in „Listen & Segmente" 322 → 185; Überlauf 0 px.

Desktop nachgemessen unverändert: Kopfzeile 63 px klebend, Seitenleiste

250 px offen, Burger unsichtbar, Tabellen als echte Tabellen.

[0.85.0] — 2026-07-29

Mobil-Durchsicht des ganzen Portals, gemessen bei 390 px Gerätebreite (Chrome

im Kopflos-Betrieb, jede Seite einzeln vermessen — nicht geschätzt).

rechte Kopfzeilen-Gruppe stand auf flex:0 0 auto — sie durfte also nie

schrumpfen und nahm immer ihre volle Inhaltsbreite (gemessen 582 px). Auf

einem 390-px-Display lief das Portal damit um rund 500 px seitlich über:

Kacheln, Tabellen und Überschriften waren rechts abgeschnitten, die ganze

Seite ließ sich seitwärts schieben. Das flex-wrap in der Mobil-Regel lief

ins Leere — umbrechen kann nur, was vorher schmaler werden darf. Jetzt

flex:1 1 auto + min-width:0; Überlauf auf allen geprüften Seiten: 0 px.

Die Tabellen waren nie das Problem, die lagen schon korrekt in ihrem

Scroll-Rahmen.

weit nach unten — in einem geöffneten Modul begann die erste Überschrift erst

bei y=555 von 900 sichtbaren Pixeln, zwei Drittel des ersten Bildschirms

waren Navigation. Jetzt ein natives <details> („Bereiche"), am Desktop per

CSS unsichtbar. Standard ist offen: Der Desktop bleibt unverändert und

ein Gerät ohne JavaScript verhält sich wie bisher; app.js klappt sie auf

schmalen Bildschirmen zu und hält sich raus, sobald jemand selbst klappt.

rund 200 px hoch — als Dauergast wäre das ein Fünftel des Bildschirms, auf

jeder Seite. Oben steht sie vollständig da, beim Lesen macht sie Platz. Die

Symbole bleiben auf jeder Seite erreichbar. Der Rollen-Umschalter (mit 283 px

der breiteste Teil, nur für Admins) bekommt die letzte Zeile für sich, statt

die Symbolgruppe zu zerreißen.

Untermenü-Einträge, Auswahlfelder und Schalter. Fließtext-Links bleiben außen

vor — die nimmt die Richtlinie ausdrücklich aus, und 44 px hohe Zeilen mitten

im Satz würden den Text zerreißen. Zu klein waren vorher 25–30 Elemente je

Seite, jetzt 10–14 (und das sind Fließtext-nahe Links).

Startseite 432 → 285, Listen & Segmente 555 → 322, Verbindung anlegen

584 → 351. Desktop unverändert nachgemessen: Kopfzeile 63 px und klebend,

Seitenleiste 250 px offen, kein Überlauf.

gelesen, der — zwei Zeilen tiefer selbst kommentiert — gar kein DB-Konto hat.

In Produktion folgenlos (display_errors aus), im Dev-Modus stand die Warnung

über jeder Seite.

modules_enabled; seit Migration 0022 ist module_status die Wahrheit und

der Alt-Schlüssel nur noch Rückfall. Grün waren sie nur, weil in der

Entwicklungs-Datenbank module_status fehlte — mit eingespielter Migration

(auf prod längst) schlugen vier fehl. Jetzt am aktuellen Mechanismus:

407 Tests grün, 0 rot (der lange mitgeschleppte Fehlschlag war genau das).

[0.84.0] — 2026-07-29

aus der Seitenleiste — Detailseiten stehen dort nicht und fielen deshalb

heraus: „Lauf #12" hing direkt unter „Newsletter-Sync", obwohl drei Ebenen

dazwischen liegen. Eine Seite kann ihre Zwischenstufen jetzt selbst

beisteuern ($pfad an view()), portal_breadcrumb() hängt sie zwischen

Sektion und Seitentitel ein. Doppelungen fallen weg, ein Link auf die

eigene Seite wird zu Text, Stufen ohne Beschriftung werden übersprungen.

Gilt für jedes Modul, ohne Nachpflege im Core.

ausführlich bleiben („Läufe: Trainer mit Lizenz") — im Pfad steht der

Verbindungsname schon eine Stufe höher, und die Wiederholung liest sich

schlecht. Ohne Angabe bleibt es beim Titel.

· Lauf-Detail · Formular bearbeiten führen jetzt über ihre echten Ebenen

(z. B. Start › Newsletter-Sync › Verbindungen › *Trainer mit Lizenz* ›

Läufe › Lauf #12) — jede Zwischenstufe anklickbar. Dafür liest

newsletter_repo_lauf() die zugehörige Verbindung mit; vorher kannte die

Detailseite ihre Herkunft nicht und man kam von dort nicht zurück.

[0.83.0] — 2026-07-29

standen bisher nur als Vorschlagsliste am Textfeld und hingen nicht an der

oben gewählten Liste — auswählbar waren sie damit nicht. Jetzt ein Kästchen

je Segment, gebündelt nach Liste; die Auswahl oben blendet auf die passende

Gruppe um (data-gruppen-wahlapp.js, kein Inline-JS). **Ohne

JavaScript** stehen alle Blöcke da, jeder unter dem Namen seiner Liste, und

bleiben bedienbar; der Server nimmt ohnehin nur die Kästchen der gewählten

Liste an — die Kästchen sind dafür nach Liste geschachtelt

(segment_wahl[<Liste>][]). Hat eine Liste keine Segmente, sagt die Seite

das, statt leer zu bleiben.

Je Segment steht dabei, ob der Abgleich es überhaupt füllen kann: Er

schreibt in phx_segmente, ein Segment mit Regel auf btv_kennung bleibt

für ihn unerreichbar — das wäre sonst die Falle, in die man beim Ankreuzen

als Erstes läuft.

Das freie Feld bleibt (jetzt „Eigene Kennzeichen"): Ein Abgleich darf

Kennzeichen setzen, für die es noch gar kein Segment gibt — beim Einrichten

ist das der Normalfall. Gespeicherte Kennzeichen werden auf beide Wege

aufgeteilt, damit keines doppelt dasteht.

[0.82.0] — 2026-07-29

Verbindungs-Formular zeigt die Empfängerlisten aus dem Katalog zur Auswahl

(Name + Kennung). Für eine Liste, die es dort noch nicht gibt, bleibt ein

klar beschriftetes Handfeld daneben stehen — ausgefüllt sticht es die

Auswahl, damit eine frisch angelegte Liste niemanden blockiert. Ohne

aufgebauten Katalog (Betriebsart simulate) bleibt es beim bisherigen

Eingabefeld. Die Segment-Kennzeichen bekommen eine Vorschlagsliste aus den

Werten, auf die die Segmente der Zielliste tatsächlich prüfen.

Neuer Befund im Verbindungs-Formular. Er beantwortet die Frage, die sonst

erst Wochen später auffällt: Der Abgleich läuft fehlerfrei und schreibt

seine Kennzeichen — aber wenn kein Segment darauf prüft, ist in der Liste

trotzdem niemand gezielt anschreibbar. Genau das ist der reale Ausgangsfall:

Alle 10 vorhandenen Segmente filtern auf btv_kennung, der Abgleich

schreibt phx_segmente.

Geprüft wird nicht nach dem Feldnamen — ein Segment darf mehrere

Bedingungen über verschiedene Felder mit UND/ODER verknüpfen. Stattdessen

wird ein Empfänger mit dem Kennzeichen simuliert und durch die **echten

Regeln** geschickt. Zwei Durchgänge, weil es einen Unterschied macht:

allein und mit einem zweiten Kennzeichen. Eine Regel mit *ist* trifft dann

nicht mehr — und der Abgleich setzt bei „Trainer mit Lizenz" je Lizenz ein

weiteres. Dieser Fall wird als „nur allein" ausgewiesen statt als grün.

Dafür speichert der Katalog die Regeln jetzt mit aufgelösten Feldnamen mit.

überlebt eine noch nicht eingespielte Migration, statt am SQL-Fehler zu

scheitern.

[0.81.0] — 2026-07-29

Der Katalog stand auf einer falschen Annahme: „Das Newsletter-Tool kennt

unsere Segmente nicht als Objekte." Geprüft worden war dafür eine Liste, die

schlicht keine Segmente hat. Richtig ist:

GET /groups.json/<Liste>/filters.json liefert je Liste genau die Segmente,

die dort im Reiter „Segmente" stehen — real 10 Stück in 2 von 20 Listen

(GYMWELT 2, wettkampfsport 8), jeweils mit Kennung, Name und Regel.

Damit ist „welche Segmente gehören zu dieser Liste?" keine Ableitung mehr,

sondern eine Ablesung; die vorherige Herleitung aus dem Vorkommen von

Kennzeichen entfällt (sie musste scheitern, weil Kennzeichen im

Newsletter-Tool GLOBAL gelten und in jede Liste mitwandern).

Neue Tabelle newsletter_katalog_segmente; die Zahlen entstehen weiter im

nächtlichen Durchgang, indem jede Segment-Regel gegen die Empfänger

ausgewertet wird (Auswertung für EQ/CONTAINS/… inkl. Klammern und AND/OR;

unbekannte Logik zählt bewusst nichts, statt zu raten). Ergebnis stimmt

exakt mit dem Newsletter-Tool überein.

active wurde mit dem Zeitstempel activated oder-verknüpft. Der bleibt

nach einer Abmeldung stehen, also galten Abgemeldete als aktiv — und

newsletter_ziel_http_upsert() setzt für Aktive activated, hätte sie also

reaktiviert. Genau das darf nie passieren. Portalweit betraf die

Fehleinstufung 1.057 von 25.616 Empfängern (Zielliste des Abgleichs:

284). Ein tatsächlicher Wiedereintrag ist nicht nachweisbar — die 284 sind

nach dem letzten Live-Lauf unverändert inaktiv. Kriterium jetzt in

newsletter_cr_ist_aktiv() gebündelt, mit Test.

Segmente (Name, Regel, Empfänger, davon aktiv, Herkunft), darunter

aufklappbar die Kennzeichen, die dort vorkommen, aber von keinem Segment

aufgegriffen werden — die sind nicht gezielt anschreibbar. Die Auswertung

über alle Listen („Kennzeichen ohne Zuordnung") steht jetzt unter den

Listen statt darüber. Inline-Hilfe erklärt Liste/Segment/Kennzeichen neu.

[0.80.2] — 2026-07-29

zwei Zeilen „— ohne Kennzeichen —" und zählten sogar in den Badge. Der

Scanner legt dafür keine Zeile mehr an, sondern zwei Kennzahlen je Liste

(Empfänger gesamt/aktiv und „ohne jedes Kennzeichen"), die in der Kopfzeile

stehen.

[0.80.1] — 2026-07-29

erschienen in jeder Liste alle Kennzeichen — Ursache: Sie sind im

Newsletter-Tool globale Attribute. Zugehörigkeit nur noch aus einer

Erklärung (Abgleich, Formular, Vermerk); Listen auf- und zuklappbar,

Tabellen per Klick auf die Überschrift sortierbar.

[0.80.0] — 2026-07-29

aller Empfängerlisten mit den darin vorkommenden Kennzeichen und ihrer

Herkunft. Der Durchgang ist teuer (25.616 Empfänger, ~90 s) und läuft

deshalb als Job; die Seite liest nur. Herkunft wird abgeleitet, wo das

Portal sie kennt, von Hand vermerkt wird nur der Rest.

[0.79.2] — 2026-07-29

Die Bestätigungsmail von CleverReach aktiviert immer in der Liste, zu der

ihr Formular gehört — eine Kennung je Portal-Formular reicht also nicht.

Die Formular-Kennung wird jetzt je Liste hinterlegt (4. Feld der

Optionszeile).

[0.79.1] — 2026-07-29

löschen, aktiv schalten; Optionen als lesbare Textzeilen

(Liste | Beschriftung | token=Beschriftung; … | Formular-Kennung).

Adress-Kürzel (slug) wird aus dem Namen gebildet — Kleinschreibung vor

der Umlaut-Ersetzung (sonst wurde aus „Übungsleiter" „bungsleiter", E093).

[0.79.0] — 2026-07-29

Ohne Anmeldung erreichbare Seiten (/newsletter/anmelden/<slug>,

/newsletter/abmelden/<slug>) zum Ein- und Austragen für einzelne Listen

und Segmente — einbettbar auf btv-turnen.de. Die Anmeldung wird erst mit

dem Klick in der Bestätigungsmail gültig (Double-Opt-In über CleverReach),

die Abmeldung wirkt sofort. Angenommen werden nur die Kennzeichen, die

das jeweilige Formular selbst anbietet. Formulare fassen ausschließlich

btv_kennung an; die Kennzeichen des Abgleichs bleiben unberührt.

Protokoll nur als E-Mail-Hash. Neue öffentliche Routen-Kennzeichnung

('public') im Front-Controller.

[0.78.0] — 2026-07-29

Drei getrennte Menüpunkte wurden zu /system mit vier Reitern

(Protokoll · Kennzahlen · Mail-Versand · Status); die Rechte werden je

Reiter geprüft, nicht pauschal.

[0.77.2] — 2026-07-29

eingebaut (Profil, Verwaltung, Einstellungen, Freigaben …); der Schalter

sitzt jetzt direkt neben dem Hilfe-Symbol in der Kopfzeile. Gilt als

Standard für jedes künftige Modul.

[0.77.1] — 2026-07-29

wurde nicht gefunden.

[0.77.0] — 2026-07-29

direkt an Ort und Stelle, ein- und ausschaltbar und je Konto gemerkt

(user_prefs): feld_hilfe() steht immer, hilfe_block() folgt dem

Schalter. Ziel ist eine Plattform, die ohne Anleitung auskommt.

[0.76.0] — 2026-07-29

Moduls (public/assets/module/<modul>.svg, mit Rückfall auf ein Emblem) —

als Funktion für alle künftigen Module. Das Hintergrundbild wird neben der

Seitenleiste vollständig angezeigt.

[0.75.0] — 2026-07-29

eine Whitelist der Kernbereiche — Fachmodule fielen durch und wurden ohne

Kopfzeilen-Icons, ohne Seitenleiste und ohne Breadcrumb gerendert; genauso

/nachrichten, /hilfe, /mail-log und /kennzahlen. Jetzt umgekehrt: die

Shell ist der Normalfall, nur die öffentlichen Strecken (Anmeldung,

Registrierung, Token-Einlösung, 2FA-Onboarding) bleiben bewusst nackt. Damit

bleiben Einstellungen/Hilfe/Nachrichten/Freigaben/Profil durchgehend stehen —

auch in jedem künftigen Modul, ohne Nachpflege einer Liste.

Neue Systemrolle super_admin (alle Admin-Rechte + neues Recht

system.modules). Jedes Fachmodul hat genau einen Zustand: aus ·

Vorschau (läuft vollständig inkl. Cron-Jobs, ist aber nur für die

ausgewählten Rollen sichtbar und erreichbar, Kennzeichnung „(Vorschau)") ·

freigegeben. Gesteuert im neuen Reiter „Module & Freigabe" der zentralen

Einstellungen — nur mit system.modules. Der Routen-Zugang wird zentral in

public/index.php geprüft (nicht im Modul), Seitenleiste/Kacheln/Reiter über

modul_sichtbar_fuer(). Das frühere Freitextfeld modules_enabled entfällt;

bestehende Module werden als „freigegeben" übernommen. system.* gilt jetzt

durchgängig als sensibel (2FA-Gate), is_real_admin() schließt Super-Admins ein.

weist aus, dass seine Werte portalweit gelten; Modul-Reiter tragen ein

„Modul"-Kennzeichen und den Hinweis, dass sie nur dieses Modul betreffen.

die Plattform-Klassen (.form mit Feld-im-Label, .check-table in

.tabelle-scroll, .btn-primary/-secondary/-ghost/-danger, .inline-form,

.badge, .note) statt erfundener Klassen; Flash-Meldungen laufen über

flash_render() (vorher wurden sie schlicht nie angezeigt). Seitenleiste mit

Untermenü (Verbindungen · Auskunft · Einstellungen) → korrekte Breadcrumbs auf

allen /newsletter/…-Seiten. Modul-Einstellungen hängen über

newsletter_settings_tab() in den zentralen Einstellungen; die Lauf-Detailseite

zeigt die ersten 200 Einzelaktionen und sagt, wenn gekürzt wurde.

[0.74.0] — 2026-07-28

Trainer wird nach E-Mail dedupliziert (Mehrfach-Lizenzen = ein Empfänger, alle

Lizenzen zusammengeführt). phx_segmente = Basis-Token Lizenzinhabende

+ je Lizenz ein Lizenzinhabende_<Kürzel>. Die Zuordnung license_type→Kürzel

liegt in der editierbaren Einstellung newsletter_lizenz_kuerzel. Der Präfix

wird bei licenseholder automatisch aus dem Basis-Token abgeleitet

(<Basis>_), per attribut_mapping.dyn_prefix überschreibbar. Orphan entfernt

den ganzen Lizenzinhabende*-Namensraum; Tokens anderer Verbindungen bleiben.

[0.73.0] — 2026-07-28

mehrere license-holder-Datensätze mit derselben Adresse (real: 9.863 Datensätze

→ 4.709 mit E-Mail → nur 3.386 eindeutige Empfänger). Bisher zählte der Lauf

pro Datensatz („4.709 neu"), obwohl CleverReach korrekt auf die eindeutige

Adresse dedupliziert. Die Engine dedupliziert die Quelle jetzt vor dem Diff

(newsletter_src_dedup) → Kennzahlen entsprechen den echten Empfängern. Kein

Datenverlust, reine Zähl-Korrektur.

[0.72.0] — 2026-07-28

ist in CleverReach ein GLOBALES Attribut und wird beim BTV manuell gepflegt —

der Sync darf es nicht überschreiben. Deshalb schreibt der Sync die Segmente

jetzt in ein eigenes globales Feld phx_segmente (per API angelegt); intern

ist das ohnehin der Diff-Name, die Abbildung ist damit die Identität.

btv_kennung wird vom Sync nicht mehr angefasst. End-to-end verifiziert

(phx_segmente gesetzt, btv_kennung leer). Merge/Orphan/Idempotenz unverändert.

[0.71.0] — 2026-07-28

landen jetzt im echten globalen Attribut btv_kennung (nicht dem

angenommenen phx_segmente); intern bleibt phx_segmente der Diff-Feldname,

die Abbildung passiert an der CleverReach-Grenze. Zusätzlich werden beim

Upsert Vorname/Nachname (firstname/lastname) und Anrede

(salutation, Herr→m/Frau→f) als globale Attribute geschrieben; Adresse/

Geschlecht/Geburtstag bleiben bewusst leer (Datensparsamkeit). Rücklesen holt

btv_kennung wieder ins interne Format (Merge/Idempotenz bleiben intakt).

End-to-end gegen das BTV-Konto verifiziert (synthetischer Empfänger,

geschrieben/gelesen/gelöscht). Real gemessen: clubs 0 % E-Mail (nicht nutzbar),

licenseholder 46,7 %, course-speakers 44,8 % gültig.

[0.70.0] — 2026-07-28

Einstellungsseite (/newsletter/einstellungen, Recht newsletter.verwalten):

Statusmail-Empfänger, Aufbewahrung des Aktionen-Logs, sowie ein

Status-Überblick (Modus, CleverReach-/Phönix-/Pepper-Zugang „gesetzt/FEHLT" —

nie der Wert) und ein gedrosselter „CleverReach-Verbindung testen"-Button

(read-only Gruppenzahl, 1×/15 min). Damit ist das Modul funktional komplett

(NL-1…NL-8): Fundament, Quell-/Ziel-Client, Engine, Betrieb, Verwaltung,

Historie/DSGVO-Auskunft, Einstellungen. 351 Tests grün.

[0.69.0] — 2026-07-28

Verbindung mit Kennzahlen (/verbindung/{id}/laeufe), Drill-down bis zur

Einzelaktion (/lauf/{id}, Empfänger nur als Hash), und „Was ist mit einer

E-Mail passiert?" (/auskunft) — Suche ausschließlich über den E-Mail-Hash,

die eingegebene Adresse wird nicht gespeichert. Zugriff mit

newsletter.ausfuehren.

[0.68.0] — 2026-07-28

Pausieren/Aktivieren, Quell-Vorschau (erste ~20 Datensätze) und Dry-Run/Live-

Start direkt aus der Übersicht. Formular mit Validierung (Name, Quelle,

Ziel-Gruppe, mind. ein Segment-Token, Ersatz-Token-Regel, Zeitplan). Rechte

greifen: Ansehen/Vorschau/Dry-Run mit newsletter.ausfuehren, CRUD + Live-Lauf

nur mit newsletter.verwalten. Alle schreibenden Aktionen als CSRF-POST in je

einer db_tx(), kein Inline-JS (CSP), Formulare ohne JS bedienbar.

[0.67.0] — 2026-07-28

newsletter_ziel_http_lesen liest den Gruppenbestand paginiert (aktiv/

deaktiviert) und überlagert die Blacklist; newsletter_ziel_http_upsert

schreibt Empfänger im Batch, setzt activated NUR für Aktive (nie

Reaktivierung Deaktivierter). OAuth2 client_credentials mit Token-Cache; neue

newsletter_ziel_gruppen() listet die Verteiler (für Einrichtung/Selfcheck).

Read-only gegen das BTV-Konto verifiziert (Token + Gruppenliste, u. a.

„Lizenzinhabende", „Vereine"). Der simulate-/Fake-Pfad und alle Tests bleiben

unberührt (342/345 grün).

[0.66.0] — 2026-07-28

app/modules/newsletter/lauf.php führt einen Lauf aus und protokolliert ihn

in newsletter_laeufe (Kennzahlen) + newsletter_aktionen (nur E-Mail-Hash,

Pepper aus config.php — der gespeicherte Bericht enthält keine Klartext-

adressen). Job newsletter_sync läuft fällige, aktive Verbindungen (Zeitplan

taeglich/manuell); cleanup_job für das Aktionen-Log

(aufbewahrung_tage('newsletter_aktionen', 30)). Manueller Start über

POST /newsletter/verbindung/{public_id}/start (Dry-Run mit Ausführen-Recht,

Live nur mit Verwaltungsrecht). Statusmail (Zusammenfassung + Fehler) über die

Outbox, nie werfend. DSGVO-Auskunft „Was ist mit X passiert?" via Hash-Suche.

Ein Fehler beendet einen Lauf sauber als fehler, ohne die übrigen zu stoppen.

[0.65.0] — 2026-07-28

für eine Verbindung den Abgleich Quelle→Ziel: neu / aktualisiert / verwaist /

übersprungen. Die eigenen Segment-Tokens werden in phx_segmente gemergt —

Tokens ANDERER Verbindungen und das Feld themen bleiben erhalten. Orphan-

Strategie token_entfernen (nur das eigene Token weg) oder ersatz_token

(z. B. phx-lizenz-abgelaufen); „verwaist ≠ abgemeldet" (Status unberührt).

Idempotent (zweiter identischer Lauf = 0 Änderungen, letzter_sync zählt

nicht als Änderung), Dry-Run erzeugt denselben Bericht wie Live, schreibt aber

nichts. Vollständig gegen den In-Memory-Fake getestet.

[0.64.0] — 2026-07-28

schreibt Empfänger in eine CleverReach-Gruppe. Harte, getestete Invarianten:

Batches ≤ 1000; Attribut-Trennung (schreibt nur phx_segmente/quelle/

letzter_sync, ein themen-Schreibversuch wirft); nie reaktivieren

(blacklist wird übersprungen, deaktiviert bleibt deaktiviert, nur Attribute).

Modus simulate (In-Memory-Fake für Engine/Tests) | live (OAuth2 client_

credentials + REST-Upsert, strukturiert vorbereitet, Live-Verifikation vom

Portal offen). Dry-Run meldet identisch, schreibt nichts.

[0.63.0] — 2026-07-28

die drei Zielgruppen aus der Mitgliederverwaltung und normalisiert sie auf ein

einheitliches Empfänger-Format: clubsofficial/clubs

(Vereinsansprechpartner, postaddress.email, sonst finderaddress.email, Gau

als Attribut) · licenseholderlicense-holders (Feld email,

license_type als Segment) · course-speakerscourses/course-speakers

(Feld heißt mail, nicht email). Modus simulate (Fixtures) | live; der

Live-Weg nutzt phoenix_api_get_all wieder (X-Auth-Token, Meta-Pagination,

Tageszähler, 401-Backoff). Eigener Test-Guard (NEWSLETTER_TESTLAUF) riegelt

echte Aufrufe im Testlauf ab, unabhängig von phoenix.mode. Leere/ungültige

Adressen werden übersprungen und gezählt (kein Upsert ohne Schlüssel). Tests

decken Feldwahl je Quelle, Normalisierung, Übersprungen-Zählung und Guard ab.

[0.62.0] — 2026-07-28

app/modules/newsletter/ (routes/controllers/repository) mit den beiden

Pflicht-Hooks newsletter_open_tasks() / newsletter_dashboard_tiles(),

optionalem newsletter_nav() und einer Verbindungs-Übersicht

(/newsletter/verbindungen, Leerzustand). Migration 0021 legt die vier

newsletter_-Tabellen an (Verbindungen, Läufe, Aktionen mit E-Mail-Hash

statt Klartext, verschlüsselte Konfig) und seedet die zwei Modul-Rollen

newsletter.admin (verwalten+ausführen) / newsletter.operator (ausführen)

mit den Rechten newsletter.verwalten / newsletter.ausfuehren. Schema und

Migration deckungsgleich. Ziel des Moduls: Zielgruppen aus der

Mitgliederverwaltung (clubs/licenseholder/course-speakers) nach CleverReach

synchronisieren; Config-Block dafür ergänzt (config.example.php). Quell-

und Ziel-Clients, Sync-Engine, Zeitsteuerung und Verwaltungs-UI folgen in

NL-2…NL-8.

[0.61.9] — 2026-07-27

<input type=file> brachte einen abgeschnittenen „Keine Datei ausgewählt"-Text

und einen nicht stylbaren Button mit; die drei Aktionen waren unterschiedlich

groß. Jetzt versteckt (barrierefrei über das Label erreichbar), ein gestyltes

Label übernimmt das Auswählen, und „Auswählen"/„Hochladen"/„Entfernen" sind

gleich breit. Bei Dateiauswahl zeigt das Label den (gekürzten) Namen; ohne

JavaScript voll bedienbar.

[0.61.8] — 2026-07-27

Optimierungs-Durchlauf (Code-Review):

platzhalter.php — nie über view() geladen); 19 tote CSS-Regeln aus dem

alten Design entfernt (Deko-SVG, alte Topnav, alter Profilkopf); totes

Registry-Feld daten aus app/blocks.php.

flash_render() ersetzt. Behebt zugleich das ARIA-Level — Fehlermeldungen

bekommen jetzt role="alert" (assertiv) statt role="status".

(Label und Button hießen unterschiedlich).

[0.61.7] — 2026-07-27

(Block-Kopf „Beim BTV" + interne „BEIM BTV") und ließen oben Leerraum durch

die Trenner-Optik. Neuer Kompakt-Modus: Wird ein Abschnitt als eigener Block

gerendert, fallen die interne Überschrift und die Trennlinie weg, der Inhalt

steht direkt unter dem Block-Kopf. Die leere Listen-Spalte (Konten ohne

Lizenzen) wird nicht mehr gerendert.

geändert — es stand in der Kopfzeile neben dem gleichen Briefumschlag der

Nachrichten und war nicht unterscheidbar.

[0.61.6] — 2026-07-27

selbst gezeichnete, generische SVG-Mockups (iPhone / Android / QR, je drei

Schritte) unter public/assets/hilfe/. Keine echten App-Screenshots, keine

fremden Logos; skalierbar und CSP-konform als <img>. Die Anleitung zeigt

jetzt Bilder statt Platzhalter.

[0.61.5] — 2026-07-27

Abnahme-Befunde Teil 3 (Layout + Registrierung), im Browser verifiziert:

wurde jedes Inline-Element (z. B. eine lange E-Mail in <strong>) zu einem

eigenen Flex-Item und in eine schmale Spalte gequetscht — der Satz brach in

Kolumnen (Passwort-vergessen-Bestätigung). Icon jetzt als Inline-Prefix, Text

fließt normal. Gilt für alle Alerts.

Drei-Absatz-Hinweis stand komplett in Fettschrift. Absätze auf Normalgewicht,

<strong> bleibt zur Betonung.

Setup-Spalte; jetzt vollbreit und umbruchfähig.

Der automatische Erstversand läuft jetzt mit Cap 1 (statt 3) — die zweite,

leicht versetzte Registrierung wird gedeckelt. „Erneut senden" behält Cap 5.

Icon in der Kopfzeile und wurde im Test nicht gefunden).

[0.61.4] — 2026-07-27

Der Versand läuft technisch weiter über den Portal-Absender (Graph App-only),

aber Nachrichten aus der Verwaltung tragen jetzt ein Reply-To auf das

Prüfteam-Postfach (pruefteam_mail) — Antworten landen dort, wo der Vorgang

bearbeitet wird, statt bei info@. Reply-To wird wie die Empfängeradresse gegen

Header-Injection gehärtet; simulate/SMTP/Graph setzen es je passend.

[0.61.3] — 2026-07-27

Abnahme-Befunde Teil 2 (Funktions- und Textfehler):

Versands wurde ignoriert — war der Stundendeckel erreicht, meldete die Seite

trotzdem „verschickt", und die Mail kam nie. Jetzt ehrliche Rückmeldung, wenn

gerade nichts gesendet werden konnte.

einen echten Hilfe-Kontakt-Button statt „(Link unten)".

[0.61.2] — 2026-07-27

Usability-Fixes aus dem fachlichen Abnahme-Test (Teil 1, ABNAHME-BEFUNDE.md):

beim Ziehen verglich erst y, dann x — sobald zwei Blöcke nebeneinander in einer

Reihe standen (breit + schmal), waren ihre y-Mitten fast gleich und das

Einsortieren sprang oder blieb aus. Jetzt „nächster Mittelpunkt" (euklidisch),

robust in jedem Raster.

Payoff-Satz stand bei aktivem Gate zweimal.

persönlichen und sensiblen Daten"; am Step-up-Gate jetzt kontextkonkret

(„… bevor Du Deine E-Mail-Adresse änderst"), über neuen optionalen Grund in

require_twofa().

Seite an den Anfang; jetzt Anker zurück auf den Block.

rein unterstützend (funktioniert ohne JS weiter).

[0.61.1] — 2026-07-20

phoenix_rollen_vorschlag() gab es seit v0.5x, aber kein einziger Aufrufer

ausserhalb der Tests — die Einstellung phoenix.rollen_mapping haette also

auch gesetzt nichts bewirkt. Die Kontoseite zeigt den Vorschlag jetzt im Block

„Rollen & Rechte" als Angebot mit einem Klick, samt Begruendung („dort gefuehrt

als …"). Bereits vergebene Rollen werden nicht erneut vorgeschlagen.

Weiterhin Vorschlag, nie Auto-Zuweisung (8.1).

Produktiv liefert license-holders 23 verschiedene license_type-Werte —

aber ausnahmslos Qualifikationen (ÜL C Allround-Fitness, Trainer C

Breitensport Gerätturnen …), keine Funktionen. Fuer den Vorschlag gab es damit

kein sinnvolles Ziel: „Referent/in" waere falsch (das kommt aus den

Kurs-Quellen), „Ehrenamt" ebenso. Die neue Rolle ist bewusst rechtelos und

nicht selbst waehlbar — sie sagt nur aus, dass eine Lizenz gefuehrt wird.

uebungsleiter. Bewusst auf dem Server aus den echten Indexwerten erzeugt,

nicht abgetippt: Der Abgleich ist eine exakte Zeichenkettengleichheit, ein

einziges falsches Zeichen ergaebe still „kein Vorschlag". Ein Testfall haelt

genau das fest (abweichende Schreibweise → nichts; Rand-Leerzeichen → egal).

[0.61.0] — 2026-07-20

jetzt aus einem Kopfbereich (wer ist das, was ist offen) und darunter aus

BLOECKEN, die sich jede Person selbst anordnen und zuklappen kann. Neu:

app/blocks.php (Registry), views/_bloecke.php (Huelle), je ein Teil-

Template pro Block, app/controllers/layout.php, Migration 0018

(user_block_layout).

<modul>_profil_bloecke($ansicht, $ctx) — dieselbe Mechanik wie bei den

Dashboard-Kacheln. Modul-Bloecke bekommen zwingend den Modulpraefix, damit

kein Modul einen Core-Block ueberschreiben kann.

dem Tablet) UND Knoepfe „nach oben"/„nach unten" plus Klapp-Knopf als echte

Formulare. Ohne JavaScript funktioniert alles — nur mit Neuladen statt ohne.

Reines Drag & Drop waere fuer Tastatur und Screenreader unbedienbar

(WCAG 2.1.1); der Griff ist deshalb aria-hidden und erscheint erst, wenn das

Skript laeuft.

„Verknuepfung loesen" standen INNERHALB des <li> der Rollen-Liste und wurden

je Rolle erneut ausgegeben — bei drei Rollen dreimal, in ungueltigem Markup.

einbindenden View. Ein so allgemeiner Name kollidiert frueher oder spaeter mit

einer View-Variablen (beim Bau der Vorschau ist genau das passiert) — jetzt

$sd_aktiv.

CSRF-Feld als csrf, csrf_field() erzeugt _csrf — jeder Speicherversuch

waere an der Pruefung gescheitert. Vom neuen Seiten-Test gefunden, nicht im

Betrieb.

(Status, Pruefung erforderlich, Zuordnung unklar) — vorher musste man sich das

aus einer Tabelle zusammensuchen.

[0.60.4] — 2026-07-20

ungleiche Inhalte: Der BTV-Bereich mit Lizenzen und Kurskategorien quoll

ueber, waehrend die meist leere Bankverbindung genauso viel Platz bekam.

Jetzt zwei Bloecke fuer die eigenen Angaben (Person, Adresse) und je ein

eigener Block fuer „Beim BTV" (mit den Listen) und die Bankverbindung.

als erster Eintrag der Lizenzliste. Das Einzelfeld erscheint nur noch, wenn

es keine Liste gibt.

Mitgliederverwaltung" stand unter jedem einzelnen Wert und war praesenter als

die Werte selbst. Stammen alle gefuellten Felder einer Gruppe aus derselben

Quelle, steht der Hinweis einmal unter der Ueberschrift; bei gemischter

Herkunft bleibt er am Feld, damit die Ueberschrift nichts Falsches behauptet.

stand als Beispiel mitten im Fliesstext, was den Absatz in drei Teile brach.

in views/_stammdaten.php und werden von Profil und Verwaltung gemeinsam

genutzt — zwei Kopien waeren auseinandergelaufen. In der Verwaltung fehlten

bisher Lizenzen, Kurskategorien und Geschlecht ganz; ohne Bearbeitungsrecht

standen dort nur drei Tabellenzeilen. Der Herkunftshinweis wird dort ebenfalls

ermittelt.

[0.60.3] — 2026-07-20

Variable fehlte in der use-Liste der Transaktions-Closure — drinnen war sie

undefiniert, gespeichert wurde NULL. Nichts stürzte ab, die Übernahme

meldete Erfolg, und im Profil stand nichts. Der neue Testfall prüft nicht

mehr die Ermittlung, sondern das Ergebnis in der Datenbank; nur so fällt

diese Fehlerart auf.

liefert eine Zeile je Lizenz, nicht je Person — wer drei hat, steht

dreimal in der Antwort, mit derselben Personennummer. Weil der Index über die

Personennummer schlüsselt, überschrieb bisher jede weitere Zeile die vorige;

welche Lizenz überlebte, entschied die Reihenfolge der Antwort. Beim

Zusammenführen werden Listen jetzt vereinigt statt ersetzt (Lizenzen über

Nummer bzw. Art entdoppelt), Einzelwerte weiterhin nur ergänzt.

Zusammenführen gab es zwei Regeln: Der Upsert-Weg vereinigte, der

Anreicherungs-Weg füllte nur Lücken — war schon eine Kategorie da, kamen die

übrigen nie an. Beide Wege nutzen jetzt dieselbe Funktion.

mit Art, Nummer und Gültigkeit; Kurskategorien und Lizenzen werden im Profil

angezeigt. lizenz_ref bleibt als führende Einzelangabe bestehen.

[0.60.2] — 2026-07-20

mehr als einem Namenstreffer passiert gar nichts" schützt vor Namensvettern —

sie blockierte aber auch den häufigen Realfall, dass **dieselbe Person auf

mehrere Index-Einträge verteilt** ist: einmal als Lizenzinhaber mit

angereichertem Kurs-Satz, einmal als eigener Kurs-Satz, der sich nicht

zuordnen ließ.

Jetzt wird unterschieden: Stimmen bei allen Treffern **E-Mail und

Geburtsdatum** überein, ist es ein Mensch — die Einträge werden

zusammengeführt, der mit der echten Personennummer führt, und die

Kurskategorien beider werden vereinigt (sonst ginge die Hälfte verloren).

Unterscheiden sich die Merkmale, passiert weiterhin nichts. Zwei Testfälle

sichern beide Seiten ab.

[0.60.1] — 2026-07-20

verloren.** Der Merge setzte nur das Quellen-Kennzeichen und behielt den

bestehenden Payload. Die Kurs-Quellen liefern aber Geschlecht und

Kurskategorien, die Lizenzinhaber-Quelle nicht — dadurch trug nach dem ersten

Lauf nur rund ein Drittel der Referenten diese Felder. Jetzt reichert der

Merge den Payload an: ergänzt wird ausschließlich, was fehlt; der zuerst

geschriebene Satz bleibt führend.

[0.60.0] — 2026-07-20

Referenten-Daten vollständiger übernehmen (Entscheidung 2026-07-20).

(Migration 0016). Die echte API-Antwort liefert deutlich mehr als die Spec

beschreibt — unter anderem sex, courses und **entgegen der Spec auch

birthdate**. Letzteres verbessert den sicheren Match für alle Sätze, in

denen es gesetzt ist.

stehen aber auch 3 und 14. Unbekannte Codes werden gar nicht übernommen —

ein falsch zugewiesenes Geschlecht ist verletzend, eine fehlende Angabe nicht.

null-Einträgen und Duplikaten (beides real belegt). Ungefiltert stünden

leere Zeilen und Wiederholungen im Profil.

als Fließtext unlesbar), Geschlecht bei den Personendaten, beides auch in der

Admin-Einsicht.

leere Felder — ändert sich in der Mitgliederverwaltung eine Anschrift, käme

sie dort nie an. Dieser Weg ersetzt auch belegte Angaben, **rührt aber

bestätigte Stammdaten nie an**: Die Bestätigung ist ein Nachweis mit Zeit und

IP (12.5.3), den eine Fremdquelle nicht im Vorbeigehen entwerten darf. Die

Identitätskennung setzt auch dieser Weg nicht.

[0.59.1] — 2026-07-20

stehen, wenn die Rolle „Referent/in" entzogen wurde — ein Verweis auf einen

Referenten-Datensatz an einem Konto, das keiner mehr ist. Die übernommenen

Stammdaten bleiben dagegen bewusst erhalten: Sie gehören inzwischen der

Person, sie hat sie möglicherweise bestätigt, und ein Konto ohne Anschrift

zurückzulassen wäre schlechter als eine Anschrift, die einmal aus der

Mitgliederverwaltung kam.

[0.59.0] — 2026-07-20

Rollenbezogener Abgleich für Referentinnen und Referenten (Entscheidung

2026-07-20). Wird die Rolle „Referent/in" bestätigt, holt das Portal aus den

Kurs-Quellen, was dort an Stammdaten steht.

course-speakers kein Geburtsdatum liefert: 286 Index-Einträge sind über

den sicheren Weg gar nicht auffindbar.

1. phoenix_person_id wird nie gesetzt. An dieser Kennung hängen

Stammdaten und später Honorare — ein Namensvetter ohne eigenes Konto würde

sie sonst unbemerkt erben. Die Referenten-Kennung landet stattdessen in

profiles.referenten_id.

2. Nur leere Felder werden gefüllt. Was jemand selbst eingetragen hat,

ist seine Aussage über sich.

3. Bei mehr als einem Namenstreffer passiert gar nichts — genau dort wäre

die Verwechslung wahrscheinlich.

Profil der Hinweis „aus der Mitgliederverwaltung" erscheint. Ohne ihn stünden

fremde Angaben unkommentiert im Profil.

Phönix keine abrufbare Personenliste (offene Frage an it4sport). Lieber nichts

als geraten.

existiert seit Beginn im Schema, war aber nirgends lesbar.

[0.58.7] — 2026-07-20

Verknüpfung → Herkunftsanzeige*. Anlass: Sechs Fehler dieses Tages hatten

alle 293 Einzeltests passiert, weil jeder davon eine Funktion gegen

Fixtures prüfte — die Fehler saßen aber in den Übergängen. Der neue Test läuft

bewusst über den Index-Pfad, nicht über die simulate-Fixtures, und prüft

am Ende, dass die Verknüpfung wirklich in der Datenbank steht — nicht nur,

dass eine Erfolgsmeldung erschien. Er hätte vier der sechs Fehler gefunden.

Dazu zwei Gegenproben: Namensvetter → Klärfall statt Verknüpfung, leerer Index

fehler statt kein_treffer.

(PHOENIX_TESTLAUF). Außerhalb der Tests bleibt der Modus reine

Konfiguration, und echte Netzaufrufe verhindert der bestehende Guard.

ergänzt (zweistufige Stammdaten, Wiedereinstieg mittendrin, Vereinsauswahl,

nachträgliche Verknüpfung, Verbindung lösen, unterscheidbare Kandidaten).

Der Fall „Personennummer-Feld" ist entfallen — das Feld gibt es seit

v0.58.2 nicht mehr.

[0.58.6] — 2026-07-20

die Kandidaten aus dem entschlüsselten Payload — und **phoenix_person_id

fehlte darin**. Folge: Die Registrierung speicherte den Treffer ohne

Verknüpfung (Vorbefüllung klappte trotzdem, deshalb fiel es nicht auf), und

„Jetzt verknüpfen" meldete danach „kein passender Eintrag" — obwohl der Treffer

mit Score 115 vorlag. Jetzt tragen Kandidaten die Kennung unter beiden Namen.

„gib zusätzlich Dein Geburtsdatum ein" stammte aus der einstufigen Fassung —

seit Etappe 5 zweistufig ist, wird es in Schritt 1 erfragt. Text korrigiert.

dieser interne Widerspruch je wieder auf, landet der Fall in der Prüfung statt

in einer Unwahrheit für die Person.

[0.58.5] — 2026-07-20

Der Versand-Deckel (B9) stand beim regulären Start auf **1 pro Stunde und

Adresse, während „erneut senden" 5** erlaubt — also genau verkehrt herum:

Der reguläre Weg war strenger als der manuelle Knopf. Wer sich innerhalb einer

Stunde ein zweites Mal registrierte, dessen Mail wurde **stillschweigend

verworfen**, während die Warteseite behauptete, sie sei unterwegs.

*(Mein erster Befund „nur Versandverzögerung" war falsch — er stützte sich auf

ältere Mails, die tatsächlich rausgingen.)*

Jetzt: Deckel beim Start 3 pro Stunde — gegen Mail-Bombing genauso wirksam

(der Unterschied zwischen 1 und 3 Mails ist kein Angriff, der zwischen 3 und

300 schon), aber ein Neustart nach Abbruch funktioniert wieder.

registrieren_verify_mail_senden() meldet zurück, ob eingereiht wurde; die

Warteseite sagt dann klar, dass vor Kurzem schon eine Mail an diese Adresse

ging — statt auf etwas warten zu lassen, das nie kommt. Vier Testfälle sichern

das Deckel-Verhalten ab.

[0.58.4] — 2026-07-20

wich für address_nr auf id aus — course-speakers liefert aber eine eigene

UUID. Dadurch bekam derselbe Mensch zwei Einträge, die Zusammenführung lief

nie an, und aus einem eindeutigen Treffer wurde ein Klärfall. Jetzt gilt nur

address_nr als Personennummer; ob die Kurs-UUID überhaupt dieselbe Person

meint, ist bei it4sport offen. Zwei Testfälle.

Telefonnummer und Anschrift — Angaben, die entweder schon erfasst sind oder

sich im Profil gar nicht bearbeiten lassen. Jetzt drei ehrliche Fälle:

Abgleich gestört · nicht sicher entscheidbar (legt jetzt einen Klärfall an

statt in einer Sackgasse zu enden) · wirklich kein Eintrag.

Namen und Geburtsdatum waren sonst nicht unterscheidbar — dann ist die Maske

wertlos.

ohne die Codes selbst: Wer Zugriff aufs Postfach hätte, käme sonst an beide

Sicherheitsstufen, und genau davor soll der zweite Faktor schützen. (Technisch

ginge es ohnehin nur einmal — gespeichert sind sie nur gehasht.) Die Mail sagt

jetzt, dass es zehn Codes gibt, wo man sie herunterlädt und wie man im Profil

neue erzeugt.

[0.58.3] — 2026-07-20

sondern reiht ein; der Cron stellt im Minutentakt zu. Ohne diesen Hinweis wirkt

jede Registrierung kaputt: Man schaut sofort ins Postfach, sieht nichts, drückt

„erneut senden" — dann kommen zwei Mails, und der Eindruck bleibt, dass nur die

zweite funktioniert hat. *(Nachgeprüft: Der Versand funktioniert, die

Bestätigungsmail ging 57 Sekunden nach dem Einreihen raus.)*

draufsteht.** Die btn-Klassen setzten kein font-family — ein <button>

erbt die Schrift nicht (Browser geben Schaltflächen die System-Schrift), ein

<a> dagegen schon. Jetzt erben alle die Seitenschrift, haben eine gemeinsame

Grundlinie und eine Mindesthöhe je Stufe; .card-head bricht auf schmalen

Schirmen sauber um.

(Mail-Log-Filter, Benutzersuche) blieben sie im Browser-Standard und wirkten

neben den gestylten Feldern wie ein Fremdkörper. Jetzt einheitlich, mit einem

eingebetteten SVG-Pfeil — kein externer Abruf (CSP) und kein JavaScript.

[0.58.2] — 2026-07-20

alten Abgleich auf offen zurück — das erzeugte einen unrettbaren Zustand:

Die Reservierung arbeitet mit INSERT IGNORE und kann eine bestehende Zeile

nicht neu übernehmen, und der Retry-Job greift nur Zeilen mit leerem

checked_at auf. Das Konto blieb still für immer offen, ohne Fehlermeldung.

Jetzt wird die Zeile gelöscht — eine bereits bestätigte Verknüpfung bleibt

dabei unangetastet (9.4). Zwei Testfälle.

niemand kennt seine Nummer. Ein optionales Feld, das fast alle leer lassen,

kostet nur Aufmerksamkeit und erzeugt das Gefühl, etwas nicht zu haben, was

man haben müsste. Das Scoring gewichtet address_nr weiterhin, falls die

Nummer später aus anderer Quelle kommt.

/verwaltung/mitgliederdatennur für echte Administratoren

(is_real_admin(), bewusst nicht system.view): Der Index enthält Daten

Dritter, die vom Portal nichts wissen; das geht auch Prüfteams nichts an.

Jeder Aufruf wird protokolliert. Gesucht wird über den verschlüsselten

Nachnamen, also nur exakt und ohne Teilwortsuche — der Index soll bewusst

keine durchsuchbare Adressliste des Verbands sein. Entschlüsselt wird nur,

was auf der angezeigten Seite steht.

[0.58.1] — 2026-07-20

bevorzugt dem links.next der API — und diese URL trägt limit nicht mit.

Ab Seite 2 galt damit die Server-Vorgabe (25 statt 1000): Für 4.653 Vereine

brauchte es 187 statt 5 Anfragen, was in die Seiten-Obergrenze lief. Ergebnis

im ersten Live-Sync: official/clubs scheiterte, der Vereins-Cache blieb

leer, und 50 Aufrufe waren für nichts verbraucht.

Jetzt wird die Folgeseite selbst gebaut (aus meta.current_page), und nur

wenn kein brauchbares meta kommt, wird links.next genutzt — dann mit

wieder angehängtem limit. Vier Testfälle sichern beide Wege.

[0.58.0] — 2026-07-20

Letzte zwei Punkte des Umsetzungsauftrags plus zwei Fehler, die der

Praxiseinsatz aufgedeckt hat.

nur solange der Zugang nicht verbunden ist. Beim ersten Abgleich fehlten

vielleicht Merkmale oder der Index war leer — ohne diesen Weg bliebe jemand

dauerhaft unverknüpft. Kostet keinen API-Aufruf (rechnet gegen den Index),

und E2 gilt auch hier: verknüpft wird nur mit bestätigtem Geburtsdatum,

sonst wäre der Knopf ein Weg um die Regel herum.

ohne Trefferdetails, mit klarem Hinweis auf Freiwilligkeit. Eine zweite Bitte

um dieselben Daten wirkt wie ein Mahnschreiben und bringt fachlich nichts.

Zwei Fehler behoben, die im Betrieb aufgefallen sind:

Intervalle — sie führt bei jedem Cron-Durchgang alle Jobs aus, und der Cron

läuft alle 1–2 Minuten. Sobald die Zugangsdaten vollständig gewesen wären,

hätte der Sync rund 12 Requests pro Durchgang gezogen: über 8.000 am Tag

gegen ein geteiltes Limit von 5.000 — unbemerkt, bis die Website ausfällt.

Jetzt phoenix.sync_abstand_stunden (Standard 24), Zeitstempel vor dem

Lauf gesetzt, damit ein Abbruch keinen Sofort-Neustart auslöst.

bei jedem Durchgang einen Fehler — hunderte Mails am Tag, die echte Störungen

verdecken. Jetzt steigt er still aus und vermerkt den Grund einmal.

dort will man vor dem Umschalten wissen, ob die Zugangsdaten reichen). Alte

Protokollzeilen erscheinen im simulate-Modus nicht mehr als ALARM, sondern

als historisch gekennzeichnete Info: Ein Alarm, den keine Handlung auflösen

kann, macht die ganze Seite unglaubwürdig.

Zeitformatierung lief bislang nur deshalb nie ins Leere, weil das

Sync-Protokoll leer war.

[0.57.0] — 2026-07-20

Registrierungsstrecke der Phönix-Anbindung (Konzept 9.3, Auftrag §4).

vor der Stammdaten-Eingabe, also allein mit der E-Mail-Adresse — nach der

Regel „ein Merkmal reicht nie" könnte daraus nie ein eindeutiger Treffer

werden, es gäbe also nie eine Vorbefüllung. Jetzt: erst Name und

Geburtsdatum, dann abgleichen, dann die vorbefüllten Restfelder.

Session. Nur so funktioniert der Wiedereinstieg auf einem anderen Gerät

(6.3) — mit einem Session-Marker finge man am Handy wieder von vorn an.

Schreiben des BTV … kannst Du leer lassen, wir finden Dich auch so"). Sie

wird nur für diesen einen Abgleich verwendet und nie als Kennung

übernommen — sie ist eine Behauptung, kein Nachweis.

es bei „wahrscheinlich" bleibt — nie nach bereits erfassten Angaben, und

immer mit der Möglichkeit weiterzugehen.

zugeordnet, ein unbekannter blockiert die Registrierung nicht. Sie darf nie

an einer Vereinsschreibweise scheitern.

wo eingewilligt wird, nicht erst, wenn er schon gelaufen ist.

(kein 32., kein zweistelliges Jahr, nichts in der Zukunft).

[0.56.0] — 2026-07-20

Verwaltungs-Teil der Phönix-Anbindung (Konzept 9.3/9.4, Auftrag §4).

(phoenix.unlinked) und Purge des Snapshots. Der Snapshot enthält die

Stammdaten der fremden Person — und genau deren Zuordnung war ja der Fehler

(Löschklasse 12.5.4). Die bereits ins Profil übernommenen Werte bleiben

dagegen stehen: Sie zu leeren würde die Person aus ihrem eigenen Konto

aussperren. Sie gelten danach als selbst erfasst, weshalb auch der

Herkunftshinweis von allein verschwindet — ein Test prüft beides.

Die Person wird neutral informiert, ohne Trefferdetails.

Spalte „Passt bei" mit den getroffenen Merkmalen plus Punktzahl. Eine nackte

Punktzahl ist für die Entscheidung wertlos — „Geburtsdatum, Telefon" sagt,

ob man ihr trauen kann. Die Option „ohne Zuordnung freigeben" heißt jetzt

„Treffer unzutreffend".

(„mit der Mitgliederverwaltung verbunden" / „noch nicht … verknüpft" /

„Verbindung wird noch geprüft"). Die internen Zwischenstufen wahrscheinlich

und mehrdeutig bleiben intern — sie zu zeigen verunsichert nur und verrät

nebenbei, dass es mehrere ähnliche Personen gibt.

[0.55.0] — 2026-07-20

Basis-URL je Modul, Key gesetzt ja/nein — nie der Wert, Pepper,

Rückschreib-Schalter), Status je genutzter API, Match-Index, Tageszähler und

Vereins-Cache, jeweils mit Ampel.

Der Bereich ruft die Schnittstelle nicht auf — alle Angaben stammen aus

dem Protokoll des Sync-Laufs. Sonst würde ausgerechnet das Nachsehen, warum

das Tageskontingent alle ist, weiteres Kontingent verbrauchen. Ein Test

prüft das über den Zählerstand.

alle 15 Minuten, je Schnittstelle genau ein limit=1-Request. Ein

ungedrosselter Prüfknopf auf einer Statusseite wäre eine Einladung, das

Kontingent per Klick zu verbrauchen. Im Modus simulate prüft er stattdessen

die Fixtures — sonst meldete die Seite „alles gut" für eine Anbindung, die

gar nicht existiert.

bedeutet, dass jeder Abgleich fehler liefert.

[0.54.0] — 2026-07-20

Phönix-Anbindung nach PHOENIX-UMSETZUNG-fuer-Code.md (Konzept 9.2/9.2b) —

Fundament, Client und Sync-Lauf. Wizard-Strecken und Selfcheck folgen.

Registrierung die Personenliste gezogen — rund 12 Requests pro Vorgang.

Beim Start des Portals sprengt das das mit Website und Reporting geteilte

Tageslimit von 5.000, und die Person wartet minutenlang. Jetzt baut ein

täglicher Sync-Lauf einen lokalen Match-Index (~12 Requests am Tag), der

Abgleich rechnet dagegen.

it4sport-Spec. Ein Test hält fest, dass weder Funktion noch Pfad zurückkommen.

ü→ue; Bindestriche; zweiter Vorname optional; Telefon-Länderkennung),

Gewichte und Schwellen als settings. „Eindeutig" verlangt mindestens ein

*sehr starkes* Merkmal und mindestens zwei Merkmale insgesamt — eine

übereinstimmende E-Mail allein verknüpft nie, weil geteilte Postfächer real

sind. Zwei gleich gute Kandidaten sind mehrdeutig, nicht wahrscheinlich.

Kandidaten bestätigt.

Vergleich über Hashes mit einem Pepper aus der config.php (nicht aus der

Datenbank — sonst wären sie mitsamt der Tabelle rückrechenbar), Payload

sodium-verschlüsselt, entschlüsselt wird nur der Treffer. Ein Test prüft, dass

nichts im Klartext in der Tabelle landet.

geliefert hat — sonst löschte ein Ausfall den Index und legte den Abgleich für

alle lahm. Nach mehr als sieben Tagen ohne Sync wird er hart gelöscht; der

Abgleich liefert dann fehler, nie kein_treffer („wir konnten nicht prüfen"

ist etwas anderes als „es gibt dich nicht").

noch Geburtsdatum. Ohne Zusammenführung stünde dieselbe Person zweimal im

Index und ein eindeutiger Treffer würde zum Klärfall. Bei mehreren möglichen

Bezügen wird bewusst nicht zusammengeführt — zwei Personen an einem

geteilten Postfach dürfen nicht verschmelzen.

address_nr ist unbestätigt): Abweichung erzeugt einen Prüfhinweis, nie eine

automatische Korrektur.

bis Tageswechsel, Sync-Protokoll je Quelle (Grundlage des Selfcheck-Bereichs),

Vereins-Cache für die Selbstauswahl im Wizard.

dann jeden Aufruf, sobald der Modus nicht simulate ist.

config.php — einmal setzen, danach nie ändern.

[0.53.1] — 2026-07-20

Nachbesserung zu 0.52.1 — beide Punkte waren Folgen meiner eigenen Lösung.

Tabelle selbst (display:block; overflow-x:auto). Dadurch wurde die innere

Tabellenbox „shrink-to-fit" und blieb schmaler als der Platz. Der Scroll

liegt jetzt in einem Container um die Tabelle (.tabelle-scroll), die

Tabelle selbst hat width:100%; min-width:100%.

overflow-wrap:anywhere senkt die min-content-Breite auf ein einzelnes

Zeichen — im automatischen Tabellenlayout durfte eine Spalte dann auf

Buchstabenbreite kollabieren. Jetzt break-word (trennt erst beim

tatsächlichen Überlauf und füllt die Zeile) plus min-width:5rem je Zelle.

Weil sich diese Mindestbreite über die Spalten summiert, wächst der

Platzbedarf automatisch mit der Spaltenzahl: schmale Tabellen passen weiter

auf den Schirm, breite lösen den Scroll des Containers aus.

[0.53.0] — 2026-07-20

Die Seite ist öffentlich erreichbar (Basic-Auth bewusst entfernt) — damit

brauchen die Rechtstexte einen belastbaren Weg.

Betreibers.** Doppelt gepflegte Rechtstexte laufen auseinander, und die

veraltete Fassung ist dann die angreifbare. Die Adressen sind Texte

(impressum_url, datenschutz_url) und unter *Einstellungen → Texte*

änderbar — der Core bleibt damit white-label-fähig (14.9/O18). Ist ein Feld

leer, rendert das Portal wieder seinen eigenen Text.

hinterlegt ist. Ohne das bliebe unter der alten Route eine leere Seite

erreichbar — genau der rechtlich heikle Fall, den Lesezeichen und

Suchmaschinen zuverlässig finden.

nur http/https, keine Steuerzeichen. Die Steuerzeichen-Prüfung läuft auf

dem Rohwerttrim() würde ein führendes \r\n still entfernen und die

Prüfung ins Leere laufen lassen.

auf die Vorbelegung zurück (sonst ließe sich der Verweis nie entfernen).

und beschreibt diese Anwendung; sie wird bei Änderungen fortgeschrieben.

Registrierung nicht verloren geht; für Screenreader wird das angesagt

(neue Klasse .visually-hidden) statt stillschweigend ein Fenster zu öffnen.

Längerem nicht mehr hat. Jetzt am verifizierten Ist-Stand: Repo- und

Server-Pfade sind 1:1 gleich, inklusive Sicherungs-, Reihenfolge- und

Verifikationsregeln.

[0.52.1] — 2026-07-20

Mobil-Regel setzte auf Tabellen pauschal white-space:nowrap — der Rahmen

war also scrollbar, der Inhalt aber auf je eine Zeile gezwungen. Aus einer

harmlosen Tabelle wurde endloses Horizontal-Scrollen. Jetzt brechen Zellen

um (overflow-wrap:anywhere, das senkt auch die min-content-Breite — nur

dadurch kann eine Tabelle überhaupt schmaler werden als ihr längstes Wort);

das Scrollen bleibt als Sicherheitsnetz für sehr breite Tabellen. Für

Zellen, in denen ein Umbruch schadet (Datum/Uhrzeit), gibt es die Klasse

.nowrap — gesetzt im Mail-Log und bei den Notfall-Codes. Nebenbei: Der

Selektor enthielt .grid, eine Klasse, die es nicht gibt und die keine

View benutzt.

[0.52.0] — 2026-07-20

Drei Korrekturen aus dem Praxiseinsatz.

Die E-Mail wurde nur übernommen, wenn Entra sie als email_verified meldet —

Entra ID sendet dieses Claim für Arbeitskonten aber gar nicht. Die

Bedingung war damit immer unwahr, jedes per SSO angelegte Konto trug die

Platzhalter-Adresse. Der Fehler war, ein *fehlendes* Claim wie ein

*verneinendes* zu behandeln. Jetzt gilt: email-Claim, sonst der UPN aus

preferred_username/upn — beides ist tenant-verwaltet, und der Token ist

per tid-Prüfung nachweislich aus dem eigenen Tenant, Gäste sind ausgeschlossen.

E004 bleibt unangetastet: Meldet Entra email_verified ausdrücklich als

false, wird weiterhin nichts übernommen. Bestehende Konten heilen sich beim

nächsten SSO-Login selbst. Drei Testfälle ergänzt.

(7.6). Der Aufschub war ein blasser Ghost-Button *unter* der kompletten

Anleitung — wer sich überfordert fühlt, sieht ihn also genau dann nicht, wenn

er ihn braucht, und bricht stattdessen ab. Jetzt zwei gleich gerahmte

Optionen direkt unter der Einleitung.

dem Abgleich stammen, tragen den dezenten Zusatz „aus der

Mitgliederverwaltung". Kein Herkunfts-Flag in der Datenbank, sondern ein

Vergleich mit dem Snapshot — ein Flag würde beim ersten Bearbeiten lügen.

Systemnamen bleiben draußen; sechs Testfälle sichern das ab.

[0.51.0] — 2026-07-19

Entscheidung zu geteilten Postfächern (B10/6.5a): kein Verbot — Texte angepasst.

[0.50.0] — 2026-07-18

Öffentliche Hilfe-Adresse und internes Team-Postfach getrennt.

[0.49.0] — 2026-07-18

Spam-Schutz für das öffentliche Registrierungsformular + Gmail-Dubletten.

Anlass: eine Registrierung mit dem Gmail-Punkt-Trick (b.al.la.kl.i.1.997@gmail.com). Kein Schaden — ein unbestätigter Rumpf ohne Passwort, der nach 72 h von allein verschwindet, keine Dubletten, keine Massenanlage. Aber er hat gezeigt, dass unser einziges öffentliches Formular völlig ungeschützt war, während das Referenzprojekt (Akkreditierung) Honeypot, Zeitfalle und Rate-Limit hat. CLAUDE.md nennt es ausdrücklich als Vorlage — übernommen wurde es nie.

[0.48.0] — 2026-07-16

Mail-Log: Fälle abhaken · Benutzerliste: Rollen auf einen Blick.

[0.47.0] — 2026-07-16

Löschkonzept: Übernahme der DSB-Vorgabe aus dem Akkreditierungsprojekt (2026-07-15).

Nicht übernommen: die Fristen. Jenes Verfahren kennt keine Finanzdaten und darf nach sechs Monaten alles löschen. Hier gilt § 147 AO — wer die sechs Monate überträgt, vernichtet steuerrelevante Belege. Übernommen sind die Muster.

[0.46.0] — 2026-07-16

Mail-Log mit eigener Rolle — und der Fehler, der die Diagnose unmöglich machte.

Anlass: ein echter Vorfall. Der Versand lief am 15.07. nachweislich (Mails auf sent); seit dem 16.07. um 16:59 scheitert alles, ein Nutzer registrierte sich und bekam nichts. Die Ursachensuche scheiterte an unserem eigenen Code.

[0.45.0] — 2026-07-16

Paket 7l: Geteilte Postfächer — Guardrails (UXC-10, Code-Teil von B10).

Entscheidung 2026-07-16: nur Hinweis, kein Verbot und kein Settings-Schalter. Ein Schalter, den niemand umlegt, wäre spekulative Konfiguration; wenn der BTV „persönliche Adresse Pflicht" entscheidet, ist das eine bewusste Code-Änderung mit Freigabe.

[0.44.0] — 2026-07-16

Paket 7k: Anonyme Kennzahlen (UXC-09/UX-07) und O023.

[0.43.0] — 2026-07-16

Paket 7j: Hilfe-Telefon pflegbar und wählbar (O14/UXC-01).

[0.42.1] — 2026-07-16

[0.42.0] — 2026-07-16

Paket 7i: „Diesem Browser vertrauen" — ehrlich benannt (P02-1, E096).

[0.41.0] — 2026-07-16

Paket 7h: Notfall-Codes — Nachweis statt Behauptung (P01-2/P02-3).

[0.40.0] — 2026-07-16

Paket 7g: Adressen mit Umlauten (P04-1).

[0.39.1] — 2026-07-16

Abnahme aus Nutzersicht: alle vier Persona-Durchläufe bestanden (UX-CODE-PAKET Abschnitt 4).

Kein Code-Fix — die Durchläufe sind die Definition of Done des Usability-Pakets. Lokal end-to-end gegen die echte Instanz, jeweils „ohne externe Hilfe ans Ziel, an keiner Stelle ohne nächsten Schritt".

Zwei Erkenntnisse aus den Durchläufen:

[0.39.0] — 2026-07-16

Paket 7f: Das Modul-Fundament trägt jetzt wirklich (Konzept 4.5 — O051 teilweise, O053, O054/O056).

Die Modul-Konvention stand im Konzept und in CLAUDE.md — funktioniert hat sie nicht. Ein Modul, das sich exakt an die Vorgaben hält, wäre schlicht wirkungslos geblieben, ohne eine einzige Fehlermeldung. Aufgefallen wäre das erst beim ersten Fachmodul.

[0.38.0] — 2026-07-16

Paket 7e: Zwei Selbstdiagnose-Fehler, aufgefallen am echten Selfcheck der Produktion.

[0.37.0] — 2026-07-16

Paket 7d: Registrierungs-Robustheit (E026, E029, E030/E031, E037, E038).

[0.36.0] — 2026-07-16

Paket 7c: Barrierefreiheit (P07-Cluster, WCAG 2.1 AA — Konzept 13).

[0.35.0] — 2026-07-16

Paket 7b: Rollen-Workflow — Modul-Rechte greifen jetzt überhaupt (E079, E081, E093).

[0.34.0] — 2026-07-16

Paket 7a: 2FA- und Klärfall-Robustheit (E002, E003, E021, E087, E090, E091, E092, O086, E096).

2FA-Robustheit

Klärfälle

[0.33.0] — 2026-07-16

Paket 6c: Abnahme-Durchlauf „Rosa" (Definition of Done, UX-CODE-PAKET Abschnitt 4).

[0.32.0] — 2026-07-16

Paket 6b: Statuskommunikation und Fristen (UXC-05, UXC-08).

[0.31.0] — 2026-07-16

Paket 6a: Usability vor Livegang — Hilfe, Anleitung, Vertrauen, Sackgassen (UXC-01/02/03/04/06/07, P01-1).

[0.30.0] — 2026-07-16

Paket 5: 2FA erst vor der ersten sensiblen Aktion (Konzept 7.6/7.5, Entscheidung 2026-07-16).

[0.29.0] — 2026-07-16

Paket 4f: Phönix-Anbindung belastbar gemacht (B7 — P-API-1, P-API-3/E052, E059, E064).

[0.28.0] — 2026-07-16

Paket 4e: SSO-Über-Provisionierung geschlossen (B6 — E001, E004).

[0.27.0] — 2026-07-16

Paket 4d: Versand-Deckel gegen Mail-Bombing (B9 — E055, E034, E035). Damit ist B9 abgeschlossen.

[0.26.0] — 2026-07-16

Paket 4c: Mail-Bodies sind at rest redigiert (B8 — E033, E056, E067).

[0.25.0] — 2026-07-16

Paket 4b: Token-Hygiene (B8 — E043, E049, E032).

[0.24.0] — 2026-07-16

Paket 4a: SMTP-Header-/Command-Injection geschlossen (B9/E054).

[0.23.0] — 2026-07-16

Paket 3 der Überarbeitung: Scoping + Audit-Dichtheit (B4, B5) — inkl. eines dabei gefundenen Live-Bugs.

[0.22.0] — 2026-07-16

Paket 2 der Überarbeitung: Rechte-Eskalation geschlossen (B3-Rest, N-DELTA-1/2/3).

[0.21.0] — 2026-07-16

Paket 1 der Überarbeitung: Nutzer-Sackgassen beseitigt (B1, B2, P10-2).

[0.20.0] — 2026-07-15

Hintergrundbild + Verwaltungs-Listen-Feinschliff:

[0.19.0] — 2026-07-15

Rollen/Rechte-Modell, Einstellungs-Reiter, Audit mit Rolle:

[0.18.0] — 2026-07-15

Nachrichten, UI-Feinschliff, Rollen-Editor, Löschbestätigung:

[0.17.0] — 2026-07-15

Fixes (Mobil, Checkbox) + Benutzer löschen:

[0.16.0] — 2026-07-15

Optimierungsschleife 3 (Rechtstexte, Verwaltung, Klärfälle, Breadcrumb, Fixes):

[0.15.0] — 2026-07-15

UI-Optimierungsschleife 2 (Ansprache, Auth-Seite, Navigation, Stammdaten):

[0.14.0] — 2026-07-15

UI-Optimierungsschleife (Navigation, Begruessung, Login, Avatar):

[0.13.0] — 2026-07-14

UI-Shell + Profilbild (orientiert an den Mockups, Konzept 4.7):

[0.12.0] — 2026-07-14

Block 12 „Härtung + Livegang" — Code-Teil (Konzept Kap. 16, 12.x, 13.6):

Damit ist der MVP-Code aller 12 Blöcke fertig. Der eigentliche Livegang

(1.0.0) folgt nach der operativen Checkliste und den Zulieferungen O3/O4/O5/O7.

[0.11.0] — 2026-07-14

Block 11 „Hilfe & Inhalte" (Konzept 13.4, 12.5.5, Kap. 11):

[0.10.0] — 2026-07-14

Block 10 „Verwaltung + Startseiten" (Konzept 8.4/8.5/8.6, 7.4, 12.5.5):

[0.9.0] — 2026-07-14

Block 9 „Phoenix" (Konzept Kap. 9):

[0.8.0] — 2026-07-14

Block 8 „Entra-SSO intern" (Konzept 6.2, Checkliste 6.2.2):

[0.7.0] — 2026-07-14

Block 7 „Passwort- & E-Mail-Strecken + Formularrettung" (Konzept 6.4/6.5, 13.3):

[0.6.0] — 2026-07-14

Block 6 „Registrierungs-Wizard" (Konzept 6.3, O6):

[0.5.0] — 2026-07-14

Block 5 „2FA" (Konzept Kap. 7, 12.3):

[0.4.0] — 2026-07-14

Block 4 „Externer Login" (Konzept Kap. 6.1/6.4, 12.1/12.2):

[0.3.0] — 2026-07-14

Block 3 „Mail-Outbox" (Konzept Kap. 11):

[0.2.0] — 2026-07-14

Block 2 „Schema + Konto-Basis" (Konzept Kap. 16):

[0.1.0] — 2026-07-14

Block 1 „Gerüst + Fundament" (Konzept Kap. 16):