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.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):