Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d604b855a | |||
| f471b785db | |||
| 6c10c9bbe0 | |||
| 5a03b75f1e | |||
| 69fe706580 | |||
| f7b5df4db8 | |||
| b406a9c5f7 | |||
| 9aa87bd000 | |||
| e651c24647 | |||
| b601141bcf | |||
| 0c89c13bb2 | |||
| 3d0bc0bfaa | |||
| a8531d44df | |||
| 7557c9aafe |
+5
-4
@@ -4,8 +4,8 @@ milestone: v1.2
|
|||||||
current_phase: 18
|
current_phase: 18
|
||||||
current_phase_name: desktop-client-fertigstellen
|
current_phase_name: desktop-client-fertigstellen
|
||||||
status: verified
|
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."
|
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-21T12:45:00.000Z"
|
last_updated: "2026-09-21T13:30:00.000Z"
|
||||||
last_activity: 2026-09-21
|
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
|
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
|
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)
|
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||||
Plan: 6 of 6
|
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
|
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%
|
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-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-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-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
|
## 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.
|
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).
|
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
|
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
|
||||||
|
|||||||
+720
@@ -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>
|
||||||
+275
@@ -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 'rss':
|
||||||
case 'email-alert':
|
case 'email-alert':
|
||||||
return this.normalizeBag(raw);
|
return this.normalizeBag(raw);
|
||||||
case 'doe-opendata':
|
|
||||||
default:
|
default:
|
||||||
// default MUST stay on the DÖE path (never throw) so future
|
// Deckt sowohl 'doe-opendata' als auch jedes kuenftige additive
|
||||||
// additive SourceType union members don't break existing callers.
|
// 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);
|
return this.normalizeDoe(raw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ export default function LoginPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
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"
|
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')}
|
placeholder={t('username')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ export default function ResetPasswordTokenPage() {
|
|||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
autoComplete="new-password"
|
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"
|
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')}
|
placeholder={t('resetPassword.newPassword')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ export default function ResetPasswordPage() {
|
|||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
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"
|
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')}
|
placeholder={t('resetPassword.email')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ export default function ChangePasswordPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
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"
|
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')}
|
placeholder={t('changePassword.currentPassword')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
// Mock next-intl useTranslations
|
// Mock next-intl useTranslations
|
||||||
vi.mock('next-intl', () => ({
|
vi.mock('next-intl', () => ({
|
||||||
useTranslations: () => (key: string) => {
|
useTranslations: () => (key: string, values?: Record<string, string | number>) => {
|
||||||
const translations: Record<string, string> = {
|
const translations: Record<string, string> = {
|
||||||
activate: 'Modul aktivieren',
|
activate: 'Modul aktivieren',
|
||||||
deactivate: 'Deaktivieren',
|
deactivate: 'Deaktivieren',
|
||||||
statusActive: 'Aktiviert',
|
statusActive: 'Aktiviert',
|
||||||
statusAvailable: 'Verfuegbar',
|
statusAvailable: 'Verfuegbar',
|
||||||
statusNoAccess: 'Kein Zugriff',
|
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);
|
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 onOpenDetail = vi.fn();
|
||||||
const onLockedClick = vi.fn();
|
const onLockedClick = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -98,13 +112,13 @@ describe('MarketplaceCard', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('Kein Zugriff')).toBeInTheDocument();
|
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(onLockedClick).toHaveBeenCalledOnce();
|
||||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
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 onOpenDetail = vi.fn();
|
||||||
const onLockedClick = vi.fn();
|
const onLockedClick = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -121,14 +135,80 @@ describe('MarketplaceCard', () => {
|
|||||||
|
|
||||||
expect(screen.queryByText('Kein Zugriff')).not.toBeInTheDocument();
|
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).toHaveBeenCalledOnce();
|
||||||
expect(onOpenDetail).toHaveBeenCalledWith('domaincheck');
|
expect(onOpenDetail).toHaveBeenCalledWith('domaincheck');
|
||||||
expect(onLockedClick).not.toHaveBeenCalled();
|
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 onOpenDetail = vi.fn();
|
||||||
const onLockedClick = vi.fn();
|
const onLockedClick = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -114,16 +114,22 @@ export function MarketplaceCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
|
className={`relative rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
|
||||||
locked
|
locked ? 'opacity-60' : 'hover:shadow-md hover:border-primary/30'
|
||||||
? 'opacity-60 cursor-not-allowed'
|
|
||||||
: isActive
|
|
||||||
? 'hover:shadow-md hover:border-primary/30 cursor-pointer'
|
|
||||||
: 'hover:shadow-md hover:border-primary/30'
|
|
||||||
}`}
|
}`}
|
||||||
style={{ minHeight: '180px' }}
|
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 */}
|
{/* Header: icon + name + badges */}
|
||||||
<div className="flex items-start gap-3 mb-3">
|
<div className="flex items-start gap-3 mb-3">
|
||||||
<div className="rounded-md bg-muted p-2.5 shrink-0">
|
<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 */}
|
{/* Footer: action button — only rendered for users who may activate/deactivate */}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<div className="flex items-center justify-end">
|
<div className="relative flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { zipSync } from 'fflate';
|
import { zipSync } from 'fflate';
|
||||||
import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions';
|
import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions';
|
||||||
|
import { sanitizeZipFilename } from '../zip-filename';
|
||||||
|
|
||||||
interface SplitTabProps {
|
interface SplitTabProps {
|
||||||
file: File | null;
|
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',
|
'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> = {};
|
const files: Record<string, Uint8Array> = {};
|
||||||
for (const cert of certs) {
|
for (const cert of certs) {
|
||||||
const bytes = Uint8Array.from(atob(cert.content), (c) => c.charCodeAt(0));
|
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 url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
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();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
@@ -81,7 +90,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
|
|||||||
{result && result.certs.length > 1 && (
|
{result && result.certs.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="border border-border px-4 py-2 rounded text-sm font-medium hover:bg-secondary transition-colors"
|
||||||
>
|
>
|
||||||
{t('actions.downloadZip')}
|
{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,
|
onAddWidget,
|
||||||
}: WidgetCatalogModalProps) {
|
}: WidgetCatalogModalProps) {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Focus trap + Escape handler
|
// Focus trap + Escape handler
|
||||||
@@ -53,12 +54,16 @@ export function WidgetCatalogModal({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
{/* Hintergrund — jetzt eine echte, benannte Schaltflaeche statt einer rein
|
||||||
onClick={onClose}
|
optischen Flaeche: ein fokussierbares Element darf vor der Vorlesehilfe
|
||||||
>
|
nicht verborgen sein (D-01). */}
|
||||||
{/* Backdrop — visually only, hidden from assistive tech */}
|
<button
|
||||||
<div className="fixed inset-0 bg-black/50" aria-hidden="true" />
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('catalogClose')}
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Dialog */}
|
{/* Dialog */}
|
||||||
<div
|
<div
|
||||||
@@ -67,7 +72,6 @@ export function WidgetCatalogModal({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t('catalogTitle')}
|
aria-label={t('catalogTitle')}
|
||||||
className="relative z-50 w-full max-w-[480px] rounded-lg border bg-card p-6 shadow-xl"
|
className="relative z-50 w-full max-w-[480px] rounded-lg border bg-card p-6 shadow-xl"
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -79,7 +83,7 @@ export function WidgetCatalogModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
aria-label="Close"
|
aria-label={tCommon('close')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
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', () => ({
|
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
|
// Must import after mocks
|
||||||
|
|||||||
@@ -336,13 +336,15 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
|
|||||||
{/* Display */}
|
{/* Display */}
|
||||||
<output
|
<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"
|
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>
|
<span className="truncate">{display}</span>
|
||||||
</output>
|
</output>
|
||||||
|
|
||||||
{/* Memory row */}
|
{/* Memory row — role="toolbar" (D-03): ohne Rolle ist das <div> generic
|
||||||
<div className="flex gap-1" aria-label="Speicherfunktionen">
|
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={memoryClear} disabled={memory === 0}>MC</button>
|
||||||
<button type="button" className={memBtn} onClick={memoryRecall} disabled={memory === 0}>MR</button>
|
<button type="button" className={memBtn} onClick={memoryRecall} disabled={memory === 0}>MR</button>
|
||||||
<button type="button" className={memBtn} onClick={memoryAdd}>M+</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="%" className={utilBtn} onClick={() => applyUnary('percent')} />
|
||||||
<CalcButton label="CE" className={utilBtn} onClick={clearEntry} />
|
<CalcButton label="CE" className={utilBtn} onClick={clearEntry} />
|
||||||
<CalcButton label="C" className={utilBtn} onClick={clearAll} />
|
<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 */}
|
{/* Row 2: unary + divide */}
|
||||||
<CalcButton label="1/x" className={utilBtn} onClick={() => applyUnary('reciprocal')} />
|
<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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { CalendarEvent } from '@/lib/calendar-api';
|
import type { CalendarEvent } from '@/lib/calendar-api';
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ vi.mock('next-intl', () => ({
|
|||||||
'calendar.upcomingTitle': 'Nächste Termine',
|
'calendar.upcomingTitle': 'Nächste Termine',
|
||||||
'calendar.tooltipMore': 'Weitere Termine vorhanden',
|
'calendar.tooltipMore': 'Weitere Termine vorhanden',
|
||||||
'calendar.allDay': 'ganztägig',
|
'calendar.allDay': 'ganztägig',
|
||||||
|
'calendar.dayEventsOne': '{date}: 1 Termin',
|
||||||
|
'calendar.dayEventsMany': '{date}: {count} Termine',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const raw = map[ns]?.[key] ?? key;
|
const raw = map[ns]?.[key] ?? key;
|
||||||
@@ -514,4 +516,196 @@ describe('CalendarWidget', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1));
|
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);
|
} | null>(null);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
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
|
// Befund 1/2 (quick-260921-gof): keine Merkung noetig — resolveCalendarConfig
|
||||||
// ist eine reine Funktion, die nur drei einfache Werte liefert; eine
|
// ist eine reine Funktion, die nur drei einfache Werte liefert; eine
|
||||||
// Merkung um das ganze config-Objekt hat hier nie etwas gespart.
|
// Merkung um das ganze config-Objekt hat hier nie etwas gespart.
|
||||||
@@ -81,13 +93,23 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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 {
|
try {
|
||||||
// Check if user has any sources configured
|
// Restposten 3b: die Quellenliste haengt nicht vom angezeigten Monat
|
||||||
const sources = await fetchSources();
|
// ab — nur holen, wenn sie noch unbekannt ist ODER der Lauf
|
||||||
if (cancelled) return;
|
// 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);
|
setHasSources(false);
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -99,8 +121,26 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
// Fetch aggregated events for the current grid/lookahead window —
|
// Fetch aggregated events for the current grid/lookahead window —
|
||||||
// ALWAYS with both day-boundary ISO strings (T-HTC-03).
|
// ALWAYS with both day-boundary ISO strings (T-HTC-03).
|
||||||
const { from, to } = computeFetchWindow(monthDate, lookaheadDays);
|
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;
|
if (cancelled) return;
|
||||||
|
lastFetchWindowRef.current = { from: fromIso, to: toIso };
|
||||||
setEvents(eventData);
|
setEvents(eventData);
|
||||||
} catch {
|
} catch {
|
||||||
// Silent fail — show empty state
|
// 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(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
loadData();
|
loadData(true);
|
||||||
}, 300_000);
|
}, 300_000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -249,20 +294,8 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
return (
|
const cellContent = (
|
||||||
<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)}
|
|
||||||
>
|
|
||||||
<span>{day.date.getDate()}</span>
|
<span>{day.date.getDate()}</span>
|
||||||
{hasEvents && (
|
{hasEvents && (
|
||||||
// Wrapper traegt die Positionierung, die einzelnen Kreise nicht
|
// Wrapper traegt die Positionierung, die einzelnen Kreise nicht
|
||||||
@@ -298,6 +331,61 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -263,7 +263,16 @@ export function FavoritesWidget({
|
|||||||
<div className="flex flex-1 flex-col gap-2 overflow-auto p-1">
|
<div className="flex flex-1 flex-col gap-2 overflow-auto p-1">
|
||||||
{/* View mode toggle (edit mode only) */}
|
{/* View mode toggle (edit mode only) */}
|
||||||
{isEditMode && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleViewMode('list')}
|
onClick={() => handleViewMode('list')}
|
||||||
@@ -463,6 +472,7 @@ function FavoriteIcon({
|
|||||||
data-testid={`icon-proxy-${fav.id}`}
|
data-testid={`icon-proxy-${fav.id}`}
|
||||||
src={proxySrc}
|
src={proxySrc}
|
||||||
alt=""
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@@ -475,6 +485,7 @@ function FavoriteIcon({
|
|||||||
data-testid={`icon-direct-${fav.id}`}
|
data-testid={`icon-direct-${fav.id}`}
|
||||||
src={directSrc}
|
src={directSrc}
|
||||||
alt=""
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { render } from '@testing-library/react';
|
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 MDEditor from '@uiw/react-md-editor';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
import { NoteCheckbox, isTaskLine, toggleTaskLine } from './note-task-list';
|
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)', () => {
|
describe('echte Vorschau (MDEditor.Markdown + rehypeSanitize + components-Override)', () => {
|
||||||
it('rendert genau die Aufgabenzeilen als anklickbare Kaestchen', () => {
|
it('rendert genau die Aufgabenzeilen als anklickbare Kaestchen', () => {
|
||||||
const SRC =
|
const SRC =
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
* Anwenders (user-files/personal-dashboard/src/app/page.tsx, ~Z. 1399).
|
* Anwenders (user-files/personal-dashboard/src/app/page.tsx, ~Z. 1399).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erkennt eine Markdown-Aufgabenzeile (`- [ ] Text`, `* [x] Text`,
|
* Erkennt eine Markdown-Aufgabenzeile (`- [ ] Text`, `* [x] Text`,
|
||||||
* `+ [X] Text`, `1. [ ] Text`, `1) [ ] Text`, auch eingerueckt). Bewusst
|
* `+ [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
|
* Zieht bewusst NUR `checked` aus den Props (react-markdown reicht
|
||||||
* zusaetzlich `node`, `disabled`, `type` durch) — sonst landen fremde
|
* zusaetzlich `node`, `disabled`, `type` durch) — sonst landen fremde
|
||||||
* Attribute (insbesondere `node`) im DOM. Kein `disabled`: das Kaestchen
|
* Attribute (insbesondere `node`) im DOM. Kein `disabled`: das Kaestchen
|
||||||
* bleibt anklickbar. `readOnly` unterdrueckt Reacts Warnung
|
* bleibt anklickbar.
|
||||||
* "checked ohne onChange" — der Klick wird nicht am Kaestchen selbst,
|
*
|
||||||
* sondern delegiert am Vorschau-Container verarbeitet.
|
* 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 (
|
return (
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!checked}
|
checked={!!checked}
|
||||||
readOnly
|
onChange={() => {
|
||||||
|
if (inputRef.current) onToggle?.(inputRef.current);
|
||||||
|
}}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
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';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// Mock next-intl
|
// Mock next-intl
|
||||||
@@ -300,6 +301,57 @@ describe('NoteWidget', () => {
|
|||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
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', () => {
|
it('setzt data-color-mode auf dark, wenn Tessera auf Dunkel steht', () => {
|
||||||
themeMock.resolvedTheme = 'dark';
|
themeMock.resolvedTheme = 'dark';
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import MDEditor, { commands } from '@uiw/react-md-editor';
|
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 —
|
// quick-260916-iex: Aufgabenlisten sind in der Ansicht direkt abhakbar —
|
||||||
// `components.input` ersetzt das per rehypeSanitize deaktivierte `input`
|
// `components.input` ersetzt das per rehypeSanitize deaktivierte `input`
|
||||||
// durch NoteCheckbox (siehe note-task-list.tsx). Stabile Konstante ausserhalb
|
// durch NoteCheckbox (siehe note-task-list.tsx). Typ direkt von MDEditor
|
||||||
// der Komponente, damit react-markdown nicht bei jedem Render neu diffed.
|
// abgeleitet statt aus dem transitiven Paket `@uiw/react-markdown-preview`
|
||||||
// Typ direkt von MDEditor abgeleitet statt aus dem transitiven Paket
|
// importiert (das ist keine direkte Dependency von apps/web und daher unter
|
||||||
// `@uiw/react-markdown-preview` importiert (das ist keine direkte
|
// pnpms strikter Isolation nicht von hier aus aufloesbar).
|
||||||
// Dependency von apps/web und daher unter pnpms strikter Isolation nicht
|
|
||||||
// von hier aus aufloesbar).
|
|
||||||
type PreviewOptions = NonNullable<
|
type PreviewOptions = NonNullable<
|
||||||
React.ComponentProps<typeof MDEditor>['previewOptions']
|
React.ComponentProps<typeof MDEditor>['previewOptions']
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const PREVIEW_OPTIONS: PreviewOptions = {
|
|
||||||
rehypePlugins: [[rehypeSanitize]],
|
|
||||||
components: { input: NoteCheckbox },
|
|
||||||
};
|
|
||||||
|
|
||||||
const NOTE_COMMANDS = [
|
const NOTE_COMMANDS = [
|
||||||
commands.bold,
|
commands.bold,
|
||||||
commands.italic,
|
commands.italic,
|
||||||
@@ -113,23 +106,27 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
[scheduleSave, content],
|
[scheduleSave, content],
|
||||||
);
|
);
|
||||||
|
|
||||||
// quick-260916-iex: Klick auf ein Aufgaben-Kaestchen in der Ansicht kippt
|
// Vorschau-Behaelter: die Index-Ermittlung (n-tes Kaestchen im Behaelter ==
|
||||||
// genau diese Zeile und speichert sofort (kein Entprellen — ein Klick ist
|
// n-te Aufgabenzeile) bleibt Wort fuer Wort wie zuvor, findet den Behaelter
|
||||||
// eine abgeschlossene Handlung). Ein noch laufender Entprell-Timer aus dem
|
// jetzt aber ueber ein ref statt ueber event.currentTarget, weil der Klick
|
||||||
// Tippen wird verworfen, damit er den ungekippten Text nicht nachschiebt.
|
// ab jetzt am Kaestchen selbst entsteht (quick-260921-jt4, D-01: das
|
||||||
const handlePreviewClick = useCallback(
|
// Kaestchen bedient sich selbst statt vom Behaelter bedient zu werden).
|
||||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
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;
|
if (isEditing) return;
|
||||||
const target = event.target;
|
const container = previewRef.current;
|
||||||
if (!(target instanceof HTMLInputElement) || target.type !== 'checkbox') {
|
if (!container) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const boxes = Array.from(
|
const boxes = Array.from(
|
||||||
event.currentTarget.querySelectorAll<HTMLInputElement>(
|
container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'),
|
||||||
'input[type="checkbox"]',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const index = boxes.indexOf(target);
|
const index = boxes.indexOf(element);
|
||||||
const next = toggleTaskLine(content, index);
|
const next = toggleTaskLine(content, index);
|
||||||
if (next === content) return;
|
if (next === content) return;
|
||||||
setContent(next);
|
setContent(next);
|
||||||
@@ -139,6 +136,30 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
[isEditing, content, title, save],
|
[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 (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
|
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
|
||||||
{/* Header: title + pencil toggle */}
|
{/* Header: title + pencil toggle */}
|
||||||
@@ -184,7 +205,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
<div
|
<div
|
||||||
className="flex-1 overflow-auto"
|
className="flex-1 overflow-auto"
|
||||||
data-testid="note-preview"
|
data-testid="note-preview"
|
||||||
onClick={handlePreviewClick}
|
ref={previewRef}
|
||||||
>
|
>
|
||||||
<MDEditor
|
<MDEditor
|
||||||
data-testid="md-editor"
|
data-testid="md-editor"
|
||||||
@@ -195,7 +216,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
hideToolbar={!isEditing}
|
hideToolbar={!isEditing}
|
||||||
height="100%"
|
height="100%"
|
||||||
visibleDragbar={false}
|
visibleDragbar={false}
|
||||||
previewOptions={PREVIEW_OPTIONS}
|
previewOptions={previewOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export function Header() {
|
|||||||
<img
|
<img
|
||||||
src={`/api-proxy/users/me/avatar?v=${avatarVersion}`}
|
src={`/api-proxy/users/me/avatar?v=${avatarVersion}`}
|
||||||
alt=""
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
className="h-8 w-8 rounded-full object-cover"
|
className="h-8 w-8 rounded-full object-cover"
|
||||||
onError={() => setAvatarError(true)}
|
onError={() => setAvatarError(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export function AccountSettingsForm() {
|
|||||||
key={avatarKey}
|
key={avatarKey}
|
||||||
src={`/api-proxy/users/me/avatar?t=${avatarKey}`}
|
src={`/api-proxy/users/me/avatar?t=${avatarKey}`}
|
||||||
alt=""
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
className="h-16 w-16 rounded-full object-cover"
|
className="h-16 w-16 rounded-full object-cover"
|
||||||
onError={() => setHasAvatar(false)}
|
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}
|
{TYPE_LABELS[source.type] || source.type}
|
||||||
</span>
|
</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 ? (
|
{source.lastSyncError ? (
|
||||||
<span
|
<span
|
||||||
title={source.lastSyncError}
|
title={source.lastSyncError}
|
||||||
className="text-orange-500"
|
className="text-orange-500"
|
||||||
aria-label="Sync error"
|
role="img"
|
||||||
|
aria-label={t('calendar.syncErrorLabel')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -179,7 +184,11 @@ export function CalendarSettingsPanel() {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
) : testResults[source.id] === 'success' ? (
|
) : 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
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -196,7 +205,11 @@ export function CalendarSettingsPanel() {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
) : testResults[source.id] === 'error' ? (
|
) : 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
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -370,17 +383,21 @@ export function CalendarSettingsPanel() {
|
|||||||
|
|
||||||
{/* Delete confirmation dialog (UI-SPEC destructive pattern) */}
|
{/* Delete confirmation dialog (UI-SPEC destructive pattern) */}
|
||||||
{deletingId && (
|
{deletingId && (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
{/* Hintergrund — jetzt eine echte, benannte Schaltflaeche. Sie MUSS
|
||||||
onClick={(e) => {
|
abbrechen und darf unter keinen Umstaenden loeschen (T-JT4-04). */}
|
||||||
if (e.target === e.currentTarget) setDeletingId(null);
|
<button
|
||||||
}}
|
type="button"
|
||||||
>
|
onClick={() => setDeletingId(null)}
|
||||||
|
aria-label={t('calendar.deleteDialogCancel')}
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<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"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Confirm deletion"
|
aria-label={t('calendar.deleteDialogLabel')}
|
||||||
>
|
>
|
||||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||||
{ts('sourceDeleteCta')}
|
{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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -206,6 +206,7 @@
|
|||||||
"emptyBody": "Klicken Sie auf Bearbeiten, um Widgets hinzuzufügen.",
|
"emptyBody": "Klicken Sie auf Bearbeiten, um Widgets hinzuzufügen.",
|
||||||
"addWidget": "Widget hinzufügen",
|
"addWidget": "Widget hinzufügen",
|
||||||
"catalogTitle": "Widget hinzufügen",
|
"catalogTitle": "Widget hinzufügen",
|
||||||
|
"catalogClose": "Dialog schließen",
|
||||||
"deleteTooltip": "Widget entfernen",
|
"deleteTooltip": "Widget entfernen",
|
||||||
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
|
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
|
||||||
"editMode": "Dashboard bearbeiten",
|
"editMode": "Dashboard bearbeiten",
|
||||||
@@ -243,6 +244,8 @@
|
|||||||
"upcomingTitle": "Nächste Termine",
|
"upcomingTitle": "Nächste Termine",
|
||||||
"tooltipMore": "Weitere Termine vorhanden",
|
"tooltipMore": "Weitere Termine vorhanden",
|
||||||
"allDay": "ganztägig",
|
"allDay": "ganztägig",
|
||||||
|
"dayEventsOne": "{date}: 1 Termin",
|
||||||
|
"dayEventsMany": "{date}: {count} Termine",
|
||||||
"configShowMonth": "Monatsansicht anzeigen",
|
"configShowMonth": "Monatsansicht anzeigen",
|
||||||
"configMaxEvents": "Anzahl Termine",
|
"configMaxEvents": "Anzahl Termine",
|
||||||
"configMaxEventsNone": "Ausblenden",
|
"configMaxEventsNone": "Ausblenden",
|
||||||
@@ -254,6 +257,8 @@
|
|||||||
"configSourcesLink": "Einstellungen → Dashboard → Kalender",
|
"configSourcesLink": "Einstellungen → Dashboard → Kalender",
|
||||||
"connectionSuccess": "Verbindung erfolgreich",
|
"connectionSuccess": "Verbindung erfolgreich",
|
||||||
"connectionError": "Verbindung fehlgeschlagen. Bitte überprüfen Sie die URL und Zugangsdaten.",
|
"connectionError": "Verbindung fehlgeschlagen. Bitte überprüfen Sie die URL und Zugangsdaten.",
|
||||||
|
"syncErrorLabel": "Synchronisierungsfehler",
|
||||||
|
"connectionFailedLabel": "Verbindung fehlgeschlagen",
|
||||||
"addSource": "Quelle hinzufügen",
|
"addSource": "Quelle hinzufügen",
|
||||||
"sourceEmpty": "Keine Kalenderquellen eingerichtet. Fügen Sie eine Quelle hinzu, um Termine anzuzeigen.",
|
"sourceEmpty": "Keine Kalenderquellen eingerichtet. Fügen Sie eine Quelle hinzu, um Termine anzuzeigen.",
|
||||||
"formFieldName": "Name",
|
"formFieldName": "Name",
|
||||||
@@ -280,7 +285,9 @@
|
|||||||
"formUrlErrorHttps": "Die Adresse muss mit https:// beginnen.",
|
"formUrlErrorHttps": "Die Adresse muss mit https:// beginnen.",
|
||||||
"formUrlErrorInvalid": "Bitte geben Sie eine gültige Adresse ein.",
|
"formUrlErrorInvalid": "Bitte geben Sie eine gültige Adresse ein.",
|
||||||
"saveSuccess": "Kalenderquelle gespeichert.",
|
"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": {
|
"note": {
|
||||||
"name": "Notizen",
|
"name": "Notizen",
|
||||||
@@ -293,7 +300,10 @@
|
|||||||
},
|
},
|
||||||
"calculator": {
|
"calculator": {
|
||||||
"name": "Taschenrechner",
|
"name": "Taschenrechner",
|
||||||
"description": "Grundrechenarten mit Tastatureingabe"
|
"description": "Grundrechenarten mit Tastatureingabe",
|
||||||
|
"displayLabel": "Anzeige",
|
||||||
|
"memoryLabel": "Speicherfunktionen",
|
||||||
|
"backspaceLabel": "Rückschritt"
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
"name": "Favoriten",
|
"name": "Favoriten",
|
||||||
@@ -313,7 +323,8 @@
|
|||||||
"cancelButton": "Abbrechen",
|
"cancelButton": "Abbrechen",
|
||||||
"error": "Fehler beim Laden der Favoriten",
|
"error": "Fehler beim Laden der Favoriten",
|
||||||
"titleLabel": "Titel",
|
"titleLabel": "Titel",
|
||||||
"titlePlaceholder": "Titel (optional)"
|
"titlePlaceholder": "Titel (optional)",
|
||||||
|
"viewModeLabel": "Ansicht wechseln"
|
||||||
},
|
},
|
||||||
"stopwatch": {
|
"stopwatch": {
|
||||||
"name": "Stoppuhr",
|
"name": "Stoppuhr",
|
||||||
@@ -747,7 +758,8 @@
|
|||||||
"accessDenied": "Zugriff verweigert",
|
"accessDenied": "Zugriff verweigert",
|
||||||
"statusNoAccess": "Kein Zugriff",
|
"statusNoAccess": "Kein Zugriff",
|
||||||
"toastNoAccess": "Kein Zugriff auf dieses Modul — wenden Sie sich an Ihren Administrator.",
|
"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": {
|
"certManager": {
|
||||||
"title": "Zertifikat-Manager",
|
"title": "Zertifikat-Manager",
|
||||||
@@ -776,7 +788,8 @@
|
|||||||
"convert": "Konvertieren",
|
"convert": "Konvertieren",
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"downloadZip": "Alle als ZIP herunterladen",
|
"downloadZip": "Alle als ZIP herunterladen",
|
||||||
"processing": "Wird verarbeitet..."
|
"processing": "Wird verarbeitet...",
|
||||||
|
"zipFilename": "Zertifikate.zip"
|
||||||
},
|
},
|
||||||
"certRole": {
|
"certRole": {
|
||||||
"root": "Root-CA",
|
"root": "Root-CA",
|
||||||
|
|||||||
@@ -206,6 +206,7 @@
|
|||||||
"emptyBody": "Click edit to add widgets to your dashboard.",
|
"emptyBody": "Click edit to add widgets to your dashboard.",
|
||||||
"addWidget": "Add widget",
|
"addWidget": "Add widget",
|
||||||
"catalogTitle": "Add widget",
|
"catalogTitle": "Add widget",
|
||||||
|
"catalogClose": "Close dialog",
|
||||||
"deleteTooltip": "Remove widget",
|
"deleteTooltip": "Remove widget",
|
||||||
"dragHint": "Drag the tile to move it",
|
"dragHint": "Drag the tile to move it",
|
||||||
"editMode": "Edit dashboard",
|
"editMode": "Edit dashboard",
|
||||||
@@ -243,6 +244,8 @@
|
|||||||
"upcomingTitle": "Upcoming events",
|
"upcomingTitle": "Upcoming events",
|
||||||
"tooltipMore": "More events available",
|
"tooltipMore": "More events available",
|
||||||
"allDay": "all day",
|
"allDay": "all day",
|
||||||
|
"dayEventsOne": "{date}: 1 event",
|
||||||
|
"dayEventsMany": "{date}: {count} events",
|
||||||
"configShowMonth": "Show month view",
|
"configShowMonth": "Show month view",
|
||||||
"configMaxEvents": "Number of events",
|
"configMaxEvents": "Number of events",
|
||||||
"configMaxEventsNone": "Hide",
|
"configMaxEventsNone": "Hide",
|
||||||
@@ -254,6 +257,8 @@
|
|||||||
"configSourcesLink": "Settings → Dashboard → Calendar",
|
"configSourcesLink": "Settings → Dashboard → Calendar",
|
||||||
"connectionSuccess": "Connection successful",
|
"connectionSuccess": "Connection successful",
|
||||||
"connectionError": "Connection failed. Please check the URL and credentials.",
|
"connectionError": "Connection failed. Please check the URL and credentials.",
|
||||||
|
"syncErrorLabel": "Sync error",
|
||||||
|
"connectionFailedLabel": "Connection failed",
|
||||||
"addSource": "Add source",
|
"addSource": "Add source",
|
||||||
"sourceEmpty": "No calendar sources configured. Add a source to display events.",
|
"sourceEmpty": "No calendar sources configured. Add a source to display events.",
|
||||||
"formFieldName": "Name",
|
"formFieldName": "Name",
|
||||||
@@ -280,7 +285,9 @@
|
|||||||
"formUrlErrorHttps": "The address must start with https://.",
|
"formUrlErrorHttps": "The address must start with https://.",
|
||||||
"formUrlErrorInvalid": "Please enter a valid address.",
|
"formUrlErrorInvalid": "Please enter a valid address.",
|
||||||
"saveSuccess": "Calendar source saved.",
|
"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": {
|
"note": {
|
||||||
"name": "Notes",
|
"name": "Notes",
|
||||||
@@ -293,7 +300,10 @@
|
|||||||
},
|
},
|
||||||
"calculator": {
|
"calculator": {
|
||||||
"name": "Calculator",
|
"name": "Calculator",
|
||||||
"description": "Basic arithmetic with keyboard support"
|
"description": "Basic arithmetic with keyboard support",
|
||||||
|
"displayLabel": "Display",
|
||||||
|
"memoryLabel": "Memory functions",
|
||||||
|
"backspaceLabel": "Backspace"
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
"name": "Favorites",
|
"name": "Favorites",
|
||||||
@@ -313,7 +323,8 @@
|
|||||||
"cancelButton": "Cancel",
|
"cancelButton": "Cancel",
|
||||||
"error": "Error loading favorites",
|
"error": "Error loading favorites",
|
||||||
"titleLabel": "Title",
|
"titleLabel": "Title",
|
||||||
"titlePlaceholder": "Title (optional)"
|
"titlePlaceholder": "Title (optional)",
|
||||||
|
"viewModeLabel": "Switch view"
|
||||||
},
|
},
|
||||||
"stopwatch": {
|
"stopwatch": {
|
||||||
"name": "Stopwatch",
|
"name": "Stopwatch",
|
||||||
@@ -747,7 +758,8 @@
|
|||||||
"accessDenied": "Access denied",
|
"accessDenied": "Access denied",
|
||||||
"statusNoAccess": "No Access",
|
"statusNoAccess": "No Access",
|
||||||
"toastNoAccess": "No access to this module — please contact your administrator.",
|
"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": {
|
"certManager": {
|
||||||
"title": "Certificate Manager",
|
"title": "Certificate Manager",
|
||||||
@@ -776,7 +788,8 @@
|
|||||||
"convert": "Convert",
|
"convert": "Convert",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadZip": "Download all as ZIP",
|
"downloadZip": "Download all as ZIP",
|
||||||
"processing": "Processing..."
|
"processing": "Processing...",
|
||||||
|
"zipFilename": "certificates.zip"
|
||||||
},
|
},
|
||||||
"certRole": {
|
"certRole": {
|
||||||
"root": "Root CA",
|
"root": "Root CA",
|
||||||
|
|||||||
Reference in New Issue
Block a user