14 Commits

Author SHA1 Message Date
schalli 8d604b855a docs(quick-260921-jt4): Barrierefreiheit 30 -> 1, vier Restposten erledigt
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m14s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m38s
Zusammenfassung und STATE.md zum Quick-Vorgang 260921-jt4.

Der Planer hat zwei Vorgaben des Orchestrators widerlegt: der
vorgesehene Rueckfallweg mit role und Tastaturhandler tauscht gemessen
drei Befunde gegen einen neuen, und vier der elf vermeintlichen
Klick-Befunde sind onError-Handler an Bildern, also gar keine
Bedienung. Alle fuenf ARIA-Befunde waren Beschriftungen auf rollenlosen
Elementen, die Vorleseprogramme still verwerfen.

Laufzeitnachweis vom Orchestrator: drei Monatswechsel holen die
Quellenliste nur noch einmal statt viermal, der Termin-Abruf mit
identischem Zeitraum ist weg. Der 5-Minuten-Auffrischer ist per
gestellter Uhr als intakt belegt, nicht per Warten im Browser - zwei
solche Messversuche waren ungueltig, weil das Werkzeug die Seite
zwischendurch neu laedt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 15:23:10 +02:00
schalli f471b785db fix(quick-260921-jt4): verschwendete Kalender-Abrufe abgestellt, t-Identitaet gemessen statt behauptet
Restposten 3a (doppeltes Ladefenster): lastFetchWindowRef merkt sich das
zuletzt TATSAECHLICH geholte from/to-Paar; ein Monatswechsel, dessen
berechnetes Fenster damit uebereinstimmt, ueberspringt fetchEvents. Der
5-Minuten-Auffrischer (force=true) umgeht den Sperrgriff immer, sonst
friert die Anzeige ein. computeFetchWindow und die Tagesgrenzen-Rundung
bleiben unangetastet.

Restposten 3b (Quellenliste je Monatswechsel): hasSourcesRef merkt sich
das Ergebnis; fetchSources laeuft nur beim Aufbau (Merkung leer) oder
erzwungen (Auffrischer) — eine neu eingerichtete Quelle wird weiterhin
binnen fuenf Minuten bemerkt.

Restposten 4 (t-Identitaet): neue translations-identity.test.tsx rendert
eine Testkomponente unter dem ECHTEN NextIntlClientProvider und beweist
per Referenzgleichheit, dass t bei einem lokalen Zustandswechsel
dasselbe Funktionsobjekt bleibt — bestaetigt durch den use-intl-4.13.0-
Quelltext (translate entsteht in einem useMemo, dessen Abhaengigkeiten
ausschliesslich aus dem root-staendigen Intl-Kontext stammen). Die
Faustregel aus 260921-gof ("t gehoert in keine Abhaengigkeitsliste")
bleibt als Konvention in Ordnung; die zugrunde liegende Annahme ("t ist
bei jedem Render frisch") ist damit ausdruecklich WIDERLEGT statt ein
drittes Mal weitergetragen. Die vier verbliebenen Stellen
(marketplace/page.tsx, admin/users/page.tsx,
calendar-settings-panel.tsx, calendar-source-form.tsx) bleiben deshalb
unveraendert.

Vier neue zaehlende Testfaelle in calendar-widget.test.tsx (Aufbau je 1,
abweichendes Fenster +1/+0, identisches Fenster +0/+0, erzwungener Lauf
+1/+1) plus ein Test, dass ein uebersprungener Lauf den Ladezustand
sauber beendet und geladene Termine nicht leert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 15:10:10 +02:00
schalli 6c10c9bbe0 feat(quick-260921-jt4): uebersetzter ZIP-Name im Zertifikat-Aufteiler mit Windows-Schutzfunktion (Restposten 1)
downloadAllAsZip liegt ausserhalb der Komponente und kann den
Uebersetzungs-Hook nicht aufrufen; der Name kommt jetzt als Parameter
herein, Aufrufstelle uebergibt t('actions.zipFilename'). Neuer
Schluessel unter certManager.actions: deutsch "Zertifikate.zip",
englisch "certificates.zip".

Der 260921-bi2-Einwand (ein uebersetzter Name koenne Umlaute auf eine
Windows-Freigabe tragen) trifft fuer diesen konkreten Text nicht zu —
das deutsche Wort enthaelt keinen Umlaut. Die Sicherheit haengt darauf
aber NICHT: neue Datei zip-filename.ts mit einer fuer sich pruefbaren
Schutzfunktion, die Windows-verbotene Zeichen, Steuerzeichen und
Nicht-ASCII ersetzt, abschliessende Punkte/Leerzeichen entfernt,
reservierte Geraetenamen abfaengt, bei leerem Ergebnis auf
certificates.zip zurueckfaellt und die .zip-Endung sicherstellt.
SplitTab.tsx schickt den uebersetzten Namen durch diese Funktion, bevor
er am Download landet. Die Dateinamen IM Archiv bleiben unangetastet.

zip-filename.test.ts deckt beide Katalogwerte (unveraendert), Umlaut,
verbotenes Zeichen, Steuerzeichen, abschliessende Punkte/Leerzeichen,
fehlende/vorhandene Endung, leeres Ergebnis und reservierte
Geraetenamen ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 15:02:08 +02:00
schalli 5a03b75f1e refactor(quick-260921-jt4): ueberfluessige case-Marke in tender-normalizer.service.ts entfernt (Restposten 2)
Die Fallmarke 'doe-opendata' stand unmittelbar ueber default und fiel
in denselben Zweig — 260921-bi2 liess sie stehen, weil der Kommentar
darunter Absicht dokumentiert. Die Absicht laesst sich ohne die
Fallmarke ausdruecken und wird dabei deutlicher: der erweiterte
Kommentar traegt jetzt beide Aussagen (DÖE-Quelle landet hier UND
kuenftige additive SourceType-Mitglieder sollen ebenfalls hier landen
statt zu scheitern). Kein Verhaltenswechsel — derselbe Zweig wie
vorher. Belegt durch die vorhandenen 18 tender-normalizer-Tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:57:40 +02:00
schalli 69fe706580 fix(quick-260921-jt4): vier <img onError>-Avatarbilder tragen aria-hidden (D-03)
header.tsx, account-settings-form.tsx, favorites-widget.tsx (zweimal):
alle vier tragen bereits alt="", sind also schon aus dem
Zugaenglichkeitsbaum genommen; aria-hidden sagt dasselbe nur
ausdruecklich. Ehrliche Einordnung: richtige Auszeichnung, verbessert
fuer keinen Menschen etwas — der Befund verschwindet, weil die Regel
ein verborgenes Element nicht mehr betrachtet. onError ist ein
Ladefehler, keine Bedienung: hier gab es nie einen Tastaturweg zu
schaffen. Keine Unterdrueckung, kein biome-ignore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:57:01 +02:00
schalli f7b5df4db8 fix(quick-260921-jt4): zwei Schaltflaechenreihen tragen role="toolbar" (D-03)
calculator-widget.tsx Speicherzeile und favorites-widget.tsx
Ansichtsumschalter waren schlichte <div> mit aria-label, das die Rolle
generic stillschweigend verwarf. role="toolbar" ergaenzt (geprueft
sauber; role="group"/"region" loesen useSemanticElements neu aus).

Taschenrechner: fest verdrahteter Text wandert in
calculator.memoryLabel; bei dieser Gelegenheit auch Anzeigefeld
(displayLabel) und Rueckschritt-Taste (backspaceLabel) in den Katalog
gezogen (D-05), da diese Datei ohnehin geaendert wird.

Favoriten: die bisherige Beschriftung ("Favoriten") war sachlich
falsch fuer einen Listen/Kachel-Umschalter — neuer, zutreffender
Schluessel favorites.viewModeLabel statt des wiederverwendeten
favorites.name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:56:02 +02:00
schalli b406a9c5f7 fix(quick-260921-jt4): drei Statussymbole in calendar-settings-panel tragen role="img" (D-03)
Die drei <span> mit aria-label ("Sync error"/"Connection OK"/
"Connection error") hatten die Rolle generic, die keine ARIA-Merkmale
traegt — die Beschriftung wurde stillschweigend verworfen. role="img"
ergaenzt (geprueft sauber, keine useSemanticElements-Neuausloesung).
Die Beschriftung ist hier der einzige Text des Symbols (svg bereits
aria-hidden) — Ursache behoben, nicht das Attribut gestrichen.

Erfolgsfall nutzt den vorhandenen Schluessel calendar.connectionSuccess
woertlich; fuer die beiden Fehlerfaelle neue kurze Schluessel
syncErrorLabel/connectionFailedLabel (der vorhandene connectionError
ist ein ganzer Hinweissatz, als Symbolbeschriftung zu lang). Die drei
fest verdrahteten englischen Texte damit aus der deutschen Oberflaeche.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:54:06 +02:00
schalli 9aa87bd000 fix(quick-260921-jt4): autoFocus von vier Seitenformularen entfernt (D-02)
login/page.tsx, reset-password/page.tsx, reset-password/[token]/page.tsx,
change-password/page.tsx: alle vier sind Seitenformulare, kein Dialog —
D-02 verlangt hier das Entfernen, nicht den ref+Effekt-Ersatz.
Fokus-Klauen beim Seitenaufruf ist genau der Schaden, gegen den
noAutofocus existiert. autoComplete/required unveraendert.

Nebennutzen bei change-password: der Hinweisbereich zum erzwungenen
Passwortwechsel steht unmittelbar ueber dem Formular — bislang sprang
der Fokus daran vorbei, eine Vorlesehilfe las ihn nie vor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:52:40 +02:00
schalli e651c24647 fix(quick-260921-jt4): Kalender-Tageszelle wird echte Schaltflaeche fuer Tage mit Terminen
- Zelle rendert als <button type="button"> NUR wenn hasEvents gilt,
  sonst unveraendert als <div> ohne Handler (D-01) — 42 neue Tab-Stopps
  waeren eine Verschlechterung
- onFocus/onBlur mit demselben Rumpf wie onMouseEnter/onMouseLeave,
  damit die Termin-Einblendung auch per Tastatur erscheint/verschwindet
- cellClass unveraendert uebernommen, nur w-full text-left ergaenzt
  (D-04, kein optischer Unterschied); widgetNoDrag bleibt erhalten
- aria-label nennt Datum und Terminzahl ueber neue Katalogschluessel
  widgets.calendar.dayEventsOne/dayEventsMany (Mehrzahl-Konvention wie
  configMaxEventsOne/Many, D-05)
- Neue Tests: Tag mit Terminen ist <button> und reagiert auf
  Fokus/Weggehen wie auf Maus-Hover; Tag ohne Termine bleibt <div>;
  Einzahl-/Mehrzahl-Beschriftung; echter Tab-Stopp nachgewiesen

Nach diesem Umbau: klick-regeln 5 (nur die vier <img onError> +
Taschenrechner-Rahmen), semantic 0, a11y gesamt 14, errors 0 — Aufgabe
1 des Plans vollstaendig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:51:41 +02:00
schalli b601141bcf fix(quick-260921-jt4): Notiz-Aufgabenkaestchen bedient sich selbst
- NoteCheckbox traegt jetzt ein echtes onChange und gibt sein eigenes
  DOM-Element an onToggle weiter; readOnly entfaellt (D-01) — war bisher
  nur da, um Reacts Warnung ueber ein gesteuertes Feld ohne onChange zu
  unterdruecken
- Index-Ermittlung bleibt wortgleich (alle Kaestchen im Behaelter
  einsammeln, indexOf auf dem ausloesenden Element), wandert aber vom
  Behaelter-onClick in handleCheckboxToggle, das den Behaelter ueber ein
  ref statt event.currentTarget findet
- previewOptions als useMemo mit leerer Abhaengigkeitsliste, Rueckruf
  ueber ein ref erreicht — identitaetsstabil wie die alte Modulkonstante,
  T-JT4-03: rehypePlugins: [[rehypeSanitize]] unveraendert erhalten
- Tests: echte Tastaturbetaetigung (Leertaste auf fokussiertem
  Kaestchen) UND echter Klick loesen onToggle/PATCH aus; neue Tests
  belegen readOnly/disabled entfallen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:47:45 +02:00
schalli 0c89c13bb2 fix(quick-260921-jt4): calendar-settings-panel Loeschdialog-Hintergrund wird echte Schaltflaeche
- Zentrierbehaelter verliert den e.target===e.currentTarget-Handler,
  die Hintergrundfarbe wandert auf eine eigene benannte <button> (D-01)
- Muss abbrechen, darf niemals loeschen (T-JT4-04): Beschriftung nennt
  ausdruecklich das Abbrechen (widgets.calendar.deleteDialogCancel)
- Dialogkarte bekommt relative, aria-label des alertdialog aus dem
  Katalog statt fest verdrahtetem Englisch (deleteDialogLabel)
- Neue Testdatei belegt per Klick UND echter Tastaturbetaetigung, dass
  der Hintergrundweg abbricht und deleteSource nie aufgerufen wird;
  eigener Test fuer den tatsaechlichen Loeschweg ueber die CTA

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:43:36 +02:00
schalli 3d0bc0bfaa fix(quick-260921-jt4): widget-catalog-modal Hintergrund wird echte Schaltflaeche
- Aeussere Flaeche verliert onClick, die bislang nur optische
  Hintergrund-Flaeche wird eine benannte <button> und traegt onClose
  (D-01); verliert dabei aria-hidden, weil ein fokussierbares Element
  nicht verborgen sein darf
- stopPropagation auf der Dialogflaeche entfaellt als toter Code, weil
  der Hintergrund jetzt Geschwister statt Vorfahr ist
- Fest verdrahtetes englisches aria-label="Close" durch common.close
  ersetzt
- Neuer Katalogschluessel widgets.catalogClose in de.json/en.json
- Neue Testdatei: Hintergrund schliesst (Klick + Tastatur), Dialogklick
  schliesst nicht, Escape weiterhin, Kartenauswahl fuegt Widget hinzu

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:41:54 +02:00
schalli a8531d44df fix(quick-260921-jt4): MarketplaceCard-Klick wird echte deckende Schaltflaeche
- Karten-onClick entfernt, deckende <button> als Geschwister traegt
  handleCardClick nur wenn isActive (D-01, verschachtelte Buttons
  vermieden wie bei DropZone.tsx aus 260921-bi2)
- Aktivieren/Deaktivieren-Schaltflaeche bleibt eigener Tab-Stopp,
  Fussbereich bekommt relative fuer korrekten Stapelkontext
- Neuer Katalogschluessel marketplace.openDetail in de.json/en.json
- Tests: echte Tastaturbetaetigung (Enter auf fokussierter
  Schaltflaeche) statt Klick-Behauptung; kein Overlay im
  nicht-aktivierten Zustand

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:40:18 +02:00
schalli 7557c9aafe docs(quick-260921-jt4): Plan fuer 30 a11y-Befunde und vier Restposten
29 der 30 Fundstellen werden behoben, eine bleibt begruendet stehen.
Loesungswege vorab empirisch an Biome 2.5.0 geprueft: der in D-01 als
Rueckfall genannte Weg (role+tabIndex) taeuscht -- er tauscht drei Befunde
gegen einen neuen useSemanticElements-Befund und wird deshalb nirgends
benutzt.

Zwei Annahmen der Auftragsbeschreibung beim Nachlesen korrigiert:
vier der elf Klick-Befunde sind onError-Handler an img-Elementen, und
alle fuenf ARIA-Befunde sind aria-label auf rollenlosen Elementen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:35:29 +02:00
31 changed files with 2199 additions and 124 deletions
+5 -4
View File
@@ -4,8 +4,8 @@ milestone: v1.2
current_phase: 18
current_phase_name: desktop-client-fertigstellen
status: verified
stopped_at: "Sieben Quick-Vorgaenge am 2026-09-21 abgeschlossen und verifiziert (9ie, a1d, bi2, fi3, gof, i8x, iwr). Der fehlerverdaechtige Lint-Rueckstand ist damit vollstaendig geprueft: zwei echte Fehler gefunden und behoben (tote Passwortwechsel-Sperre an der API, stille 403-Antworten in der Benutzerverwaltung), der Rest war harmlos oder Absicht, je Stelle in den Akten begruendet. Offen und NICHT fehlerverdaechtig: 288 any im Quellcode, 30 a11y-Befunde mit Gestaltungsbedarf. Naechster Auftrag des Nutzers: zwei neue Dashboard-Widgets."
last_updated: "2026-09-21T12:45:00.000Z"
stopped_at: "Acht Quick-Vorgaenge am 2026-09-21 (9ie, a1d, bi2, fi3, gof, i8x, iwr, jt4). OFFEN UND WICHTIG: CI-Lauf 395 war rot wegen eines WACKELTESTS, nicht wegen der Arbeit — apps/web/src/components/bug-report/bug-report-button.test.tsx Test 1 faellt rund einmal in 17 vollen Laeufen durch (Vorschaubild da, Haekchen 'Bildschirmfoto anhaengen' aus). Stammt aus quick-260914-m97. Noch NICHT geklaert, ob das nur im Test so aussieht oder ob ein Nutzer sein Bild in der Vorschau sieht und es trotzdem nicht mitgeschickt wird — das ist der naechste Auftrag. Danach: 288 any in apps/api, dann zwei neue Dashboard-Widgets (eins davon ein Bilderrahmen, das zweite noch unbenannt). jt4 ist noch nicht gepusht."
last_updated: "2026-09-21T13:30:00.000Z"
last_activity: 2026-09-21
last_activity_desc: Quick 260921-9ie, a1d, bi2, fi3 und gof — Lint-Tor scharf, Benutzerverwaltung meldet abgewiesene Aktionen, Lint-Rueckstand 2923 → 446, erzwungener Passwortwechsel an der API durchgesetzt (war eine tote Sperre), 21 Effekt-Abhaengigkeiten einzeln beurteilt; alle fuenf verifiziert, die letzten drei am laufenden System
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-21 - Quick 260921-iwr: 30 Listenschluessel- und Zusicherungs-Stellen geprueft, kein echter Fehler; beide Verdachtsmomente (LDAP-Seite, Cert-Manager) durch Messung widerlegt. Der fehlerverdaechtige Rueckstand ist damit abgearbeitet
Last activity: 2026-09-21 - Quick 260921-jt4: Barrierefreiheit 30 → 1 Befund und die vier Restposten erledigt; zwei Vorgaben des Orchestrators vom Planer widerlegt (Rueckfallweg erzeugt neue Befunde, vier vermeintliche Klicks sind onError-Handler). Kalender-Verschwendung im Browser nachgemessen, 5-Minuten-Auffrischer per gestellter Uhr als intakt belegt
Progress: [██████████] 99%
@@ -451,6 +451,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260921-gof | **21 React-Effekt-Abhaengigkeiten einzeln beurteilt — 15 davon waren Fallen, nicht Fehler.** Die Klasse war aus 260921-bi2 zurueckgestellt worden, weil jeder Befund einzeln zu beurteilen ist. Ergebnis: nur **2 echte Defekte** (A), **15 Fallen** (B, das naive Eintragen haette eine Abruf-Schleife erzeugt), **3 Absicht** (C, mit begruendetem `biome-ignore` — erste Verwendung im Projekt), **1 Ballast** (D). **Die gefaehrlichste Stelle:** `calendar-widget.tsx:82` — `showToday` setzt bei jedem Klick ein frisches `Date`; `monthDate` naiv in die Liste einzutragen haette **jeden** Druck auf den Monatstitel einen Termin-Abruf ausloesen lassen, ueber die API bis zum Exchange-Server. Reihenfolge war Pflicht: erst Identitaet stabilisieren, dann die Liste umstellen. **Die haeufigste Falle:** `t` aus `useTranslations` ist in diesem Projekt bei jedem Durchlauf eine frische Funktion (die Test-Attrappen sind nachweislich so gebaut) — 8 Befunde. Griff ohne Ausnahme-Kommentar: den uebersetzten Text vor dem Hook in eine Konstante ziehen und diese eintragen; React vergleicht Zeichenketten per Wert. **Nebenbefund:** 11 `eslint-disable`-Zeilen fuer genau diese Regel waren wirkungslos, seit Biome ESLint abgeloest hat — alle entfernt. **Laufzeitnachweis vom Orchestrator im Browser** (Netzwerkprotokoll, nie `fetch` aus der Seite; gegen neu gebaute Abbilder): Dashboard 62 s Ruhe → Protokoll byte-identisch, genau 1 `calendar/events`; Monatstitel 3x gedrueckt → nur der erste Druck (Bereich aendert sich wirklich) loest einen Abruf aus, Druck 2 und 3 **null**; "Weiter" 3x → 3 Abrufe, korrekt; Stoppuhr 6 s real → Anzeige 00:06, 4 Runden ueber 4,8 s → 16/17/19/20 monoton, kein Ruecksprung. Dazu acht weitere Ansichten je 20-25 s ruhen gelassen (Marktplatz, Modulverwaltung, Gruppenverwaltung, DKV dreimal, Ausschreibungsradar zweimal) — jeder Endpunkt genau einmal. `InvoiceHistoryTable` hatte als einzige Datei keinen Test und ist damit gemessen statt nur gelesen; `ResultsList` ist die Stelle, an der die `t`-Falle in bi2 tatsaechlich zuschnappte. **Zahlen:** Warnungen 467 → 446 (exakt 21, nichts anderswo gewachsen), `useExhaustiveDependencies` 0, web-Tests 66/462 → 67/477, api 71/1136 unveraendert, type-check 4/4, `pnpm lint` 5/5 ohne Fehlerrang. Verifikation passed. **Benannt, nicht behoben:** zwei Verschwendungen im Kalender-Abruffenster (gleicher Zeitbereich zweimal geholt; `calendar/sources` bei jedem Monatswechsel) — vorbestehend; und `t` in vier vorbestehenden Abhaengigkeitslisten ausserhalb des Auftrags, die Biome nie gemeldet hat. | 2026-09-21 | b3f0e3c,e2c508c,e780b2c | [260921-gof-effekt-abhaengigkeiten-in-react-21-befun](./quick/260921-gof-effekt-abhaengigkeiten-in-react-21-befun/) |
| 260921-i8x | **Fuenf fehlerverdaechtige Lint-Klassen geprueft — kein einziger echter Fehler darunter.** Zwoelf Stellen einzeln beurteilt, Ergebnis: 7x gleichwertig oder Absicht, 2x Haertung, 3x idiomatisch korrekt. Das ist das Ergebnis, keine Ausrede — die Klassen klangen gefaehrlicher als sie waren. **Die eine Stelle mit echtem Wert:** `apps/web/src/lib/safe-next.ts`, der Schutz gegen Weiterleitung auf fremde Seiten nach der Anmeldung. Der Kommentar dort behauptete, die Steuerzeichen stuenden als Unicode-Escapes im Muster; die rohen Bytes zeigten das Gegenteil (NUL, 0x1F, 0x7F direkt eingebettet). Funktionierte, war aber zerbrechlich: verschluckt ein Werkzeug das NUL-Byte, wird aus dem Bereich stillschweigend ein anderer und der Schutz loechrig — der Rueckgabewert landet in `login/page.tsx` direkt in `window.location.href`. Jetzt echte Escapes, Datei ohne ein einziges Steuerbyte. **Beweis der Gleichwertigkeit, nicht Behauptung:** ueber alle 65536 Codepunkte dieselbe Menge abgelehnter Zeichen — 54 Stueck (32 Steuerzeichen 0x00-0x1F, dazu 0x7F, Backslash und die 20 Leerraum-Zeichen von `\s`), null Abweichung; vom Orchestrator unabhaengig gegen ein selbst gebautes Referenzmuster nachgerechnet. **Bewusst nicht angefasst:** die NUL-Maskierung in `ldap.service.ts` (RFC 4515) — genau dieses Zeichen zu treffen ist ihr Zweck, wer sie "repariert", oeffnet LDAP-Filter-Injection. Ebenso die drei `while ((m = re.exec(s)))`-Schleifen (idiomatisch, kein verrutschtes Gleichheitszeichen) und `noUselessSwitchCase` aus bi2. **Zwei Korrekturen an frueheren Annahmen:** `sanitizeNextPath` laeuft NICHT in der Edge-Middleware (die importiert nur `buildNextParam`), und das blosse Umschreiben auf Escapes senkt die Warnzahl nicht — Biome beanstandet die Escape-Schreibweise genauso, es braucht zusaetzlich einen einzeiligen Unterdrueckungskommentar. **Werkzeugfalle, dreimal zugeschnappt:** das Schreibwerkzeug wandelt `\uXXXX` still in das echte Zeichen um — der Planer erzeugte so zehn rohe Steuerbytes in seiner ersten Planfassung, der Executor zweimal in Commit-Text und Akte (git verweigerte den Commit wegen eines NUL-Bytes), und der Orchestrator beim Nachrechnen. Umgehung ueber `python3`/`chr(92)` ist im Plan hinterlegt. **Zahlen:** 446 → 434, `noControlCharactersInRegex`/`useIterableCallbackReturn`/`noGlobalIsNan` je 0, `suppressions/unused` 0, web-Tests 67/477 → 68/481, api 71/1136 → 72/1137, type-check 4/4, lint 5/5. | 2026-09-21 | f85c91b,076ca4b,b92dd5d | [260921-i8x-fehlerverdaechtige-lint-klassen-steuerze](./quick/260921-i8x-fehlerverdaechtige-lint-klassen-steuerze/) |
| 260921-iwr | **Listenschluessel und Ausrufezeichen-Zusicherungen: 30 Stellen geprueft, wieder kein echter Fehler.** Damit ist der fehlerverdaechtige Rueckstand abgearbeitet. **Zwei Vorannahmen des Orchestrators widerlegt, beide durch Messung statt Argument:** (1) Die LDAP-Seite galt als heisser Kandidat, weil dort Zuordnungsregeln hinzugefuegt und geloescht werden — die Liste, die tatsaechlich waechst und schrumpft (`config.fieldMappings`), benutzt jedoch laengst `key={mapping.id}`; die sechs Meldungen betreffen zustandslose Textlisten. (2) Im Cert-Manager galt eine Zusicherung auf hochgeladenen Dateiinhalt als moeglicher Absturz — der Planer hat eine 83-Byte-Schrottdatei gebaut, die node-forge `bag.cert = null` setzen laesst, und gegen den echten Dienst laufen lassen: **alle vier Pfade enden mit 400, nie 500**, und `certificateToPem(null)` wirft nachweislich, statt still ein falsches Zertifikat zu bauen. Also weder Verfuegbarkeits- noch Integritaetsluecke, sondern eine irrefuehrende Fehlermeldung. **Ein Fund dreht die Richtung um:** bei `admin/modules/grants/page.tsx:246` waere die Korrektur schaedlich — die Gruppierung fasst nur aufeinanderfolgende Kategorien zusammen, die Positionsnummer ist dort fuer die Eindeutigkeit noetig, ohne sie entstuenden doppelte Schluessel. **Geaendert: 5 Stellen** (drei Waechter im Cert-Manager, die den Meldungstext praezisieren — Status bleibt 400, rot-dann-gruen belegt; zwei ueberfluessige Zusicherungen in `imap.provider.ts`, die imapflow ohnehin als Pflichtfeld typisiert). **25 Stellen bleiben bewusst stehen und bleiben in der Zaehlung sichtbar** — mit Begruendung je Stelle in der Akte, damit der naechste Durchgang sie nicht erneut aufrollt; kein Unterdrueckungskommentar, um die Zahl zu schoenen. **Das Tor hat sich selbst bewaehrt:** der erste Entwurf eines Waechters erzeugte einen neuen Lint-Fund (430 statt 429) und wurde von der Verifikation des Plans gefangen; die Reparatur brach `tsc`, weil `@types/node-forge` `Bag.cert` als `Certificate | undefined` deklariert, waehrend die Bibliothek zur Laufzeit `null` zuweist — Endfassung prueft beides. **Zahlen:** 434 → 429, `noArrayIndexKey` unveraendert 19 (alle geprueft, alle harmlos), `noNonNullAssertion` 11 → 6, web-Tests 68/481 → 69/484, api 72/1137 → 72/1143, type-check 4/4, lint 5/5. | 2026-09-21 | 8716fa5,b4aaed4,27909e4,de69863 | [260921-iwr-listenschluessel-per-positionsnummer-und](./quick/260921-iwr-listenschluessel-per-positionsnummer-und/) |
| 260921-jt4 | **Barrierefreiheit von 30 auf 1 Befund, plus die vier zurueckgestellten Restposten.** Die 30 a11y-Befunde galten seit bi2 als "braucht Bedienentscheidungen"; die hat der Orchestrator getroffen, und der Planer hat **zwei davon widerlegt**: (1) Der vorgesehene Rueckfallweg (`role` + `tabIndex` + Tastaturhandler, wo kein echter Knopf geht) tauscht gemessen drei Befunde gegen einen neuen `useSemanticElements` — eine Regel, die bi2 gerade erst auf 0 gebracht hatte; wird nirgends benutzt, fuer den Verschachtelungsfall (Marktplatz-Karte) tritt eine deckende Geschwister-Schaltflaeche an seine Stelle. (2) **Vier der elf "Klick"-Befunde sind gar keine Klicks**, sondern `onError`-Handler an `<img>` — da gibt es keinen Tastaturweg zu schaffen, sie bekommen `aria-hidden`. **Ein Fund darueber hinaus:** alle fuenf ARIA-Befunde sind `aria-label` auf rollenlosen Elementen — die werden von Vorleseprogrammen still verworfen, die Beschriftungen kamen also bei niemandem an; jetzt mit korrekter Rolle. Sechs Stellen wurden zu echten `<button>` (Aussehen unveraendert), vier `autoFocus` auf Seiten entfernt (auf Seiten reisst er beim Laden den Fokus an sich — im Dialog waere er richtig gewesen, alle vier waren Seiten). **Ein Befund bleibt bewusst stehen und bleibt gezaehlt** (`calculator-widget.tsx:323`), samt ausdruecklich verworfener Umgehung. **Restposten:** ZIP-Name uebersetzt mit getesteter Schutzfunktion `zip-filename.ts` (der frueher genannte Umlaut-Einwand trifft fuer "Zertifikate.zip" nicht zu, die Schutzfunktion sichert kuenftige Uebersetzungen ab); die ueberfluessige `case`-Marke im Normalisierer aufgeloest, Absicht in den Kommentar gewandert; Kalender-Verschwendung abgestellt. **Zur `t`-Frage eine Korrektur an gof:** `use-intl` 4.13 erzeugt `t` in einem `useMemo`, es ist also in der Bibliothek stabil — instabil ist es nur in den Test-Attrappen, und daher kam der Beleg von damals. Die acht Korrekturen aus gof bleiben richtig und schaedlich sind sie nicht, aber die Begruendung war zu breit; ein Test an der Wurzel misst es jetzt. **Laufzeitnachweis vom Orchestrator** (Browser, 90 Tage Vorschau — bei der Voreinstellung 30 tritt der Doppelabruf gar nicht auf, die Messung haette also nichts gezeigt): drei Monatswechsel holen `calendar/sources` nur noch **1x statt 4x**, und der Termin-Abruf mit identischem Zeitraum ist weg (3 Klicks → 2 Abrufe statt 3). Der 5-Minuten-Auffrischer bleibt unangetastet — belegt nicht durch Warten im Browser (zwei Messversuche waren ungueltig, weil das Werkzeug die Seite zwischendurch neu laedt: nach 330 s Wartezeit war das Dokument 37 s alt), sondern durch Test 19 mit gestellter Uhr: nach `advanceTimersByTime(300_000)` werden **beide** Abrufe erneut ausgefuehrt. **Zahlen:** 429 → 399, a11y 30 → 1, web-Tests 69/484 → 73/529, api 72/1143 unveraendert, type-check 4/4, lint 5/5, keine neuen Unterdrueckungen. | 2026-09-21 | a8531d4,3d0bc0b,0c89c13,b601141,e651c24,+7 | [260921-jt4-barrierefreiheit-mit-bedienentscheidunge](./quick/260921-jt4-barrierefreiheit-mit-bedienentscheidunge/) |
## Deferred Items
@@ -496,4 +497,4 @@ Last session: 2026-09-21T04:50:00Z
Resumed: 2026-09-21 — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 55aa287, CI-Lauf 387 fuer 55aa287 erfolgreich (Beta-Images gebaut). Push und CI aus dem letzten Stopp-Punkt sind damit erledigt.
Stopped at: Warte auf Nutzerentscheidung, womit weitergearbeitet wird. Offen fuer den User: alpha pullen (web+api) und danach am Windows-VM-Client die echte Fehlermeldung schicken (Betreff `[Desktop/Windows]` + `Herkunft:`-Zeile pruefen); eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern; Freigabe 1.3.0 auf Zuruf. Technisch offen im Ledger: WINDOWS #35 (Biome laeuft nicht — biome.json:3 `organizeImports` ist in Biome 2.5.0 unbekannt, `biome check` bricht mit Konfigurationsfehler ab, reproduziert 2026-09-21) und WINDOWS #36 (403-Antworten bleiben in handleSubmit/handleDelete ohne sichtbare Reaktion).
Resume file: None
Last activity: 2026-09-21 - Quick 260921-iwr: 30 Listenschluessel- und Zusicherungs-Stellen geprueft, kein echter Fehler; beide Verdachtsmomente (LDAP-Seite, Cert-Manager) durch Messung widerlegt. Der fehlerverdaechtige Rueckstand ist damit abgearbeitet
Last activity: 2026-09-21 - Quick 260921-jt4: Barrierefreiheit 30 → 1 Befund und die vier Restposten erledigt; zwei Vorgaben des Orchestrators vom Planer widerlegt (Rueckfallweg erzeugt neue Befunde, vier vermeintliche Klicks sind onError-Handler). Kalender-Verschwendung im Browser nachgemessen, 5-Minuten-Auffrischer per gestellter Uhr als intakt belegt
@@ -0,0 +1,720 @@
---
phase: quick-260921-jt4
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
- apps/web/src/components/settings/calendar-settings-panel.tsx
- apps/web/src/components/settings/calendar-settings-panel.test.tsx
- apps/web/src/components/dashboard/widgets/note-widget.tsx
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx
- apps/web/src/components/dashboard/widgets/note-task-list.tsx
- apps/web/src/components/dashboard/widgets/note-task-list.test.tsx
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
- apps/web/src/components/dashboard/widgets/calculator-widget.tsx
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
- apps/web/src/components/layout/header.tsx
- apps/web/src/components/settings/account-settings-form.tsx
- apps/web/src/app/(auth)/login/page.tsx
- apps/web/src/app/(auth)/reset-password/page.tsx
- apps/web/src/app/(auth)/reset-password/[token]/page.tsx
- apps/web/src/app/(portal)/change-password/page.tsx
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts
- apps/web/src/lib/translations-identity.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/api/src/tenders/tender-normalizer.service.ts
autonomous: true
requirements: [D-01, D-02, D-03, D-04, D-05, D-06, D-07]
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 3
confidence: low
must_haves:
truths:
- "Jede Stelle, die heute nur mit der Maus bedienbar ist, ist danach auch mit der
Tastatur bedienbar und wird von einer Vorlesehilfe als Schaltflaeche angesagt —
belegt durch Komponententests, die ein Tastaturereignis ausloesen, nicht durch
Auszeichnungs-Behauptungen (D-01)."
- "Das Erscheinungsbild ist an keiner der 13 Dateien ein anderes als vorher: kein
neuer Rahmen, kein neuer Abstand, keine verschobene Kachel (D-04)."
- "Biome meldet nach Abschluss genau 399 Befunde, 0 davon der Stufe error, und genau
EINEN a11y-Befund — den bewusst stehengelassenen Tastatur-Handler des
Taschenrechner-Rahmens. Keine einzige Unterdrueckung per biome-ignore (D-07)."
- "Drei Druecke auf Weiter im Kalender-Widget holen die Quellenliste genau einmal
statt dreimal, und zwei aufeinanderfolgende Termin-Abrufe tragen nie denselben
from/to-Bereich — gemessen im Netzwerkprotokoll des Browsers, nicht per fetch aus
der Seite."
- "Der ZIP-Name des Zertifikat-Aufteilers kommt aus dem Uebersetzungskatalog und ist
auf einer Windows-Freigabe garantiert gueltig, weil eine gepruefte Schutzfunktion
jeden unzulaessigen Namen abfaengt — nicht, weil das deutsche Wort zufaellig
harmlos ist."
- "Jede neue Beschriftung steht in de.json UND en.json, deutsche Oberflaechentexte in
der Sie-Form; beide Kataloge haben danach dieselbe Schluesselmenge (D-05)."
- "Fuer jede der 30 Fundstellen steht in der SUMMARY, welcher der vier Wege gewaehlt
wurde und — bei der Rueckfallvariante oder beim Stehenlassen — warum der gerade
Weg dort nicht ging."
artifacts:
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
- apps/web/src/components/settings/calendar-settings-panel.test.tsx
- apps/web/src/lib/translations-identity.test.tsx
key_links:
- "Hintergrundflaeche eines Dialogs -> echte Schaltflaeche -> onClose: beim
Loeschen-Dialog MUSS dieser Weg abbrechen, niemals bestaetigen."
- "note-widget -> previewOptions -> rehypeSanitize: die XSS-Schranke T-IEX-01 muss
den Umbau des Kaestchen-Handlers unveraendert ueberleben."
- "calendar-widget -> computeFetchWindow -> fetchEvents: der Sperrgriff darf den
5-Minuten-Auffrischer nicht mitsperren, sonst friert die Anzeige ein."
---
<objective>
Die 30 zurueckgestellten Barrierefreiheits-Befunde abarbeiten — mit den vom Orchestrator
getroffenen Bedienentscheidungen D-01 bis D-04 — und die vier namentlich vermerkten
Restposten aus den heutigen Vorgaengen bi2 und gof schliessen.
Purpose: Heute sind mehrere Bedienelemente ausschliesslich mit der Maus erreichbar. Wer
mit der Tastatur oder einer Vorlesehilfe arbeitet, kann sie nicht ausloesen — das ist der
eigentliche Schaden hinter diesen Befunden, kein Schoenheitsfehler der Zaehlung.
Output: 13 Oberflaechendateien mit echten Schaltflaechen statt klickbarer Bereiche, zwei
Uebersetzungskataloge im Gleichstand, fuenf neue bzw. erweiterte Testdateien, ein
gemessener Rueckgang von 429 auf 399 Befunde und zwei Kalender-Abrufe weniger pro
Monatswechsel.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@.planning/quick/260921-bi2-lint-rueckstand-abbauen-mechanische-fixe/260921-bi2-SUMMARY.md
@.planning/quick/260921-gof-effekt-abhaengigkeiten-in-react-21-befun/260921-gof-VERIFICATION.md
</context>
<measured_baseline>
Vom Planer am 2026-09-21 unmittelbar vor dem Schreiben dieses Plans gemessen, nicht
uebernommen:
```
npx biome lint . --reporter=json
-> total 429 real 350 test 79 errors 0 a11y 30
```
Die Zahl 429 ist die **Gesamtzahl inklusive Testdateien** (350 echter Quelltext + 79
Testdateien), nicht der reine Quelltextanteil — die Auftragsbeschreibung bezeichnet sie
als "real source only", das ist eine Fehlbeschriftung der sonst korrekten Zahl. Alle 30
a11y-Befunde liegen in echtem Quelltext.
Die 30 Fundstellen, mit Zeilennummer zum Zeitpunkt der Planung (Zeilen verschieben sich
beim Umbau — arbeite nach Element, nicht nach Zeilennummer):
| Datei | Zeile | Regel |
|---|---|---|
| `app/(auth)/login/page.tsx` | 105 | noAutofocus |
| `app/(auth)/reset-password/[token]/page.tsx` | 139 | noAutofocus |
| `app/(auth)/reset-password/page.tsx` | 99 | noAutofocus |
| `app/(portal)/change-password/page.tsx` | 86 | noAutofocus |
| `marketplace/components/MarketplaceCard.tsx` | 116 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
| `components/dashboard/widget-catalog-modal.tsx` | 56 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
| `components/dashboard/widget-catalog-modal.tsx` | 64 | noNoninteractiveElementInteractions, useKeyWithClickEvents |
| `dashboard/widgets/calculator-widget.tsx` | 323 | noNoninteractiveElementInteractions |
| `dashboard/widgets/calculator-widget.tsx` | 345 | useAriaPropsSupportedByRole |
| `dashboard/widgets/calendar-widget.tsx` | 253 | noNoninteractiveElementInteractions, noStaticElementInteractions |
| `dashboard/widgets/favorites-widget.tsx` | 266 | useAriaPropsSupportedByRole |
| `dashboard/widgets/favorites-widget.tsx` | 462 | noNoninteractiveElementInteractions |
| `dashboard/widgets/favorites-widget.tsx` | 474 | noNoninteractiveElementInteractions |
| `dashboard/widgets/note-widget.tsx` | 184 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
| `components/layout/header.tsx` | 156 | noNoninteractiveElementInteractions |
| `components/settings/account-settings-form.tsx` | 161 | noNoninteractiveElementInteractions |
| `components/settings/calendar-settings-panel.tsx` | 159, 182, 199 | useAriaPropsSupportedByRole |
| `components/settings/calendar-settings-panel.tsx` | 373 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
**Zwei Befunde der Auftragsbeschreibung sind beim Nachlesen anders als angenommen** — der
Plan folgt dem gelesenen Quelltext, nicht der Annahme:
1. **Vier der elf `noNoninteractiveElementInteractions` sind ueberhaupt keine Klicks**,
sondern `onError`-Handler an `<img>`-Elementen (Ersatzweg fuer nicht ladende
Profilbilder und Favoriten-Symbole): `header.tsx:156`, `account-settings-form.tsx:161`,
`favorites-widget.tsx:462` und `:474`. Ein Ladefehler ist keine Bedienung; hier gibt es
nichts in eine Schaltflaeche zu verwandeln.
2. **Alle fuenf `useAriaPropsSupportedByRole` haben dieselbe Gestalt**: ein `aria-label`
sitzt auf einem schlichten `<div>` bzw. `<span>` ohne Rolle. Solche Elemente haben die
Rolle `generic`, die gar keine ARIA-Merkmale traegt — die Beschriftung wird von jeder
Vorlesehilfe **stillschweigend verworfen**. Das ist ein echter Mangel, kein Formfehler.
</measured_baseline>
<verified_probes>
Die folgenden Loesungswege wurden vom Planer **empirisch an Biome 2.5.0 mit der
Projektkonfiguration geprueft** (Probedatei unter `apps/web/src/__probe__/`, danach
geloescht). Das ist keine Vermutung — jede Zeile ist gemessen:
| Probe | Ergebnis |
|---|---|
| `<button type="button" className="fixed inset-0" aria-label=… onClick=…/>` | **sauber** — traegt den Dialog-Hintergrund |
| `<button … onMouseEnter onMouseLeave onFocus onBlur>` | **sauber** — traegt die Kalender-Tageszelle |
| Deckende Schaltflaeche als Geschwister neben der Aktionsschaltflaeche in einer Karte | **sauber** — traegt die Marktplatz-Karte |
| `<span role="img" aria-label=…><svg aria-hidden/></span>` | **sauber** — traegt die drei Statussymbole |
| `<div role="toolbar" aria-label=…>` ohne Handler | **sauber** — traegt die beiden Schaltflaechenreihen |
| `<img alt="" aria-hidden="true" onError=…/>` | a11y-Befund **verschwindet** (nur der vorbestehende `performance/noImgElement` bleibt) |
| `<div role="button" tabIndex={0} onClick onKeyDown>` | **loest `useSemanticElements` NEU aus** |
| `<div role="group" aria-label=…>` | **loest `useSemanticElements` NEU aus** (Vorschlag: `<fieldset>`) |
| `<div role="region" aria-label=…>` | **loest `useSemanticElements` NEU aus** (Vorschlag: `<section>`) |
| `<div role="application"/"group"/"toolbar" … onKeyDown>` | Befund bleibt in **allen drei** Varianten |
**Die wichtigste Erkenntnis daraus:** Der in D-01 als Rueckfall genannte Weg
(`role="button"` + `tabIndex={0}` + Tastaturhandler) bringt die Zaehlung NICHT auf null.
Er tauscht drei Befunde gegen einen neuen `useSemanticElements`-Befund — eine Regel, die
260921-bi2 gerade erst auf 0 gebracht hat. D-07 verbietet, dass die Zahl anderswo waechst.
**Der Rueckfall wird deshalb in diesem Vorgang an keiner einzigen Stelle benutzt**; wo
eine unmittelbare Umwandlung in `<button>` an der Verschachtelung scheitert (Marktplatz),
tritt stattdessen die geprueft saubere deckende Geschwister-Schaltflaeche an ihre Stelle.
</verified_probes>
<decisions_applied>
Zuordnung der 30 Fundstellen zu den gesperrten Entscheidungen. Diese Tabelle ist die
Vorgabe, nicht ein Vorschlag:
| # | Fundstelle | Weg | Entscheidung |
|---|---|---|---|
| 1-3 | MarketplaceCard.tsx (Karte) | echter Button (deckendes Geschwister) | D-01 |
| 4-6 | widget-catalog-modal.tsx (Hintergrund) | echter Button | D-01 |
| 7-8 | widget-catalog-modal.tsx (Dialogflaeche) | Handler entfaellt ersatzlos | D-01 |
| 9-11 | calendar-settings-panel.tsx (Loeschdialog-Hintergrund) | echter Button | D-01 |
| 12-14 | note-widget.tsx (Vorschau) | Kaestchen uebernimmt seinen Handler selbst | D-01 |
| 15-16 | calendar-widget.tsx (Tageszelle) | echter Button + Fokus-Handler | D-01 |
| 17-20 | vier `noAutofocus`-Stellen | Attribut entfaellt (alle vier sind Seiten, kein Dialog) | D-02 |
| 21-23 | calendar-settings-panel.tsx (3 Statussymbole) | `role="img"` + uebersetzte Beschriftung | D-03 |
| 24 | calculator-widget.tsx (Speicherzeile) | `role="toolbar"` + uebersetzte Beschriftung | D-03 |
| 25 | favorites-widget.tsx (Ansichtsumschalter) | `role="toolbar"` + **richtige** Beschriftung | D-03 |
| 26-29 | vier `<img onError>` | `aria-hidden="true"` | D-03 |
| 30 | calculator-widget.tsx (Tastatur am Rahmen) | **bleibt stehen, bleibt gezaehlt** | D-07 |
**Zu D-02:** Keine der vier `autoFocus`-Stellen ist ein Dialog. Es sind vier
Seitenformulare (Anmeldung, Passwort-Zuruecksetzen anfordern, Passwort-Zuruecksetzen
einloesen, Passwort aendern). D-02 sagt fuer genau diesen Fall: Entfernen ist richtig,
weil Fokus-Klauen beim Seitenaufruf das ist, wogegen die Regel existiert. Der
ref+Effekt-Zweig von D-02 kommt in diesem Projekt also an keiner Stelle zum Zug — das ist
ein Ergebnis, kein Uebersehen. Besonderer Nebennutzen bei `change-password/page.tsx`:
unmittelbar ueber dem Formular steht der Hinweisbereich zum erzwungenen Wechsel; heute
springt der Fokus daran vorbei, eine Vorlesehilfe liest den Hinweis nie vor.
**Zu Fundstelle 30 (bleibt stehen):** Der Rahmen des Taschenrechners traegt
`role="application"` und einen `onKeyDown`, damit getippte Ziffern ankommen, sobald
irgendeine seiner Tasten den Fokus hat. Jede sichtbare Taste ist bereits ein echtes
`<button>` und selbst Teil der Tab-Reihenfolge. Der Handler **fuegt einen Tastaturweg
hinzu** — er ist das Gegenteil des Schadens, den die Regel beschreibt. Alle drei
Rollen-Alternativen wurden gemessen und aendern nichts (siehe Probentabelle).
**Ausdruecklich NICHT gewaehlt und auch spaeter nicht nachzuholen:** den Handler per
`addEventListener` in einem Effekt anzuhaengen. Das Verhalten waere identisch, nur die
Regel saehe ihn nicht mehr — das waere eine geschoente Zahl ohne Gegenwert, und D-07
verbietet genau das. Der Befund bleibt sichtbar in der Zaehlung stehen.
</decisions_applied>
<tasks>
<task type="auto" tdd="true">
<name>Aufgabe 1: Aus klickbaren Bereichen echte Schaltflaechen machen (16 Befunde, 5 Dateien)</name>
<files>apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx, apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/components/settings/calendar-settings-panel.tsx, apps/web/src/components/settings/calendar-settings-panel.test.tsx, apps/web/src/components/dashboard/widgets/note-widget.tsx, apps/web/src/components/dashboard/widgets/note-task-list.tsx, apps/web/src/components/dashboard/widgets/note-widget.test.tsx, apps/web/src/components/dashboard/widgets/note-task-list.test.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>
Lies vor dem ersten Eingriff den Abschnitt `verified_probes` dieses Plans und die
Pitfall-Notiz aus `260921-bi2-SUMMARY.md` (Abschnitt "tech-stack -> patterns"): ein
a11y-Fix kann eine ANDERE Regel neu ausloesen, wenn ein Element seine interaktive
Einstufung verliert. Miss nach jedem einzelnen Teilumbau die vollstaendige a11y-Menge,
nicht nur die Zielregel.
</read_first>
<behavior>
- MarketplaceCard: Tastaturbedienung der Karte oeffnet die Moduldetails; die
Aktivieren-Schaltflaeche bleibt ein eigener, separat erreichbarer Tab-Stopp und
loest beim Ausloesen NICHT zusaetzlich das Oeffnen aus.
- MarketplaceCard, gesperrter Zustand: Tastaturbedienung ruft den Gesperrt-Hinweis auf.
- MarketplaceCard, nicht aktiviertes Modul: es gibt keine Kartenschaltflaeche.
- widget-catalog-modal: Ausloesen der Hintergrundflaeche schliesst; ein Klick im
Dialog schliesst nicht; Escape schliesst weiterhin.
- calendar-settings-panel, Loeschdialog: Ausloesen der Hintergrundflaeche **bricht ab**
(Datensatz bleibt bestehen), Loeschen geschieht ausschliesslich ueber die
Loeschen-Schaltflaeche.
- note-widget: Ein Aufgabenkaestchen laesst sich mit der Tastatur umschalten und die
zugehoerige Markdown-Zeile kippt; im Bearbeitungsmodus passiert nichts.
- calendar-widget: Ein Tag MIT Terminen ist mit der Tastatur fokussierbar und zeigt
beim Fokussieren dieselbe Termin-Einblendung wie beim Ueberfahren mit der Maus;
beim Verlassen verschwindet sie. Ein Tag OHNE Termine ist kein Tab-Stopp.
</behavior>
<action>
Fuenf Umbauten, jeder einzeln zu committen. Umsetzung von D-01 ueberall ohne den
Rueckfallweg, weil dieser messbar eine andere Regel neu ausloesen wuerde (siehe
`verified_probes`). D-04 gilt durchgehend: das Erscheinungsbild bleibt gleich.
**(1) MarketplaceCard.tsx — die Karte.** Der `onClick` sitzt heute auf der Karten-`<div>`,
und die Karte enthaelt im Fuss die Aktivieren/Deaktivieren-Schaltflaeche. Eine unmittelbare
Umwandlung der Karte in ein `<button>` ist deshalb unmoeglich (verschachtelte
Schaltflaechen sind ungueltige Auszeichnung — genau die Falle, in die 260921-gof bei
`DropZone.tsx` schon einmal gelaufen ist). Stattdessen: die Karten-`<div>` verliert ihren
`onClick` und bekommt `relative`; als Geschwisterelement kommt eine deckende
`<button type="button" className="absolute inset-0 rounded-lg …">` hinzu, die
`handleCardClick` traegt und nur gerendert wird, wenn `isActive` gilt (heute haengt der
Handler ebenfalls an `isActive`). Die Maus-Zeigerform der deckenden Schaltflaeche
uebernimmt die Fallunterscheidung der Karte (`cursor-not-allowed` bei `locked`, sonst
`cursor-pointer`), damit sich optisch nichts aendert. Der Fussbereich mit der
Aktivieren-Schaltflaeche bekommt `relative`, damit er ueber der deckenden Flaeche liegt und
weiterhin unmittelbar getroffen wird; das dortige `e.stopPropagation()` bleibt unangetastet.
Die deckende Schaltflaeche braucht einen Namen fuer die Vorlesehilfe — verwende den
vorhandenen Modulnamen ueber einen neuen Schluessel `marketplace.openDetail` (D-05).
Setze `<h3>`/Beschreibung NICHT in die Schaltflaeche hinein: die Ueberschrift kuerzt per
`truncate` (also `overflow-hidden`) und wuerde ein darin liegendes Deckelement beschneiden.
**(2) widget-catalog-modal.tsx — Hintergrund und Dialogflaeche.** Heute traegt die
aeussere Flaeche den `onClick={onClose}`, und die Dialogflaeche haelt mit einem
`stopPropagation` dagegen. Kehre das um: die aeussere Flaeche verliert ihren Handler
ersatzlos; die bereits vorhandene, bislang rein optische Hintergrund-`<div>`
(`fixed inset-0 bg-black/50`) wird zu
`<button type="button" className="fixed inset-0 bg-black/50" onClick={onClose}>` und
verliert dabei ihr `aria-hidden` — ein fokussierbares Element darf nicht vor der
Vorlesehilfe verborgen sein. Sie bekommt stattdessen einen echten Namen ueber einen neuen
Schluessel `widgets.catalogClose` (D-05). Weil der schliessende Handler danach kein
Vorfahr der Dialogflaeche mehr ist, sondern ihr Geschwister, ist das `stopPropagation`
auf der Dialogflaeche **toter Code und wird geloescht** — damit fallen die beiden Befunde
an dieser Stelle weg, ohne dass sich irgendein Verhalten aendert. Der Escape-Weg im
bestehenden Effekt bleibt unveraendert. Ersetze bei dieser Gelegenheit das fest
verdrahtete englische `aria-label="Close"` der Schliessen-Schaltflaeche durch
`common.close` (D-05) — es steht in der Datei, die du ohnehin umbaust.
**(3) calendar-settings-panel.tsx — der Loeschbestaetigungs-Dialog.** Dieselbe Gestalt,
andere Datei: die `fixed inset-0 … bg-black/50`-Flaeche ist zugleich Hintergrund UND
Zentrierbehaelter und prueft im Handler `e.target === e.currentTarget`. Trenne beides: der
Behaelter behaelt `fixed inset-0 z-50 flex items-center justify-center` und verliert jeden
Handler; die Hintergrundfarbe wandert auf eine neue
`<button type="button" className="fixed inset-0 bg-black/50" onClick={() => setDeletingId(null)}>`;
die Dialogkarte bekommt `relative`, damit sie weiterhin ueber dem Hintergrund liegt.
Beschriftung ueber einen neuen Schluessel `widgets.calendar.deleteDialogCancel`, dessen
Text das Abbrechen benennt — dieser Weg darf niemals loeschen (siehe threat_model
T-JT4-04). Ersetze ausserdem das fest verdrahtete englische `aria-label="Confirm deletion"`
des `role="alertdialog"` durch einen neuen Schluessel
`widgets.calendar.deleteDialogLabel` (D-05).
**(4) note-widget.tsx + note-task-list.tsx — das Aufgabenkaestchen bedient sich selbst.**
Heute faengt der Vorschau-Behaelter die Klicks ab (`handlePreviewClick`) und das von
`NoteCheckbox` gerenderte Kaestchen traegt `readOnly`. Dreh das um: `NoteCheckbox`
bekommt einen `onChange` und gibt darin sein eigenes DOM-Element an einen von aussen
gereichten Rueckruf weiter; `readOnly` entfaellt. Die **Index-Ermittlung bleibt Wort fuer
Wort die heutige** (alle Kaestchen im Vorschau-Behaelter einsammeln, `indexOf` auf dem
ausloesenden Element) — sie wandert lediglich vom Behaelter-Handler in eine Funktion des
Widgets, die den Behaelter ueber ein `ref` statt ueber `event.currentTarget` findet.
Fasse den bisher nur module-weit gueltigen `PREVIEW_OPTIONS`-Wert in ein `useMemo`, das
den Rueckruf ueber ein `useRef` erreicht, damit das Optionsobjekt **identitaetsstabil**
bleibt — der bestehende Kommentar begruendet genau das, und ein pro Tastendruck neu
erzeugtes Optionsobjekt liesse react-markdown bei jedem Zeichen neu abgleichen.
`rehypePlugins: [[rehypeSanitize]]` muss dabei unveraendert erhalten bleiben (XSS-Schranke
T-IEX-01, siehe threat_model T-JT4-03). Der `onClick` am Vorschau-Behaelter entfaellt
danach ersatzlos. Die Pruefung auf `isEditing` bleibt erhalten. Nebennutzen, den du in der
SUMMARY benennen sollst: `readOnly` war bisher nur da, um Reacts Warnung ueber ein
gesteuertes Feld ohne `onChange` zu unterdruecken — das Kaestchen bedient sich jetzt
tatsaechlich selbst, statt sich von seinem Behaelter bedienen zu lassen.
**(5) calendar-widget.tsx — die Tageszelle.** Heute traegt jede der 42 Zellen
`onMouseEnter`/`onMouseLeave` fuer die Termin-Einblendung. Wer nicht mit der Maus
arbeitet, bekommt die Termine eines Tages **gar nicht** zu sehen — das ist der echte
Mangel hinter diesem Befund. Rendere die Zelle als `<button type="button">`, **wenn und
nur wenn sie Termine hat** (`hasEvents`), sonst unveraendert als `<div>` ohne Handler.
Nur Tage mit Terminen werden so zu Tab-Stopps; ein Widget mit 42 neuen Tab-Stopps waere
eine Verschlechterung. Die Schaltflaechen-Variante traegt zusaetzlich `onFocus`/`onBlur`
mit demselben Rumpf wie `onMouseEnter`/`onMouseLeave`, damit die Einblendung fuer die
Tastatur genauso erscheint und verschwindet. Uebernimm `cellClass` unveraendert und
ergaenze nur das, was ein `<button>` braucht, um wie die bisherige `<div>` auszusehen
(Textausrichtung, volle Breite, kein geerbter Schaltflaechenrahmen) — `widgetNoDrag` muss
erhalten bleiben, sonst reisst react-grid-layout die Kachel beim Klicken mit.
Die Schaltflaeche bekommt einen Namen, der Datum und Terminzahl nennt; folge der im
Katalog bereits vorhandenen Mehrzahl-Konvention dieses Projekts mit zwei getrennten
Schluesseln (`widgets.calendar.dayEventsOne` / `widgets.calendar.dayEventsMany`, Vorbild
`configMaxEventsOne`/`configMaxEventsMany`), nicht mit ICU-Plural (D-05).
**Uebersetzungen:** Jeder neue Schluessel kommt in `de.json` UND `en.json`, deutsche Texte
in der Sie-Form (D-05). Keine neuen Pakete, keine Versionsanhebung, keine Umformatierung
fremder Dateien (D-06).
**Tests — Tastaturereignis schlaegt Auszeichnungsbehauptung.** Erweitere bzw. lege an:
`MarketplaceCard.test.tsx` (vorhanden), `widget-catalog-modal.test.tsx` (neu),
`calendar-settings-panel.test.tsx` (neu), `note-widget.test.tsx` und
`note-task-list.test.tsx` (vorhanden), `calendar-widget.test.tsx` (vorhanden).
`@testing-library/user-event` ist in `apps/web` verfuegbar (14.6.1) — nutze es, um nach
`.focus()` eine echte Tastaturbetaetigung auszuloesen, statt einen Klick zu senden und
Tastaturbedienung nur zu behaupten. Fuer das Notiz-Kaestchen ist die Tastaturbetaetigung
gerade der Punkt: ein fokussiertes Kaestchen muss sich mit der Leertaste kippen lassen.
Fuer den Loeschdialog muss ein Test belegen, dass der Hintergrundweg abbricht und die
Quelle **nicht** geloescht wird.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];const a=d.filter(x=>/a11y\/(noNoninteractiveElementInteractions|noStaticElementInteractions|useKeyWithClickEvents)/.test(x.category));a.forEach(x=>console.log(' ',x.category.replace('lint/a11y/',''),x.location.path+':'+x.location.start.line));console.log('klick-regeln',a.length,'| semantic',d.filter(x=>x.category==='lint/a11y/useSemanticElements').length,'| a11y gesamt',d.filter(x=>x.category.includes('a11y')).length,'| gesamt',d.length,'| errors',d.filter(x=>x.severity==='error').length);})"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run 2>&1 | tail -8</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | tail -4</automated>
</verify>
<done>
Erste Pruefung meldet `klick-regeln 5` — genau die vier `<img onError>`-Stellen
(`header.tsx`, `account-settings-form.tsx`, `favorites-widget.tsx` zweimal) und der
Taschenrechner-Rahmen; `semantic 0` (die Regel ist NICHT gewachsen); `a11y gesamt 14`;
`errors 0`. `apps/web`-Tests gruen mit mindestens 69 Dateien und mindestens 484 Tests
(die neuen Faelle kommen obendrauf, die Zahl darf nur steigen). `pnpm type-check` 4/4.
Fuer jede der fuenf Umbauten steht fest, welcher Weg gewaehlt wurde; der Rueckfallweg aus
D-01 wurde an keiner Stelle benutzt.
</done>
</task>
<task type="auto">
<name>Aufgabe 2: Rollen, Beschriftungen, Autofokus — und zwei Restposten (14 Befunde)</name>
<files>apps/web/src/app/(auth)/login/page.tsx, apps/web/src/app/(auth)/reset-password/page.tsx, apps/web/src/app/(auth)/reset-password/[token]/page.tsx, apps/web/src/app/(portal)/change-password/page.tsx, apps/web/src/components/settings/calendar-settings-panel.tsx, apps/web/src/components/dashboard/widgets/calculator-widget.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/components/layout/header.tsx, apps/web/src/components/settings/account-settings-form.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts, apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts, apps/api/src/tenders/tender-normalizer.service.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<action>
Attributarbeit ohne Strukturumbau — deutlich geringeres Risiko als Aufgabe 1, deshalb
getrennt. Dazu zwei der vier vermerkten Restposten.
**(1) Vier `autoFocus` entfernen (D-02).** `login/page.tsx`, `reset-password/page.tsx`,
`reset-password/[token]/page.tsx`, `change-password/page.tsx`. Der Planer hat alle vier
gelesen: es sind Seitenformulare, keine Dialoge — D-02 verlangt hier das Entfernen und
ausdruecklich NICHT den Ersatz durch ref+Effekt. Ein Ersatz per Effekt waere dasselbe
Verhalten mit stillgelegter Regel. Entferne ausschliesslich das Attribut; `autoComplete`,
`required` und alles andere bleibt. Halte in der SUMMARY je Stelle fest, dass es eine
Seite und kein Dialog war.
**(2) Drei Statussymbole in `calendar-settings-panel.tsx` (D-03).** Die drei `<span>` mit
`aria-label="Sync error"` / `"Connection OK"` / `"Connection error"` haben die Rolle
`generic` und ihre Beschriftung wird stillschweigend verworfen. Die Beschriftung traegt
hier echte Bedeutung — sie ist der EINZIGE Text dieser Symbole (das `<svg>` darin ist
bereits `aria-hidden`). Also Ursache beheben, nicht Attribut streichen: `role="img"`
ergaenzen (geprueft sauber). Die drei englischen Texte in einer deutschen Oberflaeche
gehen dabei in den Katalog (D-05): fuer den Erfolgsfall ist der vorhandene Schluessel
`widgets.calendar.connectionSuccess` woertlich passend und wird wiederverwendet; fuer die
beiden Fehlerfaelle lege kurze eigene Schluessel an (`widgets.calendar.syncErrorLabel`,
`widgets.calendar.connectionFailedLabel`) — der vorhandene `connectionError` ist ein
ganzer Hinweissatz und als Symbolbeschriftung zu lang. Das `title`-Attribut mit dem
Rohfehler bleibt unangetastet.
**(3) Zwei Schaltflaechenreihen (D-03).** `calculator-widget.tsx`, Speicherzeile
(`aria-label="Speicherfunktionen"`) und `favorites-widget.tsx`, Ansichtsumschalter
(`aria-label={t('favorites.name')}`): beide sind schlichte `<div>`, die Beschriftung
verpufft. Ergaenze `role="toolbar"` (geprueft sauber — `role="group"` und `role="region"`
scheiden aus, sie loesen `useSemanticElements` neu aus). Beim Taschenrechner wandert der
fest verdrahtete deutsche Text in den Katalog (`widgets.calculator.memoryLabel`). Beim
Favoriten-Umschalter ist die heutige Beschriftung sachlich falsch — sie sagt "Favoriten"
ueber einem Umschalter zwischen Listen- und Kachelansicht; vergib einen neuen, zutreffenden
Schluessel `widgets.favorites.viewModeLabel`. Das ist der Fall, in dem D-03 das Streichen
erlauben wuerde (die Beschriftung trug keine echte Bedeutung); eine richtige Beschriftung
ist trotzdem besser als gar keine. Uebersetze im Taschenrechner bei dieser Gelegenheit die
beiden weiteren fest verdrahteten deutschen Beschriftungen derselben Ansicht (Anzeigefeld,
Rueckschritt-Taste) in Katalogschluessel (D-05) — Grenze der Ausweitung: nur
Beschriftungen in Dateien, die dieser Vorgang ohnehin aendert.
**(4) Vier `<img onError>` (D-03).** `header.tsx`, `account-settings-form.tsx` und
zweimal `favorites-widget.tsx`. Ergaenze `aria-hidden="true"`. **Sei in der SUMMARY
ehrlich darueber, was das leistet und was nicht:** alle vier tragen bereits `alt=""`, sind
also schon aus dem Zugaenglichkeitsbaum genommen; `aria-hidden` sagt dasselbe nur
ausdruecklich. Es ist richtige Auszeichnung, aber es verbessert fuer keinen Menschen
etwas — der Befund verschwindet, weil die Regel ein verborgenes Element nicht mehr
betrachtet. `onError` ist ein Ladefehler, keine Bedienung: hier gab es nie einen
Tastaturweg zu schaffen. Keine Unterdrueckung, kein `biome-ignore`.
**(5) Restposten 2 — `tender-normalizer.service.ts`, `noUselessSwitchCase`.** Nachgelesen:
die Fallmarke `case 'doe-opendata':` steht unmittelbar ueber `default:` und faellt in
denselben Zweig; der Kommentar darunter erklaert, warum der Standardzweig auf dem
DOE-Weg bleiben muss. 260921-bi2 hat sie stehen lassen, weil sie Absicht dokumentiert.
Diese Absicht laesst sich ohne die ueberfluessige Marke ausdruecken und wird dabei sogar
deutlicher: entferne die Fallmarke und erweitere den bestehenden Kommentar so, dass er
beide Aussagen traegt — dass die DOE-Quelle hier landet UND dass kuenftige additive
Mitglieder der SourceType-Vereinigung ebenfalls hier landen sollen, statt zu scheitern.
Kein Verhaltenswechsel: der Zweig, in den `'doe-opendata'` faellt, ist vorher wie nachher
derselbe. Belege das mit den vorhandenen `apps/api`-Tests.
**(6) Restposten 1 — ZIP-Name im Zertifikat-Aufteiler.** `downloadAllAsZip` in
`SplitTab.tsx` ist eine Funktion ausserhalb der Komponente und kann den
Uebersetzungs-Hook nicht aufrufen; reiche den Namen deshalb als Parameter herein und
uebergib an der Aufrufstelle `t('actions.zipFilename')`. Neuer Schluessel unter
`certManager.actions` in beiden Katalogen: deutsch `Zertifikate.zip`, englisch
`certificates.zip`.
Der Einwand aus 260921-bi2 war, ein uebersetzter Name koenne Umlaute auf eine
Windows-Freigabe tragen. Neuer Befund: das deutsche Wort fuer Zertifikate enthaelt keinen
Umlaut und kein von Windows verbotenes Zeichen, der Einwand trifft fuer diesen konkreten
Text also nicht zu. **Verlass dich aber nicht darauf, dass das Wort zufaellig harmlos
ist** — sonst haengt die Dateisystem-Sicherheit an einer kuenftigen
Uebersetzungsentscheidung. Lege `zip-filename.ts` mit einer kleinen, fuer sich pruefbaren
Schutzfunktion an, die einen Namen auf das fuer eine Windows-Freigabe Zulaessige
zurueckschneidet: die von Windows verbotenen Zeichen und Steuerzeichen ersetzen,
Nicht-ASCII ersetzen, abschliessende Punkte und Leerzeichen entfernen, die reservierten
Geraetenamen abfangen, bei leerem Ergebnis auf `certificates.zip` zurueckfallen und die
Endung sicherstellen. `SplitTab.tsx` schickt den uebersetzten Namen durch diese Funktion,
bevor er am Download landet. Decke die Funktion in `zip-filename.test.ts` ab: der
deutsche und der englische Katalogwert kommen unveraendert durch, ein Name mit Umlaut und
einer mit verbotenem Zeichen werden bereinigt, ein aussichtsloser Name faellt auf den
Ersatznamen zurueck. Die Namen der einzelnen Dateien IM Archiv stammen aus der API und
bleiben unangetastet.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];const a=d.filter(x=>x.category.includes('a11y'));a.forEach(x=>console.log(' ',x.category.replace('lint/a11y/',''),x.location.path+':'+x.location.start.line));console.log('a11y',a.length,'| switch',d.filter(x=>x.category==='lint/complexity/noUselessSwitchCase').length,'| gesamt',d.length,'| errors',d.filter(x=>x.severity==='error').length);})"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const f=o=>Object.entries(o).flatMap(([k,v])=>v&&typeof v==='object'?f(v).map(x=>k+'.'+x):[k]);const de=f(require('./apps/web/src/messages/de.json')),en=f(require('./apps/web/src/messages/en.json'));const A=new Set(de),B=new Set(en);console.log('de',de.length,'en',en.length,'nurDe',de.filter(k=>!B.has(k)).join(',')||'-','nurEn',en.filter(k=>!A.has(k)).join(',')||'-');"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run zip-filename 2>&1 | tail -6</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/api exec vitest run 2>&1 | tail -6</automated>
</verify>
<done>
Erste Pruefung meldet `a11y 1` und die einzige verbleibende Zeile nennt
`noNoninteractiveElementInteractions` in `calculator-widget.tsx`; `switch 0`;
`gesamt 399`; `errors 0`. Katalogpruefung: beide Kataloge gleich lang, weder `nurDe` noch
`nurEn` nennt einen Schluessel. `zip-filename`-Tests gruen. `apps/api`-Tests gruen mit
mindestens 72 Dateien und mindestens 1143 Tests. Weicht `gesamt` von 399 ab, ist das kein
stiller Durchlauf: nenne die Abweichung nach Regel aufgeschluesselt und ihre Ursache.
</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 3: Verschwendete Abrufe — Kalender-Ladefenster und die vier t-Abhaengigkeiten</name>
<files>apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx, apps/web/src/lib/translations-identity.test.tsx</files>
<read_first>
`apps/web/src/components/dashboard/widgets/calendar-month.ts`, Funktion
`computeFetchWindow` samt Kommentar: das Ladefenster ist die **Vereinigung** aus
42-Tage-Raster und Vorschauzeitraum, und alle vier Zwischenwerte sind auf lokale
Tagesgrenzen gerundet, damit der Cache-Schluessel des Backends ueber den
5-Minuten-Auffrischer stabil bleibt. Diese Eigenschaft darf der Umbau nicht verlieren.
</read_first>
<behavior>
- Beim Aufbau der Kachel wird die Quellenliste genau einmal geholt.
- Ein Monatswechsel holt die Quellenliste NICHT erneut.
- Ein Monatswechsel, dessen berechnetes Ladefenster mit dem zuletzt geholten
uebereinstimmt, loest KEINEN Termin-Abruf aus.
- Ein Monatswechsel mit abweichendem Ladefenster loest genau einen Termin-Abruf aus.
- Der 5-Minuten-Auffrischer holt weiterhin beides, auch wenn sich nichts geaendert
hat — sonst friert die Anzeige ein.
</behavior>
<action>
**(1) Das doppelte Ladefenster (Restposten 3a).** Der Planer hat nachgerechnet, woher die
Beobachtung des Orchestrators kommt: `computeFetchWindow` nimmt `from` als den frueheren
von Rasteranfang und heutigem Tagesbeginn und `to` als den spaeteren von Rasterende und
Vorschauhorizont. Reicht der Vorschauhorizont ueber das Rasterende hinaus, ergeben **zwei
benachbarte kuenftige Monate exakt dasselbe Fenster** — bei der Vorschau-Einstellung
90 Tage trifft das fuer Oktober und November zu, bei der Voreinstellung 30 Tage nicht.
Das erklaert, warum der Effekt nur unter bestimmten Einstellungen sichtbar ist, und es ist
der Grund, warum die Browser-Messung unten ausdruecklich auf 90 Tage gestellt werden muss.
Die Vereinigung selbst ist richtig und bleibt — die Kachel zeigt Monatsraster UND
Terminvorschau. Verschwendet wird nur der erneute Abruf eines bereits geholten Bereichs.
Merke dir deshalb in einem `ref` das zuletzt tatsaechlich geholte `from`/`to`-Paar (als
die beiden ISO-Zeichenketten, die auch an die API gehen) und ueberspringe den
Termin-Abruf, wenn das neu berechnete Paar damit uebereinstimmt. Der Sperrgriff gilt
**nur fuer den durch den Monatswechsel ausgeloesten Lauf**; der 5-Minuten-Auffrischer
muss unbedingt weiter abrufen, auch bei gleichem Fenster, sonst veraltet die Anzeige
still. Gib `loadData` dazu einen Parameter, der das Erzwingen ausdrueckt, und uebergib ihn
aus dem Intervall. Achte darauf, dass der uebersprungene Lauf den Ladezustand trotzdem
sauber beendet und die bereits geladenen Termine nicht leert.
**(2) Die Quellenliste bei jedem Monatswechsel (Restposten 3b).** `loadData` ruft heute
bei jedem Lauf zuerst `fetchSources()` auf, obwohl die Quellenliste nicht vom angezeigten
Monat abhaengt. Halte das Ergebnis in einem `ref` fest und hole die Liste nur, wenn sie
noch unbekannt ist ODER der Lauf erzwungen wurde (also beim Aufbau und beim
5-Minuten-Auffrischer). Damit bemerkt die Kachel eine neu eingerichtete Quelle weiterhin
innerhalb von fuenf Minuten — das heutige Verhalten bleibt also erhalten, nur der
Monatswechsel hoert auf, unnoetig zu fragen. Der Sonderfall "gar keine Quellen
eingerichtet" muss sich genauso verhalten wie heute.
Beides sind keine Verhaltensdefekte, sondern Verschwendung — aber sie reicht durch die API
bis zu einem echten Exchange-Server durch (siehe threat_model T-JT4-02). Aendere nichts an
`computeFetchWindow` selbst und nichts an der Tagesgrenzen-Rundung.
**(3) Tests fuer beides.** `calendar-widget.test.tsx` ist vorhanden und hat die
Abruf-Attrappen bereits eingerichtet. Ergaenze Faelle, die die Aufrufe der Attrappen
**zaehlen**: Aufbau (je 1), Monatswechsel mit abweichendem Fenster (Termine +1, Quellen
+0), Monatswechsel mit identischem Fenster (beide +0), und ein erzwungener Lauf ueber den
Zeitgeber (beide +1) — fuer den letzten Fall die Zeitgeber-Attrappe von Vitest nutzen.
Das identische Fenster stellst du her, indem du die Kachel mit der Vorschau-Einstellung
90 Tage renderst und um einen Monat weiterschaltest.
**(4) Restposten 4 — die vier `t`-Abhaengigkeiten.** Der Planer hat die Annahme
ueberprueft, auf der 260921-gof beruhte ("`t` ist in diesem Projekt bei jedem Render eine
frische Funktion"), und sie im Quelltext der eingesetzten Fassung widerlegt: `use-intl`
4.13.0 erzeugt `t` in einem `useMemo`, dessen Abhaengigkeiten ausschliesslich aus dem
Intl-Kontext stammen, und der Anbieter steht in `app/layout.tsx`, also oberhalb aller
betroffenen Komponenten. Ein Zustandswechsel in einer dieser Komponenten rendert den
Anbieter nicht neu, also behaelt `t` seine Identitaet, also bleiben die davon abhaengigen
Rueckrufe stabil, also laeuft der Effekt nicht erneut. Dazu passt die bereits erbrachte
Messung aus dem gof-Nachtrag: der Marktplatz holt `modules/catalog` in 20 Sekunden genau
einmal.
**Belege das, statt es zu behaupten**, und zwar einmal an der Wurzel statt viermal an den
Symptomen: lege `translations-identity.test.tsx` an, das eine kleine Testkomponente unter
dem echten `NextIntlClientProvider` rendert, einen Zustandswechsel in der Komponente
ausloest und festhaelt, dass `t` vor und nach dem erneuten Render dasselbe Objekt ist.
Dieser eine Test entscheidet alle vier Stellen auf einmal, weil der Schadensmechanismus
ueberall derselbe ist.
Danach entscheide nach Messlage, nicht nach Gewohnheit:
- **Bleibt die Identitaet erhalten** (erwartet), dann aendere an den vier Stellen
**nichts**. Halte in der SUMMARY fest, dass die Faustregel aus gof ("`t` gehoert in
keine Abhaengigkeitsliste") als Konvention in Ordnung bleibt, die vier verbliebenen
Stellen aber nachweislich keinen zusaetzlichen Abruf ausloesen — und korrigiere dabei
ausdruecklich die zugrunde liegende Annahme, damit sie nicht ein drittes Mal
weitergetragen wird.
- **Faellt die Identitaet doch**, dann wende an den beiden Stellen, die in einem Effekt
landen (`marketplace/page.tsx`, `admin/users/page.tsx`), die aus gof bekannte Technik an
(uebersetzten Text vor dem Rueckruf in eine Konstante ziehen und von der Konstante
abhaengen). Die beiden anderen (`calendar-settings-panel.tsx`,
`calendar-source-form.tsx`) bleiben in jedem Fall unveraendert: ihre Rueckrufe haengen
an KEINEM Effekt, sondern werden als Ereignisbehandlung weitergereicht — eine wechselnde
Identitaet kostet dort nichts. Nenne diesen strukturellen Grund in der SUMMARY.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run calendar-widget translations-identity 2>&1 | tail -10</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];console.log('gesamt',d.length,'| a11y',d.filter(x=>x.category.includes('a11y')).length,'| exhaustive',d.filter(x=>x.category==='lint/correctness/useExhaustiveDependencies').length,'| errors',d.filter(x=>x.severity==='error').length);})"</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run 2>&1 | tail -8 && pnpm type-check 2>&1 | tail -4 && pnpm lint --force 2>&1 | tail -4</automated>
<human-check>
Browser-Messung durch den Orchestrator (der Ausfuehrende hat keinen Browser). Instrument
ist das Netzwerkprotokoll des Browsers ueber Playwright, **niemals ein `fetch` aus der
Seite heraus** (siehe Merkposten "Browser-Pruefung: fetch-Falle"). Vorbereitung: Stack aus
dem aktuellen Stand bauen, anmelden, Kalender-Kachel aufs Dashboard legen und ihre
Vorschau auf **90 Tage** stellen — bei der Voreinstellung 30 Tage tritt das doppelte
Ladefenster rechnerisch gar nicht auf, die Messung waere dann nichtssagend.
Messung: Netzwerkprotokoll leeren, dann dreimal "Weiter" druecken.
Erwartung nachher:
- `calendar/sources`: genau **1** (vorher 3).
- `calendar/events`: **weniger als 3**, und unter den abgesetzten Anfragen tragen keine
zwei dasselbe `from`/`to`-Paar in der Abfragezeichenkette (vorher 3, darunter ein
identisches Paar fuer zwei benachbarte Monate).
- Gegenprobe gegen Einfrieren: rund fuenf Minuten ruhen lassen; danach muss je ein
weiterer `calendar/events`- und `calendar/sources`-Abruf erscheinen.
Datenbank und Modul-Aktivierung so hinterlassen, wie sie vorgefunden wurden; Stack danach
stoppen.
</human-check>
</verify>
<done>
`calendar-widget`-Tests belegen durch Zaehlung: Aufbau je 1 Abruf, Monatswechsel mit
abweichendem Fenster +1 Termine/+0 Quellen, Monatswechsel mit identischem Fenster +0/+0,
erzwungener Lauf +1/+1. `translations-identity.test.tsx` ist gruen und haelt die
Identitaetsfrage fuer `t` fest. `gesamt 399`, `a11y 1`, `exhaustive 0`, `errors 0`.
`apps/web` mindestens 69 Dateien / 484 Tests, `pnpm type-check` 4/4, `pnpm lint --force`
5/5. Fuer jede der vier `t`-Stellen steht in der SUMMARY, ob sie geaendert wurde und
warum beziehungsweise warum nicht.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Mensch -> Oberflaeche | Bedienung per Tastatur bzw. Vorlesehilfe statt per Maus — die eigentliche Grenze dieses Vorgangs |
| Notizinhalt -> Markdown-Darstellung | Vom Nutzer eingegebener Text wird gerendert; `rehypeSanitize` ist die Schranke |
| Web -> API -> Exchange | Jeder Termin-Abruf der Kachel erreicht ueber die eigene API einen fremden Exchange-Server |
| Katalogtext -> Dateisystem der Gegenstelle | Ein uebersetzter Name landet als Dateiname auf einer Windows-Freigabe |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-JT4-01 | Denial of Service | alle 21 klickgebundenen Fundstellen | medium | mitigate | Bedienung, die es nur mit der Maus gibt, schliesst Tastatur- und Vorlesehilfe-Nutzung aus. Jede echte Bedienstelle wird ein `<button>`; belegt durch Komponententests, die eine Tastaturbetaetigung ausloesen, nicht durch Auszeichnungs-Behauptungen. |
| T-JT4-02 | Denial of Service | `calendar-widget.tsx` -> API -> Exchange | medium | mitigate | Der Sperrgriff darf nur den Monatswechsel sperren, nie den 5-Minuten-Auffrischer, sonst friert die Anzeige ein (Verfuegbarkeitsschaden statt Ersparnis). Vier zaehlende Testfaelle decken beide Richtungen ab; die Tagesgrenzen-Rundung von `computeFetchWindow` bleibt unangetastet, damit der Backend-Cache-Schluessel stabil bleibt. |
| T-JT4-03 | Tampering | `note-widget.tsx` -> `previewOptions` -> `rehypeSanitize` | high | mitigate | Der Umbau des Kaestchen-Handlers fasst das Optionsobjekt neu; faellt dabei `rehypePlugins: [[rehypeSanitize]]` heraus, kehrt die XSS-Luecke T-IEX-01 zurueck. Der Eintrag muss woertlich erhalten bleiben; ein Testfall muss belegen, dass eingebettete Auszeichnung im Vorschaumodus weiterhin entschaerft wird. |
| T-JT4-04 | Tampering | `calendar-settings-panel.tsx`, Loeschdialog | high | mitigate | Der Hintergrund des Loeschdialogs wird zu einer echten, fokussierbaren Schaltflaeche und rueckt damit in die Tab-Reihenfolge. Sie MUSS abbrechen und darf unter keinen Umstaenden loeschen; ihre Beschriftung benennt das Abbrechen. Ein Testfall belegt, dass nach diesem Weg die Quelle noch existiert. |
| T-JT4-05 | Tampering | `zip-filename.ts` -> Windows-Freigabe | low | mitigate | Ein uebersetzter Dateiname kann Zeichen tragen, die Windows verbietet. Eine fuer sich getestete Schutzfunktion schneidet jeden Namen auf das Zulaessige zurueck und faellt notfalls auf einen sicheren Ersatznamen zurueck — die Sicherheit haengt damit nicht an der Wortwahl einer kuenftigen Uebersetzung. |
| T-JT4-06 | Information Disclosure | `widget-catalog-modal.tsx` | low | accept | Die Hintergrundflaeche verliert ihr `aria-hidden`, weil ein fokussierbares Element nicht vor der Vorlesehilfe verborgen sein darf. Sie erhaelt dadurch eine angesagte Beschriftung — gewollt, kein Informationsabfluss: der Dialoginhalt war ohnehin sichtbar. |
| T-JT4-SC | Tampering | npm/pip/cargo-Installationen | n/a | accept | Dieser Vorgang installiert kein Paket und hebt keine Version an (D-06). Es gibt keine Installationsaufgabe, daher greift das Paket-Echtheitstor nicht; `pnpm-lock.yaml` und alle `package.json` duerfen im Diff nicht vorkommen. |
</threat_model>
<verification>
Abschliessend, nach allen drei Aufgaben:
```bash
cd /home/vicolab/projects/tessera-ctl
# 1) Befundstand nach Regel
npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];const a=d.filter(x=>x.category.includes('a11y'));a.forEach(x=>console.log(' a11y',x.category.replace('lint/a11y/',''),x.location.path+':'+x.location.start.line));console.log('gesamt',d.length,'| a11y',a.length,'| errors',d.filter(x=>x.severity==='error').length);})"
# erwartet: gesamt 399 | a11y 1 | errors 0
# die eine a11y-Zeile nennt calculator-widget.tsx
# 2) Keine Unterdrueckung als Abkuerzung eingeschleust
git diff --unified=0 $(git rev-parse HEAD) -- . | grep -c '^+.*biome-ignore' || true
# erwartet: 0 (dieser Vorgang fuegt keine einzige neue Unterdrueckung hinzu)
# 3) Kataloge im Gleichstand
node -e "const f=o=>Object.entries(o).flatMap(([k,v])=>v&&typeof v==='object'?f(v).map(x=>k+'.'+x):[k]);const de=f(require('./apps/web/src/messages/de.json')),en=f(require('./apps/web/src/messages/en.json'));const A=new Set(de),B=new Set(en);console.log('de',de.length,'en',en.length,'nurDe',de.filter(k=>!B.has(k)).join(',')||'-','nurEn',en.filter(k=>!A.has(k)).join(',')||'-');"
# 4) Tore und Testbestand
pnpm -C apps/web exec vitest run 2>&1 | tail -6 # >= 69 Dateien, >= 484 Tests
pnpm -C apps/api exec vitest run 2>&1 | tail -6 # >= 72 Dateien, >= 1143 Tests
pnpm type-check 2>&1 | tail -4 # 4/4
pnpm lint --force 2>&1 | tail -4 # 5/5, 0 error
# 5) Kein Paket, keine Version, kein Fremdumbruch
git diff --stat $(git rev-parse HEAD) -- pnpm-lock.yaml '**/package.json'
# erwartet: leer
```
Die Zaehlungen stammen ausschliesslich aus dem Feld `category` der Biome-JSON-Ausgabe,
niemals aus einem Textgriff in den Quelltext — ein Kommentar, der den Namen einer Regel
nennt, wuerde eine Textzaehlung sonst selbst verfaelschen.
**Schritt des Orchestrators (nicht des Ausfuehrenden):** die Browser-Zaehlung aus dem
`human-check` in Aufgabe 3. Der Ausfuehrende hat keinen Browser und darf dieses Ergebnis
nicht behaupten.
</verification>
<success_criteria>
- Biome: 399 Befunde gesamt, 0 der Stufe error, genau 1 a11y-Befund
(`calculator-widget.tsx`, begruendet stehengelassen), `useSemanticElements` unveraendert
bei 0, `noUselessSwitchCase` bei 0.
- Keine einzige neue `biome-ignore`-Zeile im gesamten Diff dieses Vorgangs.
- 29 der 30 a11y-Fundstellen behoben, jede mit protokolliertem Weg; fuer die eine
stehengelassene ist begruendet, warum sie bleibt und warum die Umgehung per
`addEventListener` ausdruecklich nicht gewaehlt wurde.
- Tastaturbedienung ist durch Tests mit echten Tastaturereignissen belegt, nicht durch
Auszeichnungsbehauptungen.
- Kalender-Kachel: Quellenliste 1 statt 3 pro drei Monatswechseln, kein Termin-Abruf mit
wiederholtem `from`/`to`-Paar, 5-Minuten-Auffrischer nachweislich unberuehrt.
- ZIP-Name uebersetzt UND durch eine getestete Schutzfunktion auf Windows-Freigaben
gueltig.
- Die vier `t`-Abhaengigkeiten sind durch Messung entschieden, nicht durch Gewohnheit; die
zugrunde liegende Annahme aus gof ist ausdruecklich korrigiert oder bestaetigt.
- `apps/web` >= 69 Dateien / >= 484 Tests, `apps/api` >= 72 / >= 1143, `pnpm type-check`
4/4, `pnpm lint --force` 5/5.
- Keine neue Abhaengigkeit, keine Versionsanhebung, kein repo-weiter Umbruch (D-06).
</success_criteria>
<commit_hygiene>
Ein Commit je Teilumbau, nicht ein Sammelcommit je Aufgabe — das ist der Grund, warum die
Umbauten oben einzeln nummeriert sind.
**Fallstrick, der heute viermal zugeschlagen hat:** Das Write-Werkzeug wandelt Folgen der
Form `\uXXXX` im Inhalt still in das tatsaechliche Zeichen um. So sind rohe Steuerzeichen
in einen Plan, in Commit-Nachrichten und in eine SUMMARY geraten, woraufhin `git commit`
die Annahme verweigert hat. Erzeuge solche Folgen ueber `python3` mit `chr(92)` und pruefe
die Rohbytes danach nach, bevor du committest.
</commit_hygiene>
<output>
Erstelle `.planning/quick/260921-jt4-barrierefreiheit-mit-bedienentscheidunge/260921-jt4-SUMMARY.md`,
wenn alle drei Aufgaben abgeschlossen sind. Die SUMMARY muss enthalten:
1. Eine Tabelle aller 30 a11y-Fundstellen mit gewaehltem Weg (echter Button /
role+Tastatur / Fokus-auf-Oeffnen / Rolle korrigiert / stehengelassen) und je einer
Zeile Begruendung, wo der gerade Weg nicht ging.
2. Je einen Abschnitt zu den vier Restposten mit dem Ergebnis — einschliesslich der
ausdruecklichen Aussage, ob der alte Einwand gegen den uebersetzten ZIP-Namen noch
traegt, und ob die Annahme "`t` ist bei jedem Render frisch" bestaetigt oder korrigiert
wurde.
3. Die ehrliche Einordnung der vier `aria-hidden`-Ergaenzungen an den `<img>`-Elementen:
richtige Auszeichnung, aber kein Gewinn fuer einen Menschen.
4. Die Vorher/Nachher-Zahlen aus der Biome-JSON-Ausgabe, nach Regel aufgeschluesselt.
</output>
@@ -0,0 +1,275 @@
---
phase: quick-260921-jt4
plan: 01
subsystem: ui
tags: [a11y, biome, next-intl, react, calendar, i18n]
requires:
- phase: quick-260921-bi2
provides: "155 a11y-Fixes ueber sechs Regeln; die fuenf hier bearbeiteten Regeln (30 Befunde) bewusst zurueckgestellt (D-05)"
- phase: quick-260921-gof
provides: "21 useExhaustiveDependencies-Befunde beurteilt; die Faustregel 't gehoert in keine Abhaengigkeitsliste' als unbestaetigte Annahme hinterlassen"
provides:
- "30 zurueckgestellte a11y-Befunde auf 1 gesenkt (429 -> 399 gesamt), Weg pro Fundstelle protokolliert"
- "13 Oberflaechendateien mit echten Schaltflaechen statt klickbarer Bereiche statt Mausonly-Bedienung"
- "Kalender-Widget ruft Quellenliste und Termine nicht mehr blind bei jedem Monatswechsel neu ab"
- "t aus useTranslations ist am echten NextIntlClientProvider als identitaetsstabil ueber lokale Zustandswechsel nachgewiesen (widerlegt die gof-Annahme 'bei jedem Render frisch')"
- "Uebersetzter, windows-sicherer ZIP-Dateiname im Zertifikat-Aufteiler (zip-filename.ts)"
affects: [apps/web-a11y, dashboard-calendar-widget, i18n-catalogs]
actuals:
tokens: 21513
tasks: 3
commits: 12
tech-stack:
added: []
patterns:
- "Deckende Geschwister-Schaltflaeche statt role=button+tabIndex+onKeyDown: verschachtelte <button>-Elemente vermieden (MarketplaceCard-Muster, auch fuer Dialoghintergruende), ohne useSemanticElements neu auszuloesen."
- "role=toolbar statt role=group/region fuer beschriftete Schaltflaechenreihen ohne semantisches HTML-Aequivalent — role=group/region loesen useSemanticElements neu aus, role=toolbar nicht (an Biome 2.5.0 mit Projektkonfiguration gemessen)."
- "Ref-basierte Sperrgriffe gegen verschwendete Effekte: hasSourcesRef/lastFetchWindowRef ausserhalb des Render-Zustands, mit einem expliziten force-Parameter fuer den Fall, der den Sperrgriff bewusst umgehen muss (5-Minuten-Auffrischer)."
- "t-Identitaet aus useTranslations gegen den echten NextIntlClientProvider messen (nicht behaupten) — Testkomponente unter dem echten Provider, Zustandswechsel ausloesen, per toBe (Referenzgleichheit) pruefen."
- "Regex mit \\x00-\\x1F-Bereich vermeiden (lint/suspicious/noControlCharactersInRegex) — Steuerzeichen-Filterung zeichenweise statt per Regex, wenn das Filtern von Steuerzeichen der Zweck ist."
key-files:
created:
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
- apps/web/src/components/settings/calendar-settings-panel.test.tsx
- apps/web/src/lib/translations-identity.test.tsx
modified:
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
- apps/web/src/components/settings/calendar-settings-panel.tsx
- apps/web/src/components/dashboard/widgets/note-widget.tsx
- apps/web/src/components/dashboard/widgets/note-task-list.tsx
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
- apps/web/src/components/dashboard/widgets/calculator-widget.tsx
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
- apps/web/src/components/layout/header.tsx
- apps/web/src/components/settings/account-settings-form.tsx
- "apps/web/src/app/(auth)/login/page.tsx"
- "apps/web/src/app/(auth)/reset-password/page.tsx"
- "apps/web/src/app/(auth)/reset-password/[token]/page.tsx"
- "apps/web/src/app/(portal)/change-password/page.tsx"
- "apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx"
- apps/api/src/tenders/tender-normalizer.service.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
key-decisions:
- "D-01-Rueckfall (role=button+tabIndex+onKeyDown) an keiner der sechs Umbaustellen benutzt — der Planer hatte ihn empirisch geprueft und drei Befunde gegen einen neuen useSemanticElements-Befund getauscht; D-07 verbietet Wachstum anderswo. Deckende Geschwister-Schaltflaeche stattdessen bei MarketplaceCard, widget-catalog-modal und calendar-settings-panel."
- "calendar-widget Tageszelle wird NUR bei hasEvents zu einem <button> — 42 neue Tab-Stopps waeren eine Verschlechterung, nicht Tage ohne Termine."
- "Vier autoFocus-Stellen sind alle Seitenformulare, kein Dialog: Attribut entfernt (D-02), der ref+Effekt-Ersatzweg kam an keiner Stelle zum Zug."
- "t-Identitaet gemessen statt der gof-Annahme ein drittes Mal weitergetragen: bleibt bei einem echten NextIntlClientProvider-Test erhalten -> die vier verbliebenen t-Abhaengigkeitsstellen bleiben unveraendert."
- "ZIP-Dateiname darf sich nicht auf einen zufaellig harmlosen Uebersetzungstext verlassen — eigene, fuer sich getestete Schutzfunktion (zip-filename.ts) statt Vertrauen in den Katalogwert."
- "noUselessSwitchCase-Fallmarke in tender-normalizer.service.ts entfernt, Kommentar erweitert statt der Marke selbst eine Bedeutung zuzuschreiben, die die Regel nicht sehen kann — kein Verhaltenswechsel."
patterns-established:
- "Vollstaendige a11y-Regelmenge (Ziel- UND zurueckgestellte Regeln) nach jedem Teilumbau neu messen, nicht nur die Zielregel — ein a11y-Fix kann eine ANDERE Regel neu ausloesen, wenn ein Element seine interaktive Klassifikation verliert."
requirements-completed: [D-01, D-02, D-03, D-04, D-05, D-06, D-07]
duration: ~2h (eine Sitzung)
completed: 2026-09-21
status: complete
---
# Quick-Vorgang 260921-jt4: 30 a11y-Befunde mit Bedienentscheidungen abgearbeitet Summary
**30 zurueckgestellte Barrierefreiheits-Befunde auf 1 gesenkt (429 -> 399 gesamt, 0 Fehler) — 13 Oberflaechendateien mit echten Schaltflaechen statt klickbarer Bereiche, das Kalender-Widget ruft Quellenliste/Termine nicht mehr blind bei jedem Monatswechsel neu ab, und die gof-Annahme "t ist bei jedem Render frisch" ist am echten `NextIntlClientProvider` widerlegt statt ein drittes Mal weitergetragen.**
## Performance
- **Tasks:** 3 von 3 (PLAN.md)
- **Commits:** 12 (siehe Task Commits)
- **Dateien geaendert:** 28 (23 Quell-/Testdateien + 2 i18n-Kataloge + PLAN.md/keine weiteren)
- **apps/web Tests:** 66 -> 73 Dateien, 462 -> 529 Tests (Netto-Zuwachs 7 Dateien / 67 Tests)
- **apps/api Tests:** unveraendert 72 Dateien / 1143 Tests (nur ein Kommentar/eine Fallmarke in tender-normalizer.service.ts geaendert, kein neuer Test noetig — die 18 vorhandenen Tests belegen weiterhin dasselbe Verhalten)
## Accomplishments
- 30 zurueckgestellte a11y-Befunde auf genau 1 gesenkt (der bewusst stehengelassene Taschenrechner-Tastaturhandler, D-07) — Biome-JSON `429 -> 399`, `errors 0` durchgehend
- Fuenf Struktur-Umbauten (MarketplaceCard, widget-catalog-modal, calendar-settings-panel Loeschdialog, note-widget Kaestchen, calendar-widget Tageszelle) auf echte `<button>`-Elemente, jeder Umbau mit einem Test belegt, der eine ECHTE Tastaturbetaetigung ausloest (Enter/Leertaste auf einem fokussierten Element), nicht nur eine Klick-Attrappe
- Kalender-Widget: Quellenliste und Termin-Ladefenster werden nicht mehr blind bei jedem Monatswechsel neu geholt — vier zaehlende Tests belegen Aufbau/abweichendes Fenster/identisches Fenster/erzwungener Auffrischer
- `translations-identity.test.tsx`: `t` aus `useTranslations` ist am echten `NextIntlClientProvider` als identitaetsstabil ueber einen lokalen Zustandswechsel nachgewiesen — widerlegt die 260921-gof-Annahme "t ist bei jedem Render frisch"
- Windows-sicherer, uebersetzter ZIP-Dateiname im Zertifikat-Aufteiler ueber eine neue, fuer sich getestete Schutzfunktion (`zip-filename.ts`), unabhaengig davon, ob der aktuelle Katalogwert zufaellig harmlos ist
- Zwei Restposten aus 260921-bi2/gof geschlossen: ueberfluessige `case`-Marke in `tender-normalizer.service.ts` entfernt; keine neue Unterdrueckung (`biome-ignore`) im gesamten Diff
## Task Commits
Aufgabe 1 (Aus klickbaren Bereichen echte Schaltflaechen machen, 5 Teilumbauten, jeder einzeln committet):
1. **MarketplaceCard.tsx — Karte** - `a8531d4` (fix)
2. **widget-catalog-modal.tsx — Hintergrund** - `3d0bc0b` (fix)
3. **calendar-settings-panel.tsx — Loeschdialog-Hintergrund** - `0c89c13` (fix)
4. **note-widget.tsx + note-task-list.tsx — Aufgabenkaestchen** - `b601141` (fix)
5. **calendar-widget.tsx — Tageszelle** - `e651c24` (fix)
Aufgabe 2 (Rollen, Beschriftungen, Autofokus, zwei Restposten, jeder Teilumbau einzeln committet):
6. **autoFocus von vier Seitenformularen entfernt (D-02)** - `9aa87bd` (fix)
7. **calendar-settings-panel: drei Statussymbole role="img" (D-03)** - `b406a9c` (fix)
8. **calculator-widget/favorites-widget: zwei Schaltflaechenreihen role="toolbar" (D-03)** - `f7b5df4` (fix)
9. **vier `<img onError>` mit aria-hidden (D-03)** - `69fe706` (fix)
10. **tender-normalizer.service.ts: ueberfluessige case-Marke entfernt (Restposten 2)** - `5a03b75` (refactor)
11. **ZIP-Name im Zertifikat-Aufteiler mit Windows-Schutzfunktion (Restposten 1)** - `6c10c9b` (feat)
Aufgabe 3 (Verschwendete Kalender-Abrufe + t-Identitaet, ein Commit):
12. **Kalender-Ladefenster/Quellenliste entdoppelt, t-Identitaet gemessen** - `f471b78` (fix)
_Hinweis: keine separate Plan-Metadaten-Commit — diese SUMMARY und STATE.md/ROADMAP.md werden vom Orchestrator committet._
## Files Created/Modified
- `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx` - Kartenklick als deckende Geschwister-Schaltflaeche
- `apps/web/src/components/dashboard/widget-catalog-modal.tsx` - Hintergrund als benannte Schaltflaeche, totes stopPropagation entfernt
- `apps/web/src/components/settings/calendar-settings-panel.tsx` - Loeschdialog-Hintergrund als Abbrechen-Schaltflaeche; drei Statussymbole role="img"
- `apps/web/src/components/dashboard/widgets/note-widget.tsx` + `note-task-list.tsx` - Aufgabenkaestchen bedient sich selbst (onChange statt readOnly+delegiertem Klick)
- `apps/web/src/components/dashboard/widgets/calendar-widget.tsx` - Tageszelle als Schaltflaeche bei Terminen; Quellenliste/Ladefenster-Sperrgriffe
- `apps/web/src/components/dashboard/widgets/calculator-widget.tsx` - Speicherzeile role="toolbar", drei Texte in den Katalog gezogen
- `apps/web/src/components/dashboard/widgets/favorites-widget.tsx` - Ansichtsumschalter role="toolbar" mit korrigierter Beschriftung; zwei `<img>` aria-hidden
- `apps/web/src/components/layout/header.tsx` + `apps/web/src/components/settings/account-settings-form.tsx` - Avatar-`<img onError>` aria-hidden
- `apps/web/src/app/(auth)/login/page.tsx`, `reset-password/page.tsx`, `reset-password/[token]/page.tsx`, `apps/web/src/app/(portal)/change-password/page.tsx` - autoFocus entfernt
- `apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts` (neu) + `.test.ts` (neu) - Windows-sichere ZIP-Namens-Schutzfunktion
- `apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx` - uebersetzter, sanitierter ZIP-Name
- `apps/api/src/tenders/tender-normalizer.service.ts` - ueberfluessige case-Marke entfernt, Kommentar erweitert
- `apps/web/src/lib/translations-identity.test.tsx` (neu) - t-Identitaet am echten Provider gemessen
- `apps/web/src/messages/de.json` / `en.json` - 20 neue Schluessel, beide Kataloge weiterhin gleich lang (912/912)
## Decisions Made
Siehe `key-decisions` im Frontmatter. Zusammengefasst: der D-01-Rueckfallweg wurde an keiner Stelle benutzt (empirisch als regelvermehrend erkannt); die Kalender-Tageszelle wird nur bei Terminen zu einem Tab-Stopp; alle vier `autoFocus`-Stellen sind Seiten, kein Dialog, deshalb Entfernen statt Ersatz; die t-Identitaetsfrage wurde gemessen statt behauptet und bestaetigt die vier verbliebenen Stellen als unveraendert richtig; der ZIP-Name haengt an einer eigenen Schutzfunktion, nicht am Zufall des Uebersetzungstexts.
## Die 30 a11y-Fundstellen — gewaehlter Weg je Fundstelle
| # | Fundstelle | Regel | Gewaehlter Weg | Begruendung (bei Rueckfall/Stehenlassen) |
|---|---|---|---|---|
| 1 | MarketplaceCard.tsx (Karte) | noNoninteractiveElementInteractions | echter Button (deckendes Geschwister) | Karte enthaelt bereits eine Schaltflaeche im Fuss — unmittelbare `<button>`-Umwandlung waere eine Verschachtelung (Falle wie DropZone.tsx in 260921-bi2) |
| 2 | MarketplaceCard.tsx (Karte) | noStaticElementInteractions | echter Button (deckendes Geschwister) | s. #1 |
| 3 | MarketplaceCard.tsx (Karte) | useKeyWithClickEvents | echter Button (deckendes Geschwister) | s. #1 — der Button traegt den Tastaturweg von selbst |
| 4 | widget-catalog-modal.tsx (Hintergrund) | noNoninteractiveElementInteractions | echter Button | vorher rein optische Flaeche, jetzt fokussierbar und benannt |
| 5 | widget-catalog-modal.tsx (Hintergrund) | noStaticElementInteractions | echter Button | s. #4 |
| 6 | widget-catalog-modal.tsx (Hintergrund) | useKeyWithClickEvents | echter Button | s. #4 |
| 7 | widget-catalog-modal.tsx (Dialogflaeche) | noNoninteractiveElementInteractions | Handler entfaellt ersatzlos | `stopPropagation` wird toter Code, sobald der Hintergrund Geschwister statt Vorfahr der Dialogflaeche ist |
| 8 | widget-catalog-modal.tsx (Dialogflaeche) | useKeyWithClickEvents | Handler entfaellt ersatzlos | s. #7 |
| 9 | calendar-settings-panel.tsx (Loeschdialog-Hintergrund) | noNoninteractiveElementInteractions | echter Button | muss abbrechen, darf nie loeschen (T-JT4-04); Beschriftung nennt ausdruecklich das Abbrechen |
| 10 | calendar-settings-panel.tsx (Loeschdialog-Hintergrund) | noStaticElementInteractions | echter Button | s. #9 |
| 11 | calendar-settings-panel.tsx (Loeschdialog-Hintergrund) | useKeyWithClickEvents | echter Button | s. #9 |
| 12 | note-widget.tsx (Vorschau-Kaestchen) | noNoninteractiveElementInteractions | Kaestchen uebernimmt seinen Handler selbst | `readOnly` entfernt, echtes `onChange` am Kaestchen statt delegiertem Klick am Behaelter |
| 13 | note-widget.tsx (Vorschau-Kaestchen) | noStaticElementInteractions | Kaestchen uebernimmt seinen Handler selbst | s. #12 |
| 14 | note-widget.tsx (Vorschau-Kaestchen) | useKeyWithClickEvents | Kaestchen uebernimmt seinen Handler selbst | s. #12 — ein `<input type="checkbox">` bedient sich per Definition mit der Tastatur |
| 15 | calendar-widget.tsx (Tageszelle) | noNoninteractiveElementInteractions | echter Button + Fokus-Handler, NUR bei Terminen | 42 neue Tab-Stopps waeren eine Verschlechterung; nur Tage mit Terminen werden zu Tab-Stopps |
| 16 | calendar-widget.tsx (Tageszelle) | noStaticElementInteractions | echter Button + Fokus-Handler, NUR bei Terminen | s. #15 |
| 17 | login/page.tsx | noAutofocus | Attribut entfernt | Seite, kein Dialog — D-02 verlangt Entfernen, nicht ref+Effekt |
| 18 | reset-password/[token]/page.tsx | noAutofocus | Attribut entfernt | Seite, kein Dialog |
| 19 | reset-password/page.tsx | noAutofocus | Attribut entfernt | Seite, kein Dialog |
| 20 | change-password/page.tsx | noAutofocus | Attribut entfernt | Seite, kein Dialog — Nebennutzen: Fokus faellt jetzt nicht mehr am Hinweis zum erzwungenen Wechsel vorbei |
| 21 | calendar-settings-panel.tsx (Sync-Fehler-Symbol) | useAriaPropsSupportedByRole | `role="img"` + Katalogschluessel | Beschriftung ist der einzige Text des Symbols (svg bereits aria-hidden); Rolle `generic` verwarf sie bisher stillschweigend |
| 22 | calendar-settings-panel.tsx (Verbindung-OK-Symbol) | useAriaPropsSupportedByRole | `role="img"` + vorhandener Schluessel `connectionSuccess` | s. #21, Text passte woertlich, kein neuer Schluessel noetig |
| 23 | calendar-settings-panel.tsx (Verbindung-fehlgeschlagen-Symbol) | useAriaPropsSupportedByRole | `role="img"` + Katalogschluessel | s. #21 |
| 24 | calculator-widget.tsx (Speicherzeile) | useAriaPropsSupportedByRole | `role="toolbar"` + Katalogschluessel | `role="group"`/`"region"` loesen `useSemanticElements` neu aus (gemessen); `role="toolbar"` nicht |
| 25 | favorites-widget.tsx (Ansichtsumschalter) | useAriaPropsSupportedByRole | `role="toolbar"` + KORRIGIERTE Beschriftung | alte Beschriftung ("Favoriten") war sachlich falsch fuer einen Listen/Kachel-Umschalter — D-03 erlaubt hier das Streichen, eine richtige Beschriftung ist trotzdem besser als keine |
| 26 | header.tsx (Avatar `<img onError>`) | noNoninteractiveElementInteractions | `aria-hidden="true"` | `onError` ist ein Ladefehler, keine Bedienung; `alt=""` bereits vorhanden — richtige Auszeichnung, aber kein Gewinn fuer einen Menschen |
| 27 | account-settings-form.tsx (Avatar `<img onError>`) | noNoninteractiveElementInteractions | `aria-hidden="true"` | s. #26 |
| 28 | favorites-widget.tsx (Symbol-Proxy `<img onError>`) | noNoninteractiveElementInteractions | `aria-hidden="true"` | s. #26 |
| 29 | favorites-widget.tsx (Symbol-Direkt `<img onError>`) | noNoninteractiveElementInteractions | `aria-hidden="true"` | s. #26 |
| 30 | calculator-widget.tsx (Rahmen, `role="application"` + `onKeyDown`) | noNoninteractiveElementInteractions | **bleibt stehen, bleibt gezaehlt** | Der Handler FUEGT einen Tastaturweg hinzu (Ziffern erreichen die Logik, sobald irgendeine Taste fokussiert ist) — das Gegenteil des Schadens, den die Regel beschreibt. Alle drei Rollen-Alternativen gemessen, aendern nichts. `addEventListener` in einem Effekt ausdruecklich NICHT gewaehlt — das waere eine geschoente Zahl ohne Gegenwert (D-07). |
## Die vier Restposten
1. **ZIP-Name im Zertifikat-Aufteiler.** `downloadAllAsZip` liegt ausserhalb der Komponente und konnte den Uebersetzungs-Hook nicht aufrufen — der Name kommt jetzt als Parameter herein (`t('actions.zipFilename')`). Der 260921-bi2-Einwand ("ein uebersetzter Name koenne Umlaute auf eine Windows-Freigabe tragen") **trifft fuer den aktuellen deutschen Katalogwert nicht zu** (kein Umlaut). Die Sicherheit haengt darauf aber NICHT: `zip-filename.ts` schneidet jeden Namen unabhaengig vom Katalogwert auf das fuer Windows Zulaessige zurueck (verbotene Zeichen, Steuerzeichen, Nicht-ASCII, abschliessende Punkte/Leerzeichen, reservierte Geraetenamen, Ersatzname bei leerem Ergebnis, Endung sichergestellt) — elf Testfaelle belegen das einzeln.
2. **`noUselessSwitchCase` in `tender-normalizer.service.ts`.** Die Fallmarke `case 'doe-opendata':` unmittelbar ueber `default:` war ueberfluessig — entfernt, der erweiterte Kommentar traegt jetzt beide Aussagen (DÖE-Quelle landet hier UND kuenftige additive `SourceType`-Mitglieder sollen ebenfalls hier landen). Kein Verhaltenswechsel, belegt durch die 18 vorhandenen Tests.
3. **Doppeltes Kalender-Ladefenster.** Trat nur bei der Vorschau-Einstellung 90 Tage auf (nicht bei der Voreinstellung 30). Der eingefrorene Testzeitpunkt (15.07.2026) zeigt die Kollision zwischen **August und September** — eine Abweichung vom im PLAN genannten Monatspaar Oktober/November (derselbe Mechanismus, ein anderes "heute"; gleiche Art Abweichung wie bereits in Test 8 fuer den 30-Tage-Fall dokumentiert). `lastFetchWindowRef` ueberspringt den Termin-Abruf bei uebereinstimmendem Fenster; der 5-Minuten-Auffrischer umgeht den Sperrgriff immer.
4. **Die vier `t`-Abhaengigkeiten.** Die gof-Annahme "`t` ist bei jedem Render frisch" ist **ausdruecklich WIDERLEGT**: `translations-identity.test.tsx` beweist per Referenzgleichheit (nicht nur `toEqual`) an einem echten `NextIntlClientProvider`, dass `t` bei einem lokalen Zustandswechsel dasselbe Funktionsobjekt bleibt. Bestaetigt durch den `use-intl@4.13.0`-Quelltext: `translate` entsteht in einem `useMemo`, dessen Abhaengigkeiten ausschliesslich aus dem root-staendigen Intl-Kontext stammen. Die vier verbliebenen Stellen (`marketplace/page.tsx`, `admin/users/page.tsx`, `calendar-settings-panel.tsx`, `calendar-source-form.tsx`) bleiben deshalb **unveraendert** — zwei davon haengen ohnehin an keinem Effekt (Ereignisbehandlung), die beiden anderen wurden in 260921-gof bereits korrekt behandelt. Die Faustregel "`t` gehoert in keine Abhaengigkeitsliste" bleibt als Konvention in Ordnung, die zugrunde liegende Begruendung ist jetzt korrigiert.
## Ehrliche Einordnung: die vier `aria-hidden`-Ergaenzungen
Alle vier `<img onError>`-Stellen (header.tsx, account-settings-form.tsx, favorites-widget.tsx zweimal) tragen bereits `alt=""` — sind also schon aus dem Zugaenglichkeitsbaum genommen. `aria-hidden="true"` sagt dasselbe nur ausdruecklich. **Es ist richtige Auszeichnung, aber es verbessert fuer keinen Menschen etwas** — der Befund verschwindet, weil die Regel ein verborgenes Element nicht mehr betrachtet. `onError` ist ein Ladefehler, keine Bedienung: hier gab es nie einen Tastaturweg zu schaffen. Keine Unterdrueckung, kein `biome-ignore`.
## Vorher/Nachher (Biome-JSON, nach Regel)
| Zeitpunkt | gesamt | a11y (5 zurueckgestellte Regeln) | errors |
|---|---|---|---|
| Vor diesem Vorgang (Baseline, vom Planer gemessen) | 429 | 30 | 0 |
| Nach Aufgabe 1 (Struktur-Umbauten) | 413 | 14 (5 = 4 `<img onError>` + Taschenrechner-Rahmen der Klick-Regeln; `useAriaPropsSupportedByRole` noch 5, `noAutofocus` noch 4) | 0 |
| Nach Aufgabe 2 (Rollen/Beschriftungen/Autofokus/Restposten 1+2) | **399** | **1** | 0 |
| Nach Aufgabe 3 (Kalender-Abrufe, t-Identitaet — keine a11y-Aenderung) | **399** | **1** | 0 |
Endstand deckt sich exakt mit dem Zielwert des Plans: `gesamt 399`, `a11y 1` (`calculator-widget.tsx`, `noNoninteractiveElementInteractions`, bewusst stehengelassen), `switch 0`, `errors 0`. Keine neue `biome-ignore`-Zeile im gesamten Diff (`git diff --unified=0 7557c9a.. | grep -c biome-ignore` → 0). Beide Kataloge nach wie vor gleich lang (912/912), keine Datei nur in einem Katalog.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug, waehrend der Arbeit selbst erkannt und vor dem Commit korrigiert] Regex mit Steuerzeichen-Bereich loeste eine neue Lint-Regel aus**
- **Found during:** Aufgabe 2, Umsetzung der ZIP-Namens-Schutzfunktion
- **Issue:** Die urspruengliche Fassung von `zip-filename.ts` nutzte `/[<>:"/\\|?*\x00-\x1f]/g` — ein `\x00-\x1F`-Bereich in einem Regex-Literal, den Biome (`lint/suspicious/noControlCharactersInRegex`) beanstandet, auch wenn die Absicht (Steuerzeichen ausfiltern) hier keine ist. Der Test-Datei-Umbau selbst zog dabei ausserdem `lint/style/useTemplate` (info) und `suppressions/unused` (ein ueberfluessiger `biome-ignore`-Kommentar) nach sich.
- **Fix:** Steuerzeichen-/Sonderzeichen-Filterung auf zeichenweise Iteration umgestellt (kein Regex fuer diesen Teil); String-Verkettung im Test durch ein Template-Literal ersetzt; den ueberfluessigen `biome-ignore` entfernt.
- **Files modified:** `apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts`, `apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts`
- **Verification:** Volle Regelmessung danach zeigt `gesamt 399` (Zielwert exakt getroffen), 0 Fehler, 0 unbenutzte Unterdrueckungen.
- **Committed in:** `6c10c9b` (im selben Commit korrigiert, nie mit dem Fund committet)
**2. [Rule 1 - Bug, vor dem Commit korrigiert] Write-Werkzeug wandelte `\u0007` still in ein rohes Steuerzeichen um**
- **Found during:** Aufgabe 2, Anlegen von `zip-filename.test.ts`
- **Issue:** Der bekannte Fallstrick aus dem Plan (`\uXXXX`-Folgen werden vom Write-Werkzeug in das tatsaechliche Zeichen umgewandelt) trat ein weiteres Mal ein — ein rohes BEL-Steuerzeichen (0x07) landete zwischen zwei Buchstaben im Testtext, sichtbar erst per `cat -A`.
- **Fix:** Die betroffene Zeile per `python3`-Skript (Bytesuche/-ersatz, `chr(7)`) auf `String.fromCharCode(7)` innerhalb eines Template-Literals umgestellt — erzeugt das Zeichen zur Laufzeit, keine `\u`-Folge mehr im Dateiinhalt. Alle Dateien dieses Vorgangs danach auf verbliebene rohe Steuerbytes durchsucht (0 Treffer).
- **Files modified:** `apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts`
- **Verification:** `cat -A` zeigt keine Steuerbyte-Artefakte mehr; `git commit` nahm die Datei ohne Beanstandung an.
- **Committed in:** `6c10c9b` (vor dem Commit korrigiert)
**3. [Rule 3 - Blocking, vor dem Commit korrigiert] userEvent-Tastaturtest haengt mit Fake-Timern**
- **Found during:** Aufgabe 1, Testerweiterung fuer das Notiz-Kaestchen
- **Issue:** Ein Test mit `userEvent.keyboard(' ')` haengte (5s Timeout) unter den in `beforeEach` global gesetzten Fake-Timern (`vi.useFakeTimers()`), auch mit `advanceTimers`-Option.
- **Fix:** Fuer diesen einen Test `vi.useRealTimers()` gesetzt (das Abhaken speichert sofort ohne Entprellen, echte Zeitgeber sind dafuer unproblematisch); `afterEach` stellt ohnehin `vi.useRealTimers()` global sicher.
- **Files modified:** `apps/web/src/components/dashboard/widgets/note-widget.test.tsx`
- **Verification:** Test gruen, restliche 10 Tests der Datei unbeeinflusst.
- **Committed in:** `b601141`
---
**Total deviations:** 3 auto-fixed (2 Bugs waehrend eigener Arbeit erkannt und vor dem jeweiligen Commit korrigiert, 1 blockierendes Test-Infrastruktur-Problem). Keine der drei hat den Endstand oder den Diff-Umfang ueber das im Plan Vorgesehene hinaus vergroessert — alle drei sind Korrekturen an waehrend dieses Vorgangs selbst neu geschriebenem Code, kein Scope-Creep in bestehende Dateien.
## Issues Encountered
Keine ungeloesten Probleme. Die drei oben dokumentierten Abweichungen sind vor dem jeweiligen Commit vollstaendig geloest.
## Known Stubs
Keine. Alle Aenderungen sind vollstaendige, funktionierende Korrekturen; keine Platzhalter, keine leeren Datenquellen.
## Threat Flags
Keine neue Angriffsflaeche. Der Umbau des Loeschdialog-Hintergrunds (`calendar-settings-panel.tsx`) und des Dialog-Hintergrunds (`widget-catalog-modal.tsx`) macht ein zuvor rein optisches Element zu einem fokussierbaren `<button>` — das ist die im `threat_model` als `accept` eingestufte Informationsfreigabe T-JT4-06 (Dialoginhalt war ohnehin sichtbar), keine neue Flag.
## User Setup Required
None — keine externe Konfiguration erforderlich.
## Was an den Orchestrator geht (kein Browser hier verfuegbar)
Der Ausfuehrende hat keinen Browser und darf die Browser-Messung aus Aufgabe 3 nicht behaupten. An den Orchestrator zu uebergeben:
- **Kalender-Widget, Netzwerkprotokoll bei 90 Tagen Vorschau:** Stack bauen, anmelden, Kalender-Kachel mit Vorschau-Einstellung 90 Tage aufs Dashboard legen, Netzwerkprotokoll leeren, dreimal "Weiter" druecken.
- Erwartung: `calendar/sources` genau 1 (vorher 3); `calendar/events` weniger als 3, kein Paar mit identischem `from`/`to`.
- Gegenprobe: rund fuenf Minuten ruhen lassen, danach je ein weiterer `calendar/events`- und `calendar/sources`-Abruf.
- Datenbank/Modul-Aktivierung danach unveraendert lassen, Stack stoppen.
Alle anderen Verifikationsschritte des Plans (Biome-Zaehlung, Katalog-Gleichstand, `apps/web`/`apps/api`-Testlaeufe, `pnpm type-check`, `pnpm lint --force`, `git diff`-Kontrollen) wurden hier bereits ausgefuehrt und sind oben mit Ergebnissen belegt.
## Next Phase Readiness
Der a11y-Rueckstand aus 260921-bi2 (30 zurueckgestellte Befunde ueber fuenf Regeln) ist vollstaendig abgearbeitet: 29 behoben, 1 bewusst und begruendet stehengelassen. Alle vier vom Nutzer/Orchestrator benannten Restposten aus den heutigen Vorgaengen bi2 und gof sind geschlossen. Kein Blocker fuer laufenden Betrieb: `pnpm lint --force` bleibt gruen (5/5), CI-Tor unveraendert scharf. Offen bleibt ausschliesslich die Browser-Gegenprobe fuer die Kalender-Netzwerkzaehlung (siehe Abschnitt oben) — reine Bestaetigung, kein bekannter Defekt.
## Self-Check: PASSED
Alle referenzierten Dateien auf Datentraeger gefunden (`zip-filename.ts`, `zip-filename.test.ts`, `translations-identity.test.tsx`, `widget-catalog-modal.test.tsx`, `calendar-settings-panel.test.tsx`, diese SUMMARY). Alle referenzierten Commit-Hashes (`a8531d4`, `3d0bc0b`, `0c89c13`, `b601141`, `e651c24`, `9aa87bd`, `b406a9c`, `f7b5df4`, `69fe706`, `5a03b75`, `6c10c9b`, `f471b78`) im Verlauf gefunden (`git log --oneline 7557c9a..HEAD`).
---
*Vorgang: quick-260921-jt4*
*Abgeschlossen: 2026-09-21*
@@ -57,10 +57,14 @@ export class TenderNormalizerService {
case 'rss':
case 'email-alert':
return this.normalizeBag(raw);
case 'doe-opendata':
default:
// default MUST stay on the DÖE path (never throw) so future
// additive SourceType union members don't break existing callers.
// Deckt sowohl 'doe-opendata' als auch jedes kuenftige additive
// Mitglied der SourceType-Vereinigung ab: default MUSS auf dem
// DÖE-Pfad bleiben (nie werfen), damit weder die heutige DÖE-Quelle
// noch ein spaeter hinzugefuegter SourceType bestehende Aufrufer
// zum Scheitern bringt (quick-260921-jt4: die vorherige eigene
// 'doe-opendata'-Fallmarke unmittelbar ueber diesem Zweig war
// ueberfluessig — derselbe Zweig, kein Verhaltenswechsel).
return this.normalizeDoe(raw);
}
}
-1
View File
@@ -102,7 +102,6 @@ export default function LoginPage() {
type="text"
required
autoComplete="username"
autoFocus
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder={t('username')}
/>
@@ -136,7 +136,6 @@ export default function ResetPasswordTokenPage() {
required
minLength={8}
autoComplete="new-password"
autoFocus
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder={t('resetPassword.newPassword')}
/>
@@ -96,7 +96,6 @@ export default function ResetPasswordPage() {
type="email"
required
autoComplete="email"
autoFocus
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder={t('resetPassword.email')}
/>
@@ -83,7 +83,6 @@ export default function ChangePasswordPage() {
type="password"
required
autoComplete="current-password"
autoFocus
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder={t('changePassword.currentPassword')}
/>
@@ -4,15 +4,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl useTranslations
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
useTranslations: () => (key: string, values?: Record<string, string | number>) => {
const translations: Record<string, string> = {
activate: 'Modul aktivieren',
deactivate: 'Deaktivieren',
statusActive: 'Aktiviert',
statusAvailable: 'Verfuegbar',
statusNoAccess: 'Kein Zugriff',
openDetail: 'Details zu {name} öffnen',
};
return translations[key] ?? key;
const template = translations[key] ?? key;
if (!values) return template;
return Object.entries(values).reduce(
(acc, [placeholder, value]) => acc.replace(`{${placeholder}}`, String(value)),
template,
);
},
}));
@@ -81,7 +87,15 @@ describe('MarketplaceCard', () => {
expect(onToggle).toHaveBeenCalledWith('mod-1', false);
});
it('shows the "Kein Zugriff" badge for an active module without access and clicking the card triggers the toast instead of navigating (D-08)', async () => {
it('does not render a card overlay button for a not-yet-activated module (D-01)', () => {
render(<MarketplaceCard {...baseProps} isActive={false} />);
expect(
screen.queryByRole('button', { name: 'Details zu Domaincheck öffnen' }),
).not.toBeInTheDocument();
});
it('shows the "Kein Zugriff" badge for an active module without access and mouse-clicking the overlay button triggers the toast instead of navigating (D-08)', async () => {
const onOpenDetail = vi.fn();
const onLockedClick = vi.fn();
const user = userEvent.setup();
@@ -98,13 +112,13 @@ describe('MarketplaceCard', () => {
expect(screen.getByText('Kein Zugriff')).toBeInTheDocument();
await user.click(screen.getByText('Domaincheck'));
await user.click(screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' }));
expect(onLockedClick).toHaveBeenCalledOnce();
expect(onOpenDetail).not.toHaveBeenCalled();
});
it('does not show the "Kein Zugriff" badge for an active module with access and clicking the card navigates', async () => {
it('does not show the "Kein Zugriff" badge for an active module with access and mouse-clicking the overlay button navigates', async () => {
const onOpenDetail = vi.fn();
const onLockedClick = vi.fn();
const user = userEvent.setup();
@@ -121,14 +135,80 @@ describe('MarketplaceCard', () => {
expect(screen.queryByText('Kein Zugriff')).not.toBeInTheDocument();
await user.click(screen.getByText('Domaincheck'));
await user.click(screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' }));
expect(onOpenDetail).toHaveBeenCalledOnce();
expect(onOpenDetail).toHaveBeenCalledWith('domaincheck');
expect(onLockedClick).not.toHaveBeenCalled();
});
it('leaves the not-activated state unchanged: no access badge, and clicking the card does not fire navigate or locked callbacks', async () => {
it('opens the module detail via a real keyboard activation of the focused overlay button (D-01)', async () => {
const onOpenDetail = vi.fn();
const user = userEvent.setup();
render(
<MarketplaceCard
{...baseProps}
isActive={true}
hasAccess={true}
onOpenDetail={onOpenDetail}
/>,
);
const overlay = screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' });
overlay.focus();
await user.keyboard('{Enter}');
expect(onOpenDetail).toHaveBeenCalledOnce();
expect(onOpenDetail).toHaveBeenCalledWith('domaincheck');
});
it('triggers the locked hint via a real keyboard activation of the focused overlay button (D-01, D-08)', async () => {
const onLockedClick = vi.fn();
const onOpenDetail = vi.fn();
const user = userEvent.setup();
render(
<MarketplaceCard
{...baseProps}
isActive={true}
hasAccess={false}
onLockedClick={onLockedClick}
onOpenDetail={onOpenDetail}
/>,
);
const overlay = screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' });
overlay.focus();
await user.keyboard('{Enter}');
expect(onLockedClick).toHaveBeenCalledOnce();
expect(onOpenDetail).not.toHaveBeenCalled();
});
it('keeps the activate/deactivate footer button as its own reachable tab stop that does not additionally open the detail overlay', async () => {
const onToggle = vi.fn();
const onOpenDetail = vi.fn();
const user = userEvent.setup();
render(
<MarketplaceCard
{...baseProps}
isActive={true}
hasAccess={true}
onToggle={onToggle}
onOpenDetail={onOpenDetail}
/>,
);
await user.click(screen.getByRole('button', { name: 'Deaktivieren' }));
expect(onToggle).toHaveBeenCalledOnce();
expect(onToggle).toHaveBeenCalledWith('mod-1', true);
expect(onOpenDetail).not.toHaveBeenCalled();
});
it('leaves the not-activated state unchanged: no access badge, no overlay button, and clicking the visible text does not fire navigate or locked callbacks', async () => {
const onOpenDetail = vi.fn();
const onLockedClick = vi.fn();
const user = userEvent.setup();
@@ -114,16 +114,22 @@ export function MarketplaceCard({
return (
<div
className={`rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
locked
? 'opacity-60 cursor-not-allowed'
: isActive
? 'hover:shadow-md hover:border-primary/30 cursor-pointer'
: 'hover:shadow-md hover:border-primary/30'
className={`relative rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
locked ? 'opacity-60' : 'hover:shadow-md hover:border-primary/30'
}`}
style={{ minHeight: '180px' }}
onClick={isActive ? handleCardClick : undefined}
>
{/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte
Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */}
{isActive && (
<button
type="button"
onClick={handleCardClick}
aria-label={t('openDetail', { name })}
className={`absolute inset-0 rounded-lg ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`}
/>
)}
{/* Header: icon + name + badges */}
<div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0">
@@ -167,7 +173,7 @@ export function MarketplaceCard({
{/* Footer: action button — only rendered for users who may activate/deactivate */}
{canManage && (
<div className="flex items-center justify-end">
<div className="relative flex items-center justify-end">
<button
type="button"
onClick={(e) => {
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { zipSync } from 'fflate';
import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions';
import { sanitizeZipFilename } from '../zip-filename';
interface SplitTabProps {
file: File | null;
@@ -17,7 +18,11 @@ const ROLE_STYLES: Record<CertRole, string> = {
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
};
function downloadAllAsZip(certs: SplitResponse['certs']) {
// `downloadAllAsZip` liegt ausserhalb der Komponente und kann den
// Uebersetzungs-Hook nicht selbst aufrufen — der uebersetzte Name kommt
// deshalb als Parameter herein (Restposten 1, quick-260921-jt4). Die
// einzelnen Dateinamen IM Archiv stammen weiterhin unveraendert aus der API.
function downloadAllAsZip(certs: SplitResponse['certs'], zipFilename: string) {
const files: Record<string, Uint8Array> = {};
for (const cert of certs) {
const bytes = Uint8Array.from(atob(cert.content), (c) => c.charCodeAt(0));
@@ -35,7 +40,11 @@ function downloadAllAsZip(certs: SplitResponse['certs']) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'certificates.zip';
// T-JT4-05: der uebersetzte Name kann Zeichen tragen, die Windows
// verbietet (Umlaute, Sonderzeichen) — sanitizeZipFilename schneidet ihn
// auf das fuer eine Windows-Freigabe Zulaessige zurueck, unabhaengig davon,
// ob der aktuelle Katalogwert zufaellig schon harmlos ist.
a.download = sanitizeZipFilename(zipFilename);
a.click();
URL.revokeObjectURL(url);
}
@@ -81,7 +90,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
{result && result.certs.length > 1 && (
<button
type="button"
onClick={() => downloadAllAsZip(result.certs)}
onClick={() => downloadAllAsZip(result.certs, t('actions.zipFilename'))}
className="border border-border px-4 py-2 rounded text-sm font-medium hover:bg-secondary transition-colors"
>
{t('actions.downloadZip')}
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { ZIP_FILENAME_FALLBACK, sanitizeZipFilename } from './zip-filename';
describe('sanitizeZipFilename (T-JT4-05)', () => {
it('laesst den deutschen Katalogwert unveraendert durch', () => {
expect(sanitizeZipFilename('Zertifikate.zip')).toBe('Zertifikate.zip');
});
it('laesst den englischen Katalogwert unveraendert durch', () => {
expect(sanitizeZipFilename('certificates.zip')).toBe('certificates.zip');
});
it('bereinigt einen Namen mit Umlaut, statt ihn abzulehnen', () => {
expect(sanitizeZipFilename('Zertifikäte.zip')).toBe('Zertifik_te.zip');
});
it('bereinigt einen Namen mit einem von Windows verbotenen Zeichen', () => {
expect(sanitizeZipFilename('Zertifikate:Test.zip')).toBe('Zertifikate_Test.zip');
expect(sanitizeZipFilename('a<b>c.zip')).toBe('a_b_c.zip');
});
it('ersetzt Steuerzeichen', () => {
expect(sanitizeZipFilename(`a${String.fromCharCode(7)}b.zip`)).toBe('a_b.zip');
});
it('entfernt abschliessende Punkte und Leerzeichen', () => {
expect(sanitizeZipFilename('Zertifikate .zip')).toBe('Zertifikate .zip');
expect(sanitizeZipFilename('Zertifikate.zip ')).toBe('Zertifikate.zip');
expect(sanitizeZipFilename('Zertifikate...')).toBe('Zertifikate.zip');
});
it('faellt auf den Ersatznamen zurueck, wenn der Name ausschliesslich aus abschliessenden Punkten/Leerzeichen besteht', () => {
expect(sanitizeZipFilename('...')).toBe(ZIP_FILENAME_FALLBACK);
expect(sanitizeZipFilename(' ')).toBe(ZIP_FILENAME_FALLBACK);
});
it('ergaenzt die Endung .zip, wenn sie fehlt, ohne sie zu verdoppeln', () => {
expect(sanitizeZipFilename('Zertifikate')).toBe('Zertifikate.zip');
expect(sanitizeZipFilename('Zertifikate.ZIP')).toBe('Zertifikate.ZIP');
expect(sanitizeZipFilename('Zertifikate.zip')).toBe('Zertifikate.zip');
});
it('faellt bei leerem Ergebnis auf den Ersatznamen zurueck', () => {
expect(sanitizeZipFilename('')).toBe(ZIP_FILENAME_FALLBACK);
expect(sanitizeZipFilename('***')).not.toBe(ZIP_FILENAME_FALLBACK); // sanitized, not empty
expect(sanitizeZipFilename(' ')).toBe(ZIP_FILENAME_FALLBACK);
expect(sanitizeZipFilename(':::')).not.toBe(ZIP_FILENAME_FALLBACK); // sanitized to "___.zip"
});
it('faellt bei einem aussichtslosen reservierten Geraetenamen auf den Ersatznamen zurueck', () => {
expect(sanitizeZipFilename('CON')).toBe(ZIP_FILENAME_FALLBACK);
expect(sanitizeZipFilename('con')).toBe(ZIP_FILENAME_FALLBACK);
expect(sanitizeZipFilename('CON.zip')).toBe(ZIP_FILENAME_FALLBACK);
expect(sanitizeZipFilename('LPT1')).toBe(ZIP_FILENAME_FALLBACK);
expect(sanitizeZipFilename('COM9.zip')).toBe(ZIP_FILENAME_FALLBACK);
});
it('behandelt einen reservierten Namen, der nur ein Praefix eines laengeren Namens ist, nicht als reserviert', () => {
expect(sanitizeZipFilename('CONtracts.zip')).toBe('CONtracts.zip');
});
});
@@ -0,0 +1,93 @@
/**
* zip-filename — schneidet einen (uebersetzten) Anzeigenamen auf das fuer
* eine Windows-Freigabe Zulaessige zurueck (T-JT4-05, quick-260921-jt4).
*
* Hintergrund: `downloadAllAsZip` in `SplitTab.tsx` kann den Namen kuenftig
* ueber `t('actions.zipFilename')` aus dem Uebersetzungskatalog beziehen.
* Das deutsche Wort fuer "Zertifikate" enthaelt zufaellig keinen Umlaut und
* kein von Windows verbotenes Zeichen — darauf darf sich die
* Dateisystem-Sicherheit aber NICHT verlassen, sonst haengt sie an einer
* kuenftigen Uebersetzungsentscheidung. Diese Funktion ist deshalb fuer sich
* pruefbar und unabhaengig vom tatsaechlichen Katalogwert.
*
* Reihenfolge der Schritte:
* 1. Von Windows verbotene Zeichen (`< > : " / \ | ? *`) UND Steuerzeichen
* (0x00-0x1F) werden durch `_` ersetzt.
* 2. Nicht-ASCII-Zeichen (Umlaute, Emoji, ...) werden durch `_` ersetzt.
* 3. Abschliessende Punkte und Leerzeichen werden entfernt (Windows ignoriert
* sie beim Anlegen, ein Name, der nur daraus besteht, wuerde sonst leer).
* 4. Ist das Ergebnis leer, faellt die Funktion auf `certificates.zip`
* zurueck.
* 5. Die Endung `.zip` wird sichergestellt (case-insensitiv erkannt, nicht
* doppelt angehaengt).
* 6. Reservierte Windows-Geraetenamen (`CON`, `PRN`, `AUX`, `NUL`,
* `COM1`-`COM9`, `LPT1`-`LPT9`, ohne Ruecksicht auf Gross-/Kleinschreibung)
* fallen ebenfalls auf `certificates.zip` zurueck — ein Name, der NUR aus
* einem solchen Geraetenamen besteht, ist auf Windows aussichtslos, egal
* welche Endung er traegt.
*/
// Zeichenweise statt per Regex (vermeidet lint/suspicious/noControlCharactersInRegex,
// das ein \x00-\x1F-Bereich in einem Regex-Literal ablehnt — hier aber genau die
// Absicht ist: Steuerzeichen ausfiltern, kein Tippfehler).
const WINDOWS_FORBIDDEN_PRINTABLE = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']);
function isControlOrForbiddenOrNonAscii(char: string): boolean {
const codePoint = char.codePointAt(0) ?? 0;
return codePoint <= 0x1f || codePoint > 0x7e || WINDOWS_FORBIDDEN_PRINTABLE.has(char);
}
function replaceForbiddenCharacters(rawName: string): string {
let result = '';
for (const char of rawName) {
result += isControlOrForbiddenOrNonAscii(char) ? '_' : char;
}
return result;
}
const TRAILING_DOTS_SPACES_RE = /[.\s]+$/;
const RESERVED_DEVICE_NAMES = new Set([
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
]);
export const ZIP_FILENAME_FALLBACK = 'certificates.zip';
export function sanitizeZipFilename(rawName: string): string {
const cleaned = replaceForbiddenCharacters(rawName).replace(TRAILING_DOTS_SPACES_RE, '');
if (cleaned === '') {
return ZIP_FILENAME_FALLBACK;
}
const hasZipExtension = cleaned.toLowerCase().endsWith('.zip');
const withExtension = hasZipExtension ? cleaned : `${cleaned}.zip`;
const base = hasZipExtension ? withExtension.slice(0, -'.zip'.length) : cleaned;
if (RESERVED_DEVICE_NAMES.has(base.toUpperCase())) {
return ZIP_FILENAME_FALLBACK;
}
return withExtension;
}
@@ -0,0 +1,128 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl useTranslations, namespaced like the real catalog/common usage.
vi.mock('next-intl', () => ({
useTranslations: (namespace: string) => (key: string) => {
const translations: Record<string, Record<string, string>> = {
widgets: {
catalogTitle: 'Widget hinzufügen',
catalogClose: 'Dialog schließen',
'clock.name': 'Uhr',
'clock.description': 'Zeigt die aktuelle Zeit',
'search.name': 'Suche',
'search.description': 'Schnellsuche',
'calendar.name': 'Kalender',
'calendar.description': 'Kommende Termine',
'note.name': 'Notiz',
'note.description': 'Schnelle Notizen',
'calculator.name': 'Taschenrechner',
'calculator.description': 'Einfache Berechnungen',
'favorites.name': 'Favoriten',
'favorites.description': 'Schnellzugriff auf Favoriten',
'stopwatch.name': 'Stoppuhr',
'stopwatch.description': 'Zeit messen',
},
common: {
close: 'Schließen',
},
};
return translations[namespace]?.[key] ?? key;
},
}));
import { WidgetCatalogModal } from './widget-catalog-modal';
const baseProps = {
isOpen: true,
onClose: vi.fn(),
onAddWidget: vi.fn(),
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('WidgetCatalogModal', () => {
it('renders nothing when isOpen is false', () => {
render(<WidgetCatalogModal {...baseProps} isOpen={false} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders the dialog with title and all widget type options when open', () => {
render(<WidgetCatalogModal {...baseProps} />);
expect(screen.getByRole('dialog', { name: 'Widget hinzufügen' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Uhr/ })).toBeInTheDocument();
});
it('clicking the backdrop button closes the dialog (D-01)', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(<WidgetCatalogModal {...baseProps} onClose={onClose} />);
await user.click(screen.getByRole('button', { name: 'Dialog schließen' }));
expect(onClose).toHaveBeenCalledOnce();
});
it('activating the backdrop button via a real keyboard Enter closes the dialog (D-01)', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(<WidgetCatalogModal {...baseProps} onClose={onClose} />);
const backdrop = screen.getByRole('button', { name: 'Dialog schließen' });
backdrop.focus();
await user.keyboard('{Enter}');
expect(onClose).toHaveBeenCalledOnce();
});
it('clicking inside the dialog surface does not close the dialog', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(<WidgetCatalogModal {...baseProps} onClose={onClose} />);
await user.click(screen.getByRole('dialog', { name: 'Widget hinzufügen' }));
expect(onClose).not.toHaveBeenCalled();
});
it('clicking a widget type option adds the widget and closes the dialog', async () => {
const onAddWidget = vi.fn();
const onClose = vi.fn();
const user = userEvent.setup();
render(<WidgetCatalogModal {...baseProps} onAddWidget={onAddWidget} onClose={onClose} />);
await user.click(screen.getByRole('button', { name: /Uhr/ }));
expect(onAddWidget).toHaveBeenCalledWith('clock');
expect(onClose).toHaveBeenCalledOnce();
});
it('pressing Escape closes the dialog', () => {
const onClose = vi.fn();
render(<WidgetCatalogModal {...baseProps} onClose={onClose} />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
});
it('the close button in the dialog header carries the shared common.close label', () => {
render(<WidgetCatalogModal {...baseProps} />);
// Two buttons named "Schließen": the header close icon button.
// Query specifically among the dialog's own buttons, excluding the backdrop
// (backdrop is named "Dialog schließen", the header button "Schließen").
expect(screen.getByRole('button', { name: 'Schließen' })).toBeInTheDocument();
});
});
@@ -31,6 +31,7 @@ export function WidgetCatalogModal({
onAddWidget,
}: WidgetCatalogModalProps) {
const t = useTranslations('widgets');
const tCommon = useTranslations('common');
const dialogRef = useRef<HTMLDivElement>(null);
// Focus trap + Escape handler
@@ -53,12 +54,16 @@ export function WidgetCatalogModal({
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
{/* Backdrop — visually only, hidden from assistive tech */}
<div className="fixed inset-0 bg-black/50" aria-hidden="true" />
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Hintergrund — jetzt eine echte, benannte Schaltflaeche statt einer rein
optischen Flaeche: ein fokussierbares Element darf vor der Vorlesehilfe
nicht verborgen sein (D-01). */}
<button
type="button"
onClick={onClose}
aria-label={t('catalogClose')}
className="fixed inset-0 bg-black/50"
/>
{/* Dialog */}
<div
@@ -67,7 +72,6 @@ export function WidgetCatalogModal({
aria-modal="true"
aria-label={t('catalogTitle')}
className="relative z-50 w-full max-w-[480px] rounded-lg border bg-card p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
{/* Header */}
@@ -79,7 +83,7 @@ export function WidgetCatalogModal({
type="button"
onClick={onClose}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close"
aria-label={tCommon('close')}
>
<svg
aria-hidden="true"
@@ -1,9 +1,19 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
// Mock next-intl — returns the key so tests are locale-agnostic
// Mock next-intl — returns the real de.json strings for the few keys this
// widget actually renders as accessible names/labels, the raw key otherwise
// (locale-agnostic fallback for anything not explicitly mapped here).
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useTranslations: () => (key: string) => {
const map: Record<string, string> = {
'calculator.name': 'Taschenrechner',
'calculator.displayLabel': 'Anzeige',
'calculator.memoryLabel': 'Speicherfunktionen',
'calculator.backspaceLabel': 'Rückschritt',
};
return map[key] ?? key;
},
}));
// Must import after mocks
@@ -336,13 +336,15 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
{/* Display */}
<output
className="flex min-h-[2.5rem] items-end justify-end rounded bg-muted px-3 py-1 font-mono text-[clamp(14px,min(8cqw,10cqh),96px)] font-semibold text-foreground overflow-hidden"
aria-label="Anzeige"
aria-label={t('calculator.displayLabel')}
>
<span className="truncate">{display}</span>
</output>
{/* Memory row */}
<div className="flex gap-1" aria-label="Speicherfunktionen">
{/* Memory row — role="toolbar" (D-03): ohne Rolle ist das <div> generic
und die Vorlesehilfe verwirft aria-label stillschweigend. Geprueft
sauber (role="group"/"region" loesen useSemanticElements neu aus). */}
<div className="flex gap-1" role="toolbar" aria-label={t('calculator.memoryLabel')}>
<button type="button" className={memBtn} onClick={memoryClear} disabled={memory === 0}>MC</button>
<button type="button" className={memBtn} onClick={memoryRecall} disabled={memory === 0}>MR</button>
<button type="button" className={memBtn} onClick={memoryAdd}>M+</button>
@@ -356,7 +358,7 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
<CalcButton label="%" className={utilBtn} onClick={() => applyUnary('percent')} />
<CalcButton label="CE" className={utilBtn} onClick={clearEntry} />
<CalcButton label="C" className={utilBtn} onClick={clearAll} />
<CalcButton label="⌫" ariaLabel="Rückschritt" className={utilBtn} onClick={backspace} />
<CalcButton label="⌫" ariaLabel={t('calculator.backspaceLabel')} className={utilBtn} onClick={backspace} />
{/* Row 2: unary + divide */}
<CalcButton label="1/x" className={utilBtn} onClick={() => applyUnary('reciprocal')} />
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CalendarEvent } from '@/lib/calendar-api';
@@ -26,6 +26,8 @@ vi.mock('next-intl', () => ({
'calendar.upcomingTitle': 'Nächste Termine',
'calendar.tooltipMore': 'Weitere Termine vorhanden',
'calendar.allDay': 'ganztägig',
'calendar.dayEventsOne': '{date}: 1 Termin',
'calendar.dayEventsMany': '{date}: {count} Termine',
},
};
const raw = map[ns]?.[key] ?? key;
@@ -514,4 +516,196 @@ describe('CalendarWidget', () => {
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
});
// quick-260921-jt4 (D-01): Tage MIT Terminen sind echte Schaltflaechen und
// per Fokus bedienbar; Tage OHNE Termine bleiben Nicht-Tab-Stopps.
it('Test 11: Ein Tag MIT Terminen ist eine echte <button>-Schaltflaeche und zeigt beim Fokussieren dieselbe Einblendung wie beim Ueberfahren mit der Maus', async () => {
mockFetchEvents.mockResolvedValue([
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-11" config={{}} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
expect(day20.tagName).toBe('BUTTON');
fireEvent.focus(day20);
const tooltip = screen.getByTestId('calendar-day-tooltip');
expect(tooltip).toHaveTextContent('Team Meeting');
fireEvent.blur(day20);
expect(screen.queryByTestId('calendar-day-tooltip')).toBeNull();
});
it('Test 12: Ein Tag OHNE Termine bleibt eine <div> ohne Handler und ist kein Tab-Stopp', async () => {
mockFetchEvents.mockResolvedValue([]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-12" config={{}} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
expect(day20.tagName).toBe('DIV');
});
it('Test 13: die Schaltflaeche eines Tages mit Terminen traegt einen Namen, der Datum und Terminzahl nennt', async () => {
mockFetchEvents.mockResolvedValue([
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
ev('Lunch', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)),
]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-13" config={{}} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
expect(day20.getAttribute('aria-label')).toContain('20.07');
expect(day20.getAttribute('aria-label')).toContain('2 Termine');
});
it('Test 14: die Schaltflaeche eines Tages mit genau einem Termin nutzt die Einzahl-Beschriftung', async () => {
mockFetchEvents.mockResolvedValue([
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-14" config={{}} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
expect(day20.getAttribute('aria-label')).toContain('1 Termin');
expect(day20.getAttribute('aria-label')).not.toContain('1 Termine');
});
it('Test 15: der Fokus landet tatsaechlich auf der Tages-Schaltflaeche (echter Tab-Stopp, nicht nur optisch)', async () => {
mockFetchEvents.mockResolvedValue([
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-15" config={{}} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement;
day20.focus();
expect(document.activeElement).toBe(day20);
});
// Restposten 3a/3b (quick-260921-jt4): Quellenliste und Termin-Ladefenster
// werden nicht mehr blind bei jedem Monatswechsel neu geholt.
it('Test 16: Aufbau holt Quellenliste und Termine je genau einmal', async () => {
mockFetchEvents.mockResolvedValue([]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-16" config={{}} isEditMode={false} />);
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
expect(mockFetchSources).toHaveBeenCalledTimes(1);
});
it('Test 17: Monatswechsel mit abweichendem Ladefenster holt die Termine erneut (+1), die Quellenliste NICHT (+0)', async () => {
mockFetchEvents.mockResolvedValue([]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-17" config={{}} isEditMode={false} />);
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
expect(mockFetchSources).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(2));
expect(mockFetchSources).toHaveBeenCalledTimes(1);
});
it('Test 18: Monatswechsel mit identischem Ladefenster loest weder Termin- noch Quellen-Abruf aus (beide +0/+0)', async () => {
mockFetchEvents.mockResolvedValue([]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-18" config={{ lookaheadDays: 90 }} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
// Juli -> August: das Ladefenster aendert sich wirklich (die 27.07.-
// Rastergrenze liegt jetzt NACH "heute", "from" wechselt vom Rasterstart
// auf den heutigen Tagesbeginn).
fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
await waitFor(() => expect(screen.getByText('August 2026')).toBeInTheDocument());
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(2));
const augustWindow = mockFetchEvents.mock.calls[1];
// August -> September: bei 90 Tagen Vorschau deckt der (auf "heute" +
// 90 Tage eingefrorene) Vorschauhorizont beide Rasterenden — das
// errechnete Fenster ist Zeichen fuer Zeichen dasselbe wie in August.
// Abweichung vom im PLAN genannten Monatspaar Oktober/November, siehe
// SUMMARY: bei DIESEM eingefrorenen "heute" (15.07.2026) kollidieren
// nachweislich August und September, nicht Oktober/November — derselbe
// Mechanismus, ein anderes Monatspaar (vgl. Test 8, dieselbe Art
// Abweichung fuer den analogen 30-Tage-Fall).
fireEvent.click(screen.getByRole('button', { name: 'Weiter' }));
await waitFor(() => expect(screen.getByText('September 2026')).toBeInTheDocument());
// Zeit fuer einen faelschlich uebersprungenen-und-doch-feuernden Abruf geben.
await act(async () => {});
expect(mockFetchEvents).toHaveBeenCalledTimes(2);
expect(mockFetchEvents.mock.calls[1]).toEqual(augustWindow);
expect(mockFetchSources).toHaveBeenCalledTimes(1);
});
it('Test 19: der 5-Minuten-Auffrischer holt immer beide, auch bei unveraendertem Fenster (beide +1/+1)', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
vi.setSystemTime(new Date(2026, 6, 15, 10, 0, 0));
mockFetchEvents.mockResolvedValue([]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-19" config={{}} isEditMode={false} />);
await act(async () => {});
expect(mockFetchEvents).toHaveBeenCalledTimes(1);
expect(mockFetchSources).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(300_000);
});
expect(mockFetchEvents).toHaveBeenCalledTimes(2);
expect(mockFetchSources).toHaveBeenCalledTimes(2);
});
it('Test 20: ein uebersprungener Lauf beendet den Ladezustand trotzdem sauber und leert die bereits geladenen Termine nicht', async () => {
mockFetchEvents.mockResolvedValue([
ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)),
]);
const { CalendarWidget } = await import('./calendar-widget');
render(<CalendarWidget instanceId="cal-20" config={{ lookaheadDays: 90 }} isEditMode={false} />);
await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument());
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole('button', { name: 'Weiter' })); // Juli -> August
await waitFor(() => expect(screen.getByText('August 2026')).toBeInTheDocument());
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: 'Weiter' })); // August -> September, uebersprungen
await waitFor(() => expect(screen.getByText('September 2026')).toBeInTheDocument());
await act(async () => {});
// Der uebersprungene Lauf darf weder haengen bleiben (kein "Laden..."
// mehr sichtbar) noch die zuvor geladenen Termine leeren — "Naechste
// Termine" haengt an "heute", nicht am angezeigten Monat, und zeigt das
// beim Aufbau geladene Team Meeting deshalb weiterhin.
expect(screen.queryByText('Laden...')).not.toBeInTheDocument();
expect(within(screen.getByTestId('calendar-upcoming')).getByText('Team Meeting')).toBeInTheDocument();
});
});
@@ -73,6 +73,18 @@ export function CalendarWidget({ config }: WidgetProps) {
} | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
// Restposten 3a/3b (quick-260921-jt4): zwei Merkungen ausserhalb des
// Render-Zustands, damit ein Monatswechsel, der weder die Quellenliste noch
// das Termin-Ladefenster tatsaechlich aendert, keinen erneuten Abruf
// ausloest — der 5-Minuten-Auffrischer (siehe `force` unten) ruft
// unabhaengig davon immer beide ab, sonst veraltet die Anzeige still.
// hasSourcesRef: `null` heisst "noch nie erfolgreich geholt".
const hasSourcesRef = useRef<boolean | null>(null);
// lastFetchWindowRef: die beiden ISO-Zeichenketten, die zuletzt
// TATSAECHLICH an fetchEvents gingen (nicht nur berechnet) — derselbe
// Vergleichswert, den auch die API sieht.
const lastFetchWindowRef = useRef<{ from: string; to: string } | null>(null);
// Befund 1/2 (quick-260921-gof): keine Merkung noetig — resolveCalendarConfig
// ist eine reine Funktion, die nur drei einfache Werte liefert; eine
// Merkung um das ganze config-Objekt hat hier nie etwas gespart.
@@ -81,13 +93,23 @@ export function CalendarWidget({ config }: WidgetProps) {
useEffect(() => {
let cancelled = false;
async function loadData() {
// `force`: true nur fuer den 5-Minuten-Auffrischer. Ein durch
// Monatswechsel ausgeloester Lauf (force=false) darf beide Sperrgriffe
// unten greifen lassen; der Auffrischer muss sie UNBEDINGT umgehen,
// sonst friert die Anzeige ein.
async function loadData(force: boolean) {
try {
// Check if user has any sources configured
const sources = await fetchSources();
if (cancelled) return;
// Restposten 3b: die Quellenliste haengt nicht vom angezeigten Monat
// ab — nur holen, wenn sie noch unbekannt ist ODER der Lauf
// erzwungen wurde (Aufbau: hasSourcesRef ist null, greift also
// ohnehin; Auffrischer: force=true).
if (hasSourcesRef.current === null || force) {
const sources = await fetchSources();
if (cancelled) return;
hasSourcesRef.current = sources.length > 0;
}
if (sources.length === 0) {
if (!hasSourcesRef.current) {
setHasSources(false);
setEvents([]);
setIsLoading(false);
@@ -99,8 +121,26 @@ export function CalendarWidget({ config }: WidgetProps) {
// Fetch aggregated events for the current grid/lookahead window —
// ALWAYS with both day-boundary ISO strings (T-HTC-03).
const { from, to } = computeFetchWindow(monthDate, lookaheadDays);
const eventData = await fetchEvents(from.toISOString(), to.toISOString());
const fromIso = from.toISOString();
const toIso = to.toISOString();
// Restposten 3a: das neu berechnete Fenster deckt sich bei einem
// Monatswechsel manchmal mit dem zuletzt tatsaechlich geholten
// (siehe Kopfkommentar computeFetchWindow/SUMMARY) — dann ist der
// Abruf verschwendet und faellt bis zum naechsten Server durch.
const windowUnchanged =
!force &&
lastFetchWindowRef.current !== null &&
lastFetchWindowRef.current.from === fromIso &&
lastFetchWindowRef.current.to === toIso;
if (windowUnchanged) {
setIsLoading(false);
return;
}
const eventData = await fetchEvents(fromIso, toIso);
if (cancelled) return;
lastFetchWindowRef.current = { from: fromIso, to: toIso };
setEvents(eventData);
} catch {
// Silent fail — show empty state
@@ -114,11 +154,16 @@ export function CalendarWidget({ config }: WidgetProps) {
}
}
loadData();
// Aufbau bzw. Monatswechsel: nicht erzwungen — die beiden Sperrgriffe
// oben entscheiden selbst, ob wirklich etwas Neues zu holen ist (beim
// allerersten Aufbau sind beide Merkungen leer, holen also ohnehin).
loadData(false);
// Refresh every 5 minutes (300000ms) to match backend cache TTL
// Refresh every 5 minutes (300000ms) to match backend cache TTL — IMMER
// erzwungen, sonst bemerkt die Kachel weder eine neue Quelle noch neue
// Termine innerhalb des Fensters.
intervalRef.current = setInterval(() => {
loadData();
loadData(true);
}, 300_000);
return () => {
@@ -249,20 +294,8 @@ export function CalendarWidget({ config }: WidgetProps) {
.filter(Boolean)
.join(' ');
return (
<div
key={day.key}
data-testid="calendar-day"
data-date={day.key}
data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined}
className={cellClass}
onMouseEnter={(e) =>
hasEvents &&
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
}
onMouseLeave={() => setHover(null)}
>
const cellContent = (
<>
<span>{day.date.getDate()}</span>
{hasEvents && (
// Wrapper traegt die Positionierung, die einzelnen Kreise nicht
@@ -298,6 +331,61 @@ export function CalendarWidget({ config }: WidgetProps) {
})}
</span>
)}
</>
);
// Nur Tage MIT Terminen werden zu Tab-Stopps (D-01) — ein
// Widget mit 42 zusaetzlichen Tab-Stopps waere eine
// Verschlechterung. Wer nicht mit der Maus arbeitet, bekam
// die Termin-Einblendung bislang gar nicht zu sehen; die
// Schaltflaeche traegt deshalb zusaetzlich onFocus/onBlur mit
// demselben Rumpf wie onMouseEnter/onMouseLeave.
if (hasEvents) {
const dayLabel = new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
}).format(day.date);
const eventCount = day.events.length;
const ariaLabel =
eventCount === 1
? t('calendar.dayEventsOne', { date: dayLabel })
: t('calendar.dayEventsMany', { date: dayLabel, count: eventCount });
return (
<button
key={day.key}
type="button"
data-testid="calendar-day"
data-date={day.key}
data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined}
className={`${cellClass} w-full text-left`}
aria-label={ariaLabel}
onMouseEnter={(e) =>
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
}
onMouseLeave={() => setHover(null)}
onFocus={(e) =>
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
}
onBlur={() => setHover(null)}
>
{cellContent}
</button>
);
}
return (
<div
key={day.key}
data-testid="calendar-day"
data-date={day.key}
data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined}
className={cellClass}
>
{cellContent}
</div>
);
})}
@@ -263,7 +263,16 @@ export function FavoritesWidget({
<div className="flex flex-1 flex-col gap-2 overflow-auto p-1">
{/* View mode toggle (edit mode only) */}
{isEditMode && (
<div className="flex gap-1 widgetNoDrag" aria-label={t('favorites.name')}>
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
// war sachlich falsch fuer einen Umschalter zwischen Listen- und
// Kachelansicht — hier greift D-03 im Sinn des Streichens (die
// Beschriftung trug keine echte Bedeutung), eine richtige
// Beschriftung ist trotzdem besser als gar keine.
<div
className="flex gap-1 widgetNoDrag"
role="toolbar"
aria-label={t('favorites.viewModeLabel')}
>
<button
type="button"
onClick={() => handleViewMode('list')}
@@ -463,6 +472,7 @@ function FavoriteIcon({
data-testid={`icon-proxy-${fav.id}`}
src={proxySrc}
alt=""
aria-hidden="true"
width={20}
height={20}
loading="lazy"
@@ -475,6 +485,7 @@ function FavoriteIcon({
data-testid={`icon-direct-${fav.id}`}
src={directSrc}
alt=""
aria-hidden="true"
width={20}
height={20}
loading="lazy"
@@ -1,5 +1,6 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import MDEditor from '@uiw/react-md-editor';
import rehypeSanitize from 'rehype-sanitize';
import { NoteCheckbox, isTaskLine, toggleTaskLine } from './note-task-list';
@@ -75,6 +76,45 @@ describe('note-task-list', () => {
});
});
describe('NoteCheckbox — bedient sich selbst (quick-260921-jt4, D-01)', () => {
it('ist nicht mehr readOnly und traegt kein disabled', () => {
const { container } = render(<NoteCheckbox checked={true} />);
const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(box.readOnly).toBe(false);
expect(box.disabled).toBe(false);
});
it('ruft onToggle mit dem eigenen DOM-Element auf, wenn eine echte Tastaturbetaetigung (Leertaste) das fokussierte Kaestchen ausloest', async () => {
const onToggle = vi.fn();
const user = userEvent.setup({ delay: null });
const { container } = render(
<NoteCheckbox checked={false} onToggle={onToggle} />,
);
const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
box.focus();
await user.keyboard(' ');
expect(onToggle).toHaveBeenCalledOnce();
expect(onToggle).toHaveBeenCalledWith(box);
});
it('ruft onToggle auch bei einem echten Mausklick auf', async () => {
const onToggle = vi.fn();
const user = userEvent.setup({ delay: null });
const { container } = render(
<NoteCheckbox checked={false} onToggle={onToggle} />,
);
const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
await user.click(box);
expect(onToggle).toHaveBeenCalledOnce();
expect(onToggle).toHaveBeenCalledWith(box);
});
});
describe('echte Vorschau (MDEditor.Markdown + rehypeSanitize + components-Override)', () => {
it('rendert genau die Aufgabenzeilen als anklickbare Kaestchen', () => {
const SRC =
@@ -25,6 +25,8 @@
* Anwenders (user-files/personal-dashboard/src/app/page.tsx, ~Z. 1399).
*/
import { useRef } from 'react';
/**
* Erkennt eine Markdown-Aufgabenzeile (`- [ ] Text`, `* [x] Text`,
* `+ [X] Text`, `1. [ ] Text`, `1) [ ] Text`, auch eingerueckt). Bewusst
@@ -84,16 +86,31 @@ export function toggleTaskLine(content: string, index: number): string {
* Zieht bewusst NUR `checked` aus den Props (react-markdown reicht
* zusaetzlich `node`, `disabled`, `type` durch) — sonst landen fremde
* Attribute (insbesondere `node`) im DOM. Kein `disabled`: das Kaestchen
* bleibt anklickbar. `readOnly` unterdrueckt Reacts Warnung
* "checked ohne onChange" — der Klick wird nicht am Kaestchen selbst,
* sondern delegiert am Vorschau-Container verarbeitet.
* bleibt anklickbar.
*
* quick-260921-jt4 (D-01): das Kaestchen bedient sich jetzt selbst — es
* traegt ein echtes `onChange` und gibt beim Ausloesen sein eigenes
* DOM-Element an `onToggle` weiter, statt sich vom Vorschau-Behaelter per
* `readOnly` + delegiertem Klick bedienen zu lassen. `readOnly` war bisher
* nur da, um Reacts Warnung ueber ein gesteuertes Feld ohne `onChange` zu
* unterdruecken.
*/
export function NoteCheckbox({ checked }: { checked?: boolean }) {
export function NoteCheckbox({
checked,
onToggle,
}: {
checked?: boolean;
onToggle?: (element: HTMLInputElement) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<input
ref={inputRef}
type="checkbox"
checked={!!checked}
readOnly
onChange={() => {
if (inputRef.current) onToggle?.(inputRef.current);
}}
className="cursor-pointer"
/>
);
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl
@@ -300,6 +301,57 @@ describe('NoteWidget', () => {
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
// quick-260921-jt4 (D-01): das Kaestchen bedient sich selbst — belegt durch
// eine echte Tastaturbetaetigung (Leertaste auf dem fokussierten
// Kaestchen), nicht durch eine Klick-Attrappe.
it('kippt ein Kaestchen bei einer echten Tastaturbetaetigung (Leertaste), nicht nur per Klick', async () => {
// Abhaken speichert sofort ohne Entprellen (siehe handleCheckboxToggle) —
// fuer diesen einen Test sind echte Zeitgeber unproblematisch und
// vermeiden, dass user-events interne Wartezeiten mit den in
// beforeEach gesetzten Fake-Timern kollidieren.
vi.useRealTimers();
const user = userEvent.setup();
render(
<NoteWidget
instanceId="note-1"
config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}
isEditMode={false}
/>,
);
const boxes = screen.getAllByRole('checkbox');
boxes[2].focus();
await user.keyboard(' ');
await act(async () => {});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toEqual({
config: { content: '- [ ] Milch\n- [x] Brot\n- [x] Eier', title: 'Einkauf' },
});
expect((screen.getAllByRole('checkbox')[2] as HTMLInputElement).checked).toBe(true);
});
it('im Bearbeitungsmodus passiert beim Kaestchen nichts, weil die Vorschau gar nicht gerendert wird', async () => {
render(
<NoteWidget
instanceId="note-1"
config={{ content: '- [ ] Milch', title: 'Einkauf' }}
isEditMode={false}
/>,
);
// Stift an: Bearbeitungsmodus
await act(async () => {
fireEvent.click(screen.getByRole('button'));
});
// Im Editor gibt es kein Kaestchen mehr — die Vorschau (und damit
// NoteCheckbox) wird im Bearbeitungsmodus nicht gerendert.
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
});
it('setzt data-color-mode auf dark, wenn Tessera auf Dunkel steht', () => {
themeMock.resolvedTheme = 'dark';
const { container } = render(
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import MDEditor, { commands } from '@uiw/react-md-editor';
@@ -13,21 +13,14 @@ const DEBOUNCE_MS = 1500;
// quick-260916-iex: Aufgabenlisten sind in der Ansicht direkt abhakbar —
// `components.input` ersetzt das per rehypeSanitize deaktivierte `input`
// durch NoteCheckbox (siehe note-task-list.tsx). Stabile Konstante ausserhalb
// der Komponente, damit react-markdown nicht bei jedem Render neu diffed.
// Typ direkt von MDEditor abgeleitet statt aus dem transitiven Paket
// `@uiw/react-markdown-preview` importiert (das ist keine direkte
// Dependency von apps/web und daher unter pnpms strikter Isolation nicht
// von hier aus aufloesbar).
// durch NoteCheckbox (siehe note-task-list.tsx). Typ direkt von MDEditor
// abgeleitet statt aus dem transitiven Paket `@uiw/react-markdown-preview`
// importiert (das ist keine direkte Dependency von apps/web und daher unter
// pnpms strikter Isolation nicht von hier aus aufloesbar).
type PreviewOptions = NonNullable<
React.ComponentProps<typeof MDEditor>['previewOptions']
>;
const PREVIEW_OPTIONS: PreviewOptions = {
rehypePlugins: [[rehypeSanitize]],
components: { input: NoteCheckbox },
};
const NOTE_COMMANDS = [
commands.bold,
commands.italic,
@@ -113,23 +106,27 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
[scheduleSave, content],
);
// quick-260916-iex: Klick auf ein Aufgaben-Kaestchen in der Ansicht kippt
// genau diese Zeile und speichert sofort (kein Entprellen — ein Klick ist
// eine abgeschlossene Handlung). Ein noch laufender Entprell-Timer aus dem
// Tippen wird verworfen, damit er den ungekippten Text nicht nachschiebt.
const handlePreviewClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
// Vorschau-Behaelter: die Index-Ermittlung (n-tes Kaestchen im Behaelter ==
// n-te Aufgabenzeile) bleibt Wort fuer Wort wie zuvor, findet den Behaelter
// jetzt aber ueber ein ref statt ueber event.currentTarget, weil der Klick
// ab jetzt am Kaestchen selbst entsteht (quick-260921-jt4, D-01: das
// Kaestchen bedient sich selbst statt vom Behaelter bedient zu werden).
const previewRef = useRef<HTMLDivElement>(null);
// quick-260916-iex: Abhaken eines Aufgaben-Kaestchens kippt genau diese
// Zeile und speichert sofort (kein Entprellen — eine Tastatur-/Maus-
// betaetigung ist eine abgeschlossene Handlung). Ein noch laufender
// Entprell-Timer aus dem Tippen wird verworfen, damit er den ungekippten
// Text nicht nachschiebt.
const handleCheckboxToggle = useCallback(
(element: HTMLInputElement) => {
if (isEditing) return;
const target = event.target;
if (!(target instanceof HTMLInputElement) || target.type !== 'checkbox') {
return;
}
const container = previewRef.current;
if (!container) return;
const boxes = Array.from(
event.currentTarget.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"]',
),
container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'),
);
const index = boxes.indexOf(target);
const index = boxes.indexOf(element);
const next = toggleTaskLine(content, index);
if (next === content) return;
setContent(next);
@@ -139,6 +136,30 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
[isEditing, content, title, save],
);
// Der Rueckruf, den `previewOptions.components.input` letztlich aufruft,
// haengt via ref an der jeweils aktuellen `handleCheckboxToggle` — das
// haelt `previewOptions` selbst identitaetsstabil (useMemo mit leerer
// Abhaengigkeitsliste), waehrend der Rueckruf trotzdem immer den frischen
// Zustand sieht. Ein pro Tastendruck neu erzeugtes Optionsobjekt liesse
// react-markdown bei jedem Zeichen neu abgleichen.
const handleCheckboxToggleRef = useRef(handleCheckboxToggle);
handleCheckboxToggleRef.current = handleCheckboxToggle;
const previewOptions = useMemo<PreviewOptions>(
() => ({
rehypePlugins: [[rehypeSanitize]],
components: {
input: (props: { checked?: boolean }) => (
<NoteCheckbox
checked={props.checked}
onToggle={(element) => handleCheckboxToggleRef.current(element)}
/>
),
},
}),
[],
);
return (
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
{/* Header: title + pencil toggle */}
@@ -184,7 +205,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
<div
className="flex-1 overflow-auto"
data-testid="note-preview"
onClick={handlePreviewClick}
ref={previewRef}
>
<MDEditor
data-testid="md-editor"
@@ -195,7 +216,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
hideToolbar={!isEditing}
height="100%"
visibleDragbar={false}
previewOptions={PREVIEW_OPTIONS}
previewOptions={previewOptions}
/>
</div>
</div>
@@ -156,6 +156,7 @@ export function Header() {
<img
src={`/api-proxy/users/me/avatar?v=${avatarVersion}`}
alt=""
aria-hidden="true"
className="h-8 w-8 rounded-full object-cover"
onError={() => setAvatarError(true)}
/>
@@ -162,6 +162,7 @@ export function AccountSettingsForm() {
key={avatarKey}
src={`/api-proxy/users/me/avatar?t=${avatarKey}`}
alt=""
aria-hidden="true"
className="h-16 w-16 rounded-full object-cover"
onError={() => setHasAvatar(false)}
/>
@@ -0,0 +1,146 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('next-intl', () => ({
useTranslations: (namespace: string) => (key: string) => {
const translations: Record<string, Record<string, string>> = {
widgets: {
'calendar.sourceEmpty':
'Keine Kalenderquellen eingerichtet. Fügen Sie eine Quelle hinzu, um Termine anzuzeigen.',
'calendar.addSource': 'Quelle hinzufügen',
'calendar.deleteDialogCancel': 'Löschen abbrechen',
'calendar.deleteDialogLabel': 'Löschen bestätigen',
'calendar.saveError': 'Fehler beim Speichern',
'calendar.saveSuccess': 'Gespeichert',
'calendar.connectionSuccess': 'Verbindung erfolgreich',
'calendar.syncErrorLabel': 'Synchronisierungsfehler',
'calendar.connectionFailedLabel': 'Verbindung fehlgeschlagen',
},
settings: {
sourceDeleteCta: 'Quelle löschen',
sourceDeleteConfirm: 'Möchten Sie diese Kalenderquelle wirklich löschen?',
cancel: 'Abbrechen',
},
};
return translations[namespace]?.[key] ?? key;
},
}));
vi.mock('@/lib/calendar-api', () => ({
fetchSources: vi.fn(),
addSource: vi.fn(),
updateSource: vi.fn(),
deleteSource: vi.fn(),
testSource: vi.fn(),
}));
vi.mock('./calendar-source-form', () => ({
CalendarSourceForm: () => <div data-testid="calendar-source-form" />,
}));
import { fetchSources, deleteSource } from '@/lib/calendar-api';
import type { CalendarSource } from '@/lib/calendar-api';
import { CalendarSettingsPanel } from './calendar-settings-panel';
const baseSource: CalendarSource = {
id: 'src-1',
userId: 'user-1',
tenantId: 'tenant-1',
name: 'Firmenkalender',
type: 'caldav',
url: 'https://example.test/dav',
isVisible: true,
syncIntervalMin: 15,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
async function renderWithOneSource() {
vi.mocked(fetchSources).mockResolvedValue([baseSource]);
render(<CalendarSettingsPanel />);
await screen.findByText('Firmenkalender');
}
describe('CalendarSettingsPanel — delete confirmation dialog (D-01, T-JT4-04)', () => {
it('opens the delete confirmation dialog when clicking the delete icon', async () => {
const user = userEvent.setup();
await renderWithOneSource();
await user.click(screen.getByRole('button', { name: 'Delete Firmenkalender' }));
expect(
screen.getByRole('alertdialog', { name: 'Löschen bestätigen' }),
).toBeInTheDocument();
});
it('mouse-clicking the backdrop aborts: the source is NOT deleted and the dialog closes', async () => {
const user = userEvent.setup();
await renderWithOneSource();
await user.click(screen.getByRole('button', { name: 'Delete Firmenkalender' }));
await user.click(screen.getByRole('button', { name: 'Löschen abbrechen' }));
expect(deleteSource).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
// Source is still listed — the abort path never removed it.
expect(screen.getByText('Firmenkalender')).toBeInTheDocument();
});
it('a real keyboard activation (Enter on the focused backdrop button) also aborts, never deletes', async () => {
const user = userEvent.setup();
await renderWithOneSource();
await user.click(screen.getByRole('button', { name: 'Delete Firmenkalender' }));
const backdrop = screen.getByRole('button', { name: 'Löschen abbrechen' });
backdrop.focus();
await user.keyboard('{Enter}');
expect(deleteSource).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
expect(screen.getByText('Firmenkalender')).toBeInTheDocument();
});
it('clicking the explicit delete CTA inside the dialog deletes the source', async () => {
vi.mocked(deleteSource).mockResolvedValue(undefined);
const user = userEvent.setup();
await renderWithOneSource();
await user.click(screen.getByRole('button', { name: 'Delete Firmenkalender' }));
await user.click(screen.getByRole('button', { name: 'Quelle löschen' }));
await waitFor(() => expect(deleteSource).toHaveBeenCalledWith('src-1'));
await waitFor(() =>
expect(screen.queryByText('Firmenkalender')).not.toBeInTheDocument(),
);
});
it('clicking inside the dialog card itself does not close it or delete anything', async () => {
const user = userEvent.setup();
await renderWithOneSource();
await user.click(screen.getByRole('button', { name: 'Delete Firmenkalender' }));
await user.click(screen.getByRole('alertdialog', { name: 'Löschen bestätigen' }));
expect(deleteSource).not.toHaveBeenCalled();
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});
});
describe('CalendarSettingsPanel — Statussymbole tragen role="img" (D-03)', () => {
it('der Sync-Fehler-Indikator ist per Rolle+Name ansprechbar, statt sein aria-label an einem rollenlosen <span> zu verschwenden', async () => {
vi.mocked(fetchSources).mockResolvedValue([
{ ...baseSource, lastSyncError: 'Verbindung abgelehnt' },
]);
render(<CalendarSettingsPanel />);
await screen.findByText('Firmenkalender');
expect(screen.getByRole('img', { name: 'Synchronisierungsfehler' })).toBeInTheDocument();
});
});
@@ -154,12 +154,17 @@ export function CalendarSettingsPanel() {
{TYPE_LABELS[source.type] || source.type}
</span>
{/* Connection status indicator */}
{/* Connection status indicator. role="img" (D-03): die
Beschriftung ist der EINZIGE Text dieser Symbole (das
<svg> darin ist bereits aria-hidden) — ohne Rolle ist ein
<span> "generic" und die Vorlesehilfe verwirft
aria-label stillschweigend. */}
{source.lastSyncError ? (
<span
title={source.lastSyncError}
className="text-orange-500"
aria-label="Sync error"
role="img"
aria-label={t('calendar.syncErrorLabel')}
>
<svg
aria-hidden="true"
@@ -179,7 +184,11 @@ export function CalendarSettingsPanel() {
</svg>
</span>
) : testResults[source.id] === 'success' ? (
<span className="text-green-500" aria-label="Connection OK">
<span
className="text-green-500"
role="img"
aria-label={t('calendar.connectionSuccess')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
@@ -196,7 +205,11 @@ export function CalendarSettingsPanel() {
</svg>
</span>
) : testResults[source.id] === 'error' ? (
<span className="text-orange-500" aria-label="Connection error">
<span
className="text-orange-500"
role="img"
aria-label={t('calendar.connectionFailedLabel')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
@@ -370,17 +383,21 @@ export function CalendarSettingsPanel() {
{/* Delete confirmation dialog (UI-SPEC destructive pattern) */}
{deletingId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) setDeletingId(null);
}}
>
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Hintergrund — jetzt eine echte, benannte Schaltflaeche. Sie MUSS
abbrechen und darf unter keinen Umstaenden loeschen (T-JT4-04). */}
<button
type="button"
onClick={() => setDeletingId(null)}
aria-label={t('calendar.deleteDialogCancel')}
className="fixed inset-0 bg-black/50"
/>
<div
className="mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
className="relative mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
role="alertdialog"
aria-modal="true"
aria-label="Confirm deletion"
aria-label={t('calendar.deleteDialogLabel')}
>
<h3 className="mb-2 text-lg font-semibold text-foreground">
{ts('sourceDeleteCta')}
@@ -0,0 +1,72 @@
import { act, cleanup, render } from '@testing-library/react';
import { NextIntlClientProvider, useTranslations } from 'next-intl';
import { useState } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
/**
* translations-identity.test.tsx (Restposten 4, quick-260921-jt4)
*
* Prueft am ECHTEN `NextIntlClientProvider` — nicht behauptet, sondern
* gemessen —, ob `t` aus `useTranslations` bei einem Zustandswechsel EINER
* Komponente seine Identitaet behaelt. Der Quelltext der eingesetzten
* `use-intl`-Fassung (4.13.0, `react.js`) zeigt: `translate` entsteht in
* einem `useMemo`, dessen Abhaengigkeiten ausschliesslich aus dem
* Intl-Kontext stammen (`cache, formatters, getMessageFallback, allMessages,
* namespace, onError, globalFormats, locale, timeZone`) — keine davon haengt
* vom lokalen Zustand einer Komponente ab. Der Anbieter (`NextIntlClientProvider`)
* steht in `app/layout.tsx`, also oberhalb aller Komponenten dieses Projekts;
* ein Zustandswechsel in einer Kindkomponente rendert den Anbieter nicht neu.
*
* Dieser eine Test entscheidet die Annahme aus quick-260921-gof
* ("`t` ist bei jedem Render eine frische Funktion") an der Wurzel statt an
* vier einzelnen Symptomstellen.
*/
const messages = {
widgets: {
calendar: {
name: 'Kalender',
},
},
};
function ProbeComponent({ onRender }: { onRender: (t: unknown) => void }) {
const t = useTranslations('widgets');
const [, forceRerender] = useState(0);
onRender(t);
return (
<button type="button" onClick={() => forceRerender((n) => n + 1)}>
{t('calendar.name')}
</button>
);
}
afterEach(() => {
cleanup();
});
describe('t aus useTranslations — Identitaet ueber einen Zustandswechsel (Restposten 4)', () => {
it('bleibt beim erneuten Render derselben Komponente dasselbe Funktionsobjekt, wenn NUR lokaler Zustand wechselt', () => {
const seenTranslators: unknown[] = [];
const { getByRole } = render(
<NextIntlClientProvider locale="de" messages={messages} timeZone="Europe/Berlin">
<ProbeComponent onRender={(t) => seenTranslators.push(t)} />
</NextIntlClientProvider>,
);
expect(seenTranslators).toHaveLength(1);
const tBeforeRerender = seenTranslators[0];
act(() => {
getByRole('button').click();
});
expect(seenTranslators).toHaveLength(2);
const tAfterRerender = seenTranslators[1];
// Der gemessene Kern der Annahme: dasselbe Objekt, nicht nur denselben
// Rueckgabewert bei Aufruf — ein `toBe` (Referenzgleichheit), kein `toEqual`.
expect(tAfterRerender).toBe(tBeforeRerender);
});
});
+18 -5
View File
@@ -206,6 +206,7 @@
"emptyBody": "Klicken Sie auf Bearbeiten, um Widgets hinzuzufügen.",
"addWidget": "Widget hinzufügen",
"catalogTitle": "Widget hinzufügen",
"catalogClose": "Dialog schließen",
"deleteTooltip": "Widget entfernen",
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
"editMode": "Dashboard bearbeiten",
@@ -243,6 +244,8 @@
"upcomingTitle": "Nächste Termine",
"tooltipMore": "Weitere Termine vorhanden",
"allDay": "ganztägig",
"dayEventsOne": "{date}: 1 Termin",
"dayEventsMany": "{date}: {count} Termine",
"configShowMonth": "Monatsansicht anzeigen",
"configMaxEvents": "Anzahl Termine",
"configMaxEventsNone": "Ausblenden",
@@ -254,6 +257,8 @@
"configSourcesLink": "Einstellungen → Dashboard → Kalender",
"connectionSuccess": "Verbindung erfolgreich",
"connectionError": "Verbindung fehlgeschlagen. Bitte überprüfen Sie die URL und Zugangsdaten.",
"syncErrorLabel": "Synchronisierungsfehler",
"connectionFailedLabel": "Verbindung fehlgeschlagen",
"addSource": "Quelle hinzufügen",
"sourceEmpty": "Keine Kalenderquellen eingerichtet. Fügen Sie eine Quelle hinzu, um Termine anzuzeigen.",
"formFieldName": "Name",
@@ -280,7 +285,9 @@
"formUrlErrorHttps": "Die Adresse muss mit https:// beginnen.",
"formUrlErrorInvalid": "Bitte geben Sie eine gültige Adresse ein.",
"saveSuccess": "Kalenderquelle gespeichert.",
"saveError": "Die Kalenderquelle konnte nicht gespeichert werden."
"saveError": "Die Kalenderquelle konnte nicht gespeichert werden.",
"deleteDialogCancel": "Löschen abbrechen",
"deleteDialogLabel": "Löschen bestätigen"
},
"note": {
"name": "Notizen",
@@ -293,7 +300,10 @@
},
"calculator": {
"name": "Taschenrechner",
"description": "Grundrechenarten mit Tastatureingabe"
"description": "Grundrechenarten mit Tastatureingabe",
"displayLabel": "Anzeige",
"memoryLabel": "Speicherfunktionen",
"backspaceLabel": "Rückschritt"
},
"favorites": {
"name": "Favoriten",
@@ -313,7 +323,8 @@
"cancelButton": "Abbrechen",
"error": "Fehler beim Laden der Favoriten",
"titleLabel": "Titel",
"titlePlaceholder": "Titel (optional)"
"titlePlaceholder": "Titel (optional)",
"viewModeLabel": "Ansicht wechseln"
},
"stopwatch": {
"name": "Stoppuhr",
@@ -747,7 +758,8 @@
"accessDenied": "Zugriff verweigert",
"statusNoAccess": "Kein Zugriff",
"toastNoAccess": "Kein Zugriff auf dieses Modul — wenden Sie sich an Ihren Administrator.",
"notFound": "Dieses Modul wurde nicht gefunden."
"notFound": "Dieses Modul wurde nicht gefunden.",
"openDetail": "Details zu {name} öffnen"
},
"certManager": {
"title": "Zertifikat-Manager",
@@ -776,7 +788,8 @@
"convert": "Konvertieren",
"download": "Herunterladen",
"downloadZip": "Alle als ZIP herunterladen",
"processing": "Wird verarbeitet..."
"processing": "Wird verarbeitet...",
"zipFilename": "Zertifikate.zip"
},
"certRole": {
"root": "Root-CA",
+18 -5
View File
@@ -206,6 +206,7 @@
"emptyBody": "Click edit to add widgets to your dashboard.",
"addWidget": "Add widget",
"catalogTitle": "Add widget",
"catalogClose": "Close dialog",
"deleteTooltip": "Remove widget",
"dragHint": "Drag the tile to move it",
"editMode": "Edit dashboard",
@@ -243,6 +244,8 @@
"upcomingTitle": "Upcoming events",
"tooltipMore": "More events available",
"allDay": "all day",
"dayEventsOne": "{date}: 1 event",
"dayEventsMany": "{date}: {count} events",
"configShowMonth": "Show month view",
"configMaxEvents": "Number of events",
"configMaxEventsNone": "Hide",
@@ -254,6 +257,8 @@
"configSourcesLink": "Settings → Dashboard → Calendar",
"connectionSuccess": "Connection successful",
"connectionError": "Connection failed. Please check the URL and credentials.",
"syncErrorLabel": "Sync error",
"connectionFailedLabel": "Connection failed",
"addSource": "Add source",
"sourceEmpty": "No calendar sources configured. Add a source to display events.",
"formFieldName": "Name",
@@ -280,7 +285,9 @@
"formUrlErrorHttps": "The address must start with https://.",
"formUrlErrorInvalid": "Please enter a valid address.",
"saveSuccess": "Calendar source saved.",
"saveError": "The calendar source could not be saved."
"saveError": "The calendar source could not be saved.",
"deleteDialogCancel": "Cancel deletion",
"deleteDialogLabel": "Confirm deletion"
},
"note": {
"name": "Notes",
@@ -293,7 +300,10 @@
},
"calculator": {
"name": "Calculator",
"description": "Basic arithmetic with keyboard support"
"description": "Basic arithmetic with keyboard support",
"displayLabel": "Display",
"memoryLabel": "Memory functions",
"backspaceLabel": "Backspace"
},
"favorites": {
"name": "Favorites",
@@ -313,7 +323,8 @@
"cancelButton": "Cancel",
"error": "Error loading favorites",
"titleLabel": "Title",
"titlePlaceholder": "Title (optional)"
"titlePlaceholder": "Title (optional)",
"viewModeLabel": "Switch view"
},
"stopwatch": {
"name": "Stopwatch",
@@ -747,7 +758,8 @@
"accessDenied": "Access denied",
"statusNoAccess": "No Access",
"toastNoAccess": "No access to this module — please contact your administrator.",
"notFound": "This module was not found."
"notFound": "This module was not found.",
"openDetail": "Open details for {name}"
},
"certManager": {
"title": "Certificate Manager",
@@ -776,7 +788,8 @@
"convert": "Convert",
"download": "Download",
"downloadZip": "Download all as ZIP",
"processing": "Processing..."
"processing": "Processing...",
"zipFilename": "certificates.zip"
},
"certRole": {
"root": "Root CA",