Compare commits

..
31 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 594aa9196a Datenschutz Weg B: PDF aus der freigegebenen Fassung (Stand 22.10.2026) gedruckt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:01:19 +00:00
ClaudeandClaude Sonnet 5.5 3b5e1d559a Datenschutz Weg B (E4): Forensatz sagt "sind angewiesen" statt "nehmen auf"
Der Hinweis in der E-Mail zu einem Forenbeitrag ist eine Arbeitsanweisung. Der Dienst legt den Baustein nicht in den Pult-Eintrag und
erfasst keine von Hand geschriebene Mail; der Text sagt es darum wie im Absatz davor: Die Mitarbeiter sind angewiesen, den Hinweis
aufzunehmen. Der Test verlangt den neuen Satz und verbietet den alten. Kommentar zur Forenerkennung des Dienstes nachgezogen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:41:20 +00:00
ClaudeandClaude Sonnet 5.5 c6e3c47be7 Datenschutz Weg B (E4): Beiträge aus Frage-Foren beantworten wir nicht im Forum
Abschnitt 11 sagt, was der Dienst seit E4 tut: Für Beiträge in den Frage-Foren (gutefrage.net,
forum-hilfe.de, wer-weiss-was.de, wpde.org) entwirft die KI keine Antwort; ein Mitarbeiter sieht
den Beitrag an und meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich um Angebote
per E-Mail bittet (mit dem Hinweis am Ende), sonst nicht. Der Satz über öffentlich sichtbare
Antworten in Frage-Foren entfällt, Abschnitt 14 sagt "höchstens eine Antwort". Der Test pinnt
die Sätze, die Forenliste (gleich in Quelle und Regel) und dass nirgends steht, wir antworteten
im Forum.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:26:18 +00:00
ClaudeandClaude Sonnet 5.5 b73cd340bc Datenschutz Weg B (E2): aussortierte Anfragen spätestens am Tag nach dem Fund kürzen
Abschnitt 11 sagt jetzt, was der Dienst seit Regel C tut: Anfragen, die die KI als nicht
passend aussortiert, werden spätestens am Tag nach dem Fund gekürzt (erster täglicher
Kürzlauf nach dem Berliner Kalendertag des Fundes). 30 Tage gelten nur noch nach einer
Absage, gerechnet ab dem Lesen. Der Zweck "Mitarbeiter sehen nach, ob die KI zu Unrecht
aussortiert hat" und der Satz "Bis dahin speichern wir sie" entfallen. Die Fristenliste und
der Test legal_datenschutz_anfragen sind angepasst (Liste wörtlich, nur noch ein Eintrag mit
30 Tagen, entfallene Sätze verboten).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:24:28 +00:00
ClaudeandClaude Sonnet 5.5 53ae593860 Datenschutz Weg B: zwei Sätze lesbarer (Plattform-Hinweise, Firmenadresse im Widerspruchskasten)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:47:06 +00:00
ClaudeandClaude Sonnet 5.5 fd5994c243 Datenschutz Weg B nach zweiter Prüfung: Firmen-Domain, Postfach, Zwecke, verständlichere Fristen
- Prüfwerte nennen die Firmen-Domain (bei Firmenadressen, nicht bei Freemail); der Widerspruchskasten sagt, dass die Sperre dann auch die Domain trifft
- Postfach: der Dienst lädt jede neue Mail zur Prüfung des Absenders, gespeichert werden nur die Mails der Plattformen
- Name: "Wir lesen Ihren Namen nicht gesondert aus"; Zweck der 30 Tage (Fehlurteile der KI prüfen) und der Reste (Auswertung) genannt
- Hinweis: Mitarbeiter nehmen ihn in jede Antwort auf, auch in selbst geschriebene; Frage-Foren: Antwort öffentlich sichtbar
- Widerspruchskasten nennt die Plattform-Mail im Postfach (bis 90 Tage nach Eingang)
- Verständlich: "Fund" erklärt, Fristen als Liste, robots.txt umschrieben, Absätze höchstens 130 Wörter; Hinweis im Dienst folgt (02a0b3c, Nachfolger)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:45:32 +00:00
ClaudeandClaude Sonnet 5.5 5c49f63616 Datenschutz Weg B: Alarm-Postfach bei Hetzner, Sicherungen bis zu sieben Tage, keine [OFFEN]-Markierung mehr
Abschnitt 11: Das Alarm-Postfach ist support@webklar.com bei der Hetzner Online GmbH (Abschnitt 8); der Dienst liest dort nur den Posteingang
und übernimmt nur die E-Mails der Plattformen; diese löschen wir im Postfach spätestens 90 Tage nach dem Eingang (keine "Postfach-Regel"
versprochen). Gekürzte Daten bleiben in den Datensicherungen bis zu sieben Tage, wie im übrigen Text. Der Hinweis in der Antwort heißt jetzt
"Am Ende unserer Antwort steht ein Hinweis" und nennt, dass die Mitarbeiter angewiesen sind, ihn beim Bearbeiten nicht zu entfernen; er ist
wörtlich auf Claras Hinweis im Dienst abgestimmt. Abschnitt 12: Hetzner nennt das Postfach, der eigene Listenpunkt entfällt.

Tests: jede [OFFEN]-Markierung (auch im Kommentar) und jeder Span class="offen" macht legalTexts rot; Pins für die neuen Sätze. Das Upload-Skript
bricht weiter ab, solange eine Markierung steht. pdf_pruefsumme bleibt rot bis zum Neudruck des PDF am Pushtag.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:18:18 +00:00
ClaudeandClaude Sonnet 5.5 23ab43f9fe Datenschutz Weg B nach Prüfung: Zusagen auf den Dienst begrenzt, [OFFEN]-Markierungen, Upload-Sperren
Abschnitt 11: Sperre und Kürzung ab dem Lesen unserer Antwort, nur je Beitrag und E-Mail-Adresse (neues Gesuch = neuer Beitrag);
aussortierte Anfragen 30 Tage; feste Ausschlussliste der Websuche samt robots.txt; Kriterium für die Dauer der Reste; Kopie der
Standardvertragsklauseln; Hinweis in der ersten Nachricht nennt den Widerspruch; Abschnitt 11 und 14: die KI sortiert aus, ein
Mitarbeiter entscheidet über jede Antwort. Alarm-Postfach-Anbieter und Aufbewahrung der Server-Sicherungen stehen als sichtbare
[OFFEN]-Markierung im Text (auch in Abschnitt 12).

Tests: Pins angepasst, Markierungen nur die zwei erlaubten, neuer Test für Zusagen in Reichweite des Dienstes. Upload-Skript bricht ab,
solange eine [OFFEN]-Markierung steht oder die Prüfsumme neben dem PDF nicht zur HTML passt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:57:18 +00:00
ClaudeandClaude Sonnet 5.5 b3ca84a8e5 Datenschutz-Upload: Vermerk "Abschnitt 11 Interessenten (Antworten auf öffentliche Anfragen)", Test prüft den vollen Wortlaut
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:10:22 +00:00
ClaudeandClaude Sonnet 5.5 6a25df22fb Tests: legal_datenschutz_anfragen (Abschnitt 11 Weg B, Stand 22.10.2026), Titelliste und Stand-Pin angepasst
Drei neue Tests: tragende Wendungen von Abschnitt 11, keine Stufe-2-Reste und keine offenen Markierungen, Abschnitt 2, 12 und 14 passend zu 11.
Der alte Pin "Stand 17.10.2026" prüft nur noch "nicht vor der Reichweitenmessung". Die Titelliste kennt die neuen Überschriften von 11 und 14.
pdf_pruefsumme bleibt rot, bis das PDF neu gedruckt ist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:10:22 +00:00
ClaudeandClaude Sonnet 5.5 e554071739 Datenschutz Stand 22.10.2026: Abschnitt 11 Interessenten nur für den Anfrage-Weg (Weg B), Anker #interessenten
Abschnitt 11 beschreibt nur, was heute läuft: öffentliche Gesuche (Alarm-E-Mails der Plattformen, Websuche), KI bewertet und entwirft, ein
Mitarbeiter sendet einmal von Hand. Daten, Rechtsgrundlage, Anthropic, Fristen (90/30 Tage), Hash-Werte, Kopien und Widerspruch.
Abschnitt 2 (auch Privatpersonen aus Foren), 12 (Anthropic-Zweck, Plattformen) und 14 (keine Einzelentscheidung nach Art. 22) angepasst.
Kein Resend, Cloudflare, Overpass und kein Kaltmail-Weg: die volle Fassung bleibt auf feature/workers-datenschutz für Stufe 2.
PDF und Prüfsumme sind noch nicht neu gedruckt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:10:22 +00:00
Claude 0fbeadd0c1 Rechtliches: Skript zum Hochladen der Datenschutzerklärung als neue Version
Der Ablauf des Vorbilds (hochladen) ist jetzt eine exportierte, parametrisierte Funktion; datenschutz-hochladen.mjs ruft sie mit Karte datenschutz, PDF und HTML auf und bricht ab, wenn der Stand in der HTML nicht der Freigabe entspricht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 06676c459d0ac4f534aed7d616e2c0d40ac97b97)
2026-10-22 13:03:30 +00:00
ClaudeandClaude Opus 5.5 dd062b1f15 Analyse-Skript: bei SPA-Seitenwechseln die Verweildauer der vorigen Seite senden
Router-Links (z. B. React Router auf webklar.com) verhindern den Standard-Klick, dann griff onLinkClick nicht
und das en der vorigen Seite ging verloren. Jetzt sendet der Seitenwechsel-Timer zuerst en (nur mit
Einwilligung, einmal je Seite) und dann das pv der neuen Seite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:37:55 +00:00
ClaudeandClaude Opus 5.5 07a4e153bf Datenschutzerklärung Stand 17.10.2026: Abschnitt 4a Reichweitenmessung auf webklar.com, PDF neu
- Abschnitt 4: eigene Website-Analyse auf webklar.com statt "keine Analyse-Werkzeuge", Eintrag wk-consent.
- Abschnitt 4a: Rolle (WEBklar selbst Verantwortlicher), Daten ohne und mit Einwilligung, Rechtsgrundlagen,
  Widerruf, Fristen, Verweis auf webklar.com/datenschutz-analyse.html.
- Abschnitt 5: Kontaktformular-Anfragen gehen seit 17.10.2026 auch per Mail an das eigene Postfach.
- Abschnitt 13: Einwilligung nur für die erweiterte Reichweitenmessung.
- Archiv: Banner-Fassung eigen 2026-10-17 und Kopie der Zusatzseite von webklar.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:34:06 +00:00
ClaudeandClaude Opus 5.5 9ed295946c Website-Analyse für webklar.com: Sie-Fassung des Banners, fester Messdienst-Eintrag, Auswertung fürs WOMS
- Messskript: data-variant="eigen" zeigt eine eigene Banner-Fassung (Sie-Form, WEBklar misst selbst,
  BANNER_VERSION_EIGEN 2026-10-17); Kunden-Websites bleiben bei der Fassung 2026-10-02.
- config.analyse.eigeneWebsite: fester Schlüssel und Domains von webklar.com; ensureEigeneWebsite zieht den
  Eintrag im Messdienst täglich nach (aktiv: false schaltet ab). Kein Kunde, kein Abo, keine Tickets.
- /api/admin/analyse-eigen (jeder Mitarbeiter): Kennzahlen 7/30/90 Tage und Heatmap-Link auf webklar.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:34:06 +00:00
ClaudeandClaude Sonnet 5.5 f3f8ac3777 Kundenansicht neu: doppelte Klasse .kn-nur-lesen (ansichten.css) durch die vorhandene .kn-nur-leser (huelle.css) ersetzt
Die Stufennamen im Fortschrittsbalken der Zeitleiste (nur für Vorlesegeräte) tragen jetzt .kn-nur-leser; die zweite,
gleichwertige Regel .kn-nur-lesen in ansichten.css ist gestrichen. Tests: DOM- und Quelltext-Test (keine Spur von
.kn-nur-lesen in Stil und Modul), Stil-Test sucht die Klassenregeln auch in huelle.css.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:48:56 +00:00
ClaudeandClaude Sonnet 5.5 ace4c85e72 Kundenansicht neu (Aufträge): Zeitleiste barrierefrei, "mehr anzeigen" mit Auftragstitel im Namen und aria-controls, "Im Chat antworten" bricht nicht um
Der Knopf "mehr anzeigen"/"weniger anzeigen" bekommt einen aria-label mit dem Auftragstitel (der sichtbare Text steht vorn im
Namen, der Titel nur als Text) und aria-controls auf die Liste seines Auftrags (eindeutige Id je Liste); beides zieht beim Umschalten mit.
Der Linktext "Im Chat antworten" trägt white-space: nowrap und bleibt in einer Zeile beisammen.
Tests: DOM-Test für Name, Verknüpfung, Umschalten, fremden Titel und Ersatztitel; CSS-Test für nowrap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:48:25 +00:00
ClaudeandClaude Sonnet 5.5 14dc2ad1bf Kundenansicht neu (Aufträge): Aufträge mit Rückfrage vor den übrigen laufenden, nie hinter "Ältere Aufträge anzeigen"
sortiereAuftraege stellt unter den laufenden Aufträgen die mit Rückfrage (braucheInfo) nach vorn, danach
wie bisher die jüngste Aktivität, dann die Reihenfolge des Servers. Erledigte und Stornierte bleiben dahinter.
Tests: Reihenfolge mit Rückfragen und Gruppierung (die Rückfrage mit der ältesten Aktivität bleibt sichtbar).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:47:21 +00:00
ClaudeandClaude Sonnet 5.5 6bfcaa892c Kundenansicht neu (Meine): Kurzinfo nur auf Anfrage (GET /api/funktionen?kurzinfo=1)
Der Server berechnet kurzinfo (Terminplaner, Webshop, News, Buchungskalender, Öffnungszeiten) nur noch,
wenn die neue Ansicht ?kurzinfo=1 sendet. Das tut allein die Seite "Meine"; Etagen, Einschalt-Ablauf und
Module fragen ohne den Parameter, dann fehlt das Feld und es gibt keine Abfrage auf Termine, Bestellungen,
Beiträge und Anfragen. Mit dem Parameter bleibt die Antwort wie bisher (Text oder null je Funktion).
Nur der Wert "1" gilt; doppelte oder als Liste gesendete Parameter heißen: ohne. Alte Ansicht unverändert.

Tests: kundenansichtKurzinfo (neuer Fall ohne/mit Parameter, Abfragen und Feld), knFunktionenAnsicht
(die Seite lädt genau einmal ?kurzinfo=1, auch nach "Nochmal versuchen").

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:46:48 +00:00
ClaudeandClaude Opus 5.5 d7724afc02 Merge kn/meine-pilot (Pilot-Feedback 17.10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:43:07 +00:00
ClaudeandClaude Opus 5.5 5f4e158660 Merge kn/auftraege-pilot (Pilot-Feedback 17.10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:43:07 +00:00
ClaudeandClaude Opus 5.5 85808fc115 Merge kn/chat-fix (Pilot-Feedback 17.10.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:43:07 +00:00
ClaudeandClaude Sonnet 5.5 1703dabaaf Kundenansicht neu (Aufträge): Rückfrage-Link als Block, bündige Datumsspalte bei Einträgen aus dem Vorjahr
- Browserlauf: display:flex am Link zerlegte den Satz in zwei Spalten; der Link ist jetzt ein Block, die 44 px Tippfläche kommen aus dem Innenabstand.
- Trägt ein Datum das Jahr (TT.MM.JJ), bekommt die Datumsspalte des Auftrags die Breite dafür (Klasse kn-az-mit-jahr), die Texte bleiben bündig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:31:46 +00:00
ClaudeandClaude Sonnet 5.5 d62f8b805e Kundenansicht neu (Aufträge): Rückfrage-Zeile als ganze Link-Zeile, Einklappen ohne verschachtelte Regel, Balken im Pseudo-Element
- Die Zeile "Wir brauchen eine Info von dir · Im Chat antworten" ist ein Link (#chat) mit großer Tippfläche am Handy; unterstrichen ist nur der zweite Teil.
- Browserlauf: .kn-einklappbar:not(.is-open) [data-extra] verbarg die Zeilen einer aufgeklappten Zeitleiste, wenn der äußere Wrapper zu war.
  Die Wrapper haben jetzt eigene Klassen und Regeln nur für direkte Kinder (mit Regressionstest).
- Die Fläche der Balken-Abschnitte liegt im Pseudo-Element, der Lesetext darunter bleibt auf Weiß (Gate low-contrast).
- Das Modul steht in den Listen der Quelltext- und Stil-Tests der Ansicht-Module.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:28:43 +00:00
ClaudeandClaude Sonnet 5.5 ec79defb2a Plan Kundenansicht neu: Nachtrag N52 (Kurzinfo je Funktion in Meine) und Vertrag V5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:28:00 +00:00
ClaudeandClaude Sonnet 5.5 670ead3091 Kundenansicht neu Chat-Fix: Chat als Vollbild-Ansicht (Verlauf scrollt in sich, Eingabe unten, Spalte zentriert, "Chat" klein im Kopf)
Pilot-Feedback 17.10.: Der Chat wirkte durchsichtig und verrutscht.
- Handy: Inhalt steht fest zwischen Kopf und Leiste (wie das Blatt); die Eingabe sitzt höchstens 6 px über der Leiste bzw. dem Widerrufsstreifen und bei
  offener Tastatur am unteren Rand des sichtbaren Bereichs. "Chat" steht klein im Kopf (die H1 bleibt nur für Screenreader), der Kopf ist deckend mit Haarlinie,
  der Verlauf beginnt direkt darunter und scrollt in sich.
- Desktop: Spalte in Fensterhöhe, Chat höchstens 760 px breit und mittig, Verlauf füllt die Höhe, Eingabe am unteren Spaltenrand.
- Verlauf steht unten (neueste Nachricht sichtbar), kurze Verläufe unten bei der Eingabe; Blasen fremd #f8f9fb, eigen #ebeef1.
- "Senden" bleibt schwarz, auch bei leerem Feld (gesperrt im Ansichtsmodus weiter gedämpft).
- haltFokus: Druck auf Senden, Anhang oder Mikrofon nimmt dem Textfeld den Fokus nicht; sonst kommt die Leiste zurück, schiebt die feste Eingabe hoch und der
  Tipp auf "Senden" geht bei offener Tastatur verloren (im Browser nachgestellt). public/chat-composer.js und die alte Ansicht bleiben unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:17:26 +00:00
ClaudeandClaude Sonnet 5.5 9aeb9872be Kundenansicht neu (Meine, Live-Detail): Zeile einer abgeschalteten Funktion kürzer ("Im Chat wieder anfragen")
Die Detailzeile umbrach bei mehreren Websites neben der Pille auf drei Zeilen. Der Abo-Ende-Text mit dem
Löschtag bleibt unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:17:13 +00:00
ClaudeandClaude Sonnet 5.5 db62e320ff Kundenansicht neu (Meine, Live-Detail): Zeile mit Detailzeile und Pfeil, Pille nur wenn die Funktion nicht läuft
Jede Zeile in "Meine" zeigt den Funktionsnamen fett, darunter die Kurzinfo des Servers ("3 Termine heute ·
nächster 10:30") oder "Aktiv", rechts einen Pfeil. Eine Status-Pille steht nur noch bei Funktionen, die nicht
laufen (In Einrichtung, Zahlung offen, Angefragt, Abgeschaltet, Abgelehnt, Gesperrt ...); "Aktiv seit ..." entfällt.
Bei mehreren Websites steht der Websitename vorne in der Detailzeile. Die Zeilen für Funktionen, die nicht laufen,
sind kürzer ("Wir richten sie gerade ein", "Anfrage abgelehnt · Im Chat nachfragen"). Die Kurzinfo gilt nur bei
aktiver Funktion, nur als kurzer nicht leerer Text und wird nie als HTML gelesen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:10:43 +00:00
ClaudeandClaude Sonnet 5.5 53a191219e Kundenansicht neu (Meine, Live-Detail): GET /api/funktionen liefert je Projekt und Funktion die Kurzinfo
In der neuen Ansicht trägt jede Funktion unter projects[].features.<key> zusätzlich kurzinfo (Text oder null).
Nur aktive Funktionen bekommen einen Text: Terminplaner "3 Termine heute · nächster 10:30", Webshop "2 neue
Bestellungen", News-Pinnwand "4 Beiträge sichtbar", Buchungskalender "1 offene Anfrage" bzw. "Nächste Anreise
24.10.", Öffnungszeiten live der Status der Website (ohne eigene Abfrage). Berliner Zeit, je Funktion höchstens
zwei schmale Abfragen (limit, select, Index-Felder), alles gleichzeitig; jede Funktion einzeln begrenzt (1,5 s)
und fehlertolerant: Fehler oder Zeitüberschreitung ergibt null. Ohne neue Ansicht bleibt die Antwort wie sie
war und es wird nichts gelesen. Die Analyse hat keine Kurzinfo (Zahlen kommen aus dem Auswertungsdienst).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:09:25 +00:00
ClaudeandClaude Sonnet 5.5 2064bddba6 Kundenansicht neu (Aufträge): "Was wir zuletzt erledigt haben" als Zeitleiste auf der Website-Seite
Je Auftrag eine Überschrift (Titel fett, Status-Pille), ein schmaler Balken mit vier
Abschnitten (Stufennamen als Text für Screenreader), bei Rückfrage die Zeile "Wir brauchen
eine Info von dir · Im Chat antworten" und eine Zeitleiste mit Haarlinie (Datum TT.MM. und
Kurztext, drei sichtbar, "mehr anzeigen"). Fünf Aufträge offen, der Rest hinter
"Ältere Aufträge anzeigen". Die Auftragsnummer steht nicht mehr im Text (ruhiger), bleibt
als data-auftrag. Die alte baueAktivitaet und ihre toten CSS-Regeln entfallen; die
Projektdetails (baueAuftrag, baueArbeiten) bleiben unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:07:34 +00:00
ClaudeandClaude Sonnet 5.5 f231f4b887 Kundenansicht neu (Aufträge): Logik der Zeitleiste (Fortschritt, Reihenfolge, Gruppierung, Kürzen, Kurztexte)
Reine Funktionen in public/neu/auftrags-zeitleiste.js: laufende Aufträge vor erledigten
(jeweils jüngste Aktivität zuerst, Storniertes zuletzt), höchstens fünf Aufträge offen,
vier Fortschrittsabschnitte aus status.step, Zeilen der Zeitleiste neueste zuerst mit
Datum TT.MM. und Kurztext. Test zuerst (rot: Modul fehlte), dann grün.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:05:48 +00:00
44 changed files with 2429 additions and 154 deletions

No files matched your search

+13 -2
View File
@@ -5,6 +5,10 @@ export const CONSENT_KEY = 'wk-consent';
export const CONSENT_MAX_AGE = 183 * 86400000; export const CONSENT_MAX_AGE = 183 * 86400000;
export const BANNER_VERSION = '2026-10-02'; export const BANNER_VERSION = '2026-10-02';
export const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; export const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst
// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt.
export const BANNER_VERSION_EIGEN = '2026-10-17';
export const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
export const BANNER_LINK_LABEL = 'Datenschutz'; export const BANNER_LINK_LABEL = 'Datenschutz';
export const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung'; export const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
export const SETTINGS_LABEL = 'Datenschutz-Einstellungen'; export const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
@@ -88,13 +92,20 @@ export function contactKind(a, ownHost) {
return stripWww(host) === stripWww(own || '') ? null : 'out'; return stripWww(host) === stripWww(own || '') ? null : 'out';
} }
export function readConsent(storage, now) { // Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung.
export function bannerFor(variant) {
return variant === 'eigen'
? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN }
: { version: BANNER_VERSION, text: BANNER_TEXT };
}
export function readConsent(storage, now, version = BANNER_VERSION) {
try { try {
const raw = storage.getItem(CONSENT_KEY); const raw = storage.getItem(CONSENT_KEY);
if (!raw) return null; if (!raw) return null;
const c = JSON.parse(raw); const c = JSON.parse(raw);
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null; if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen if (c.bv !== version) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
const age = now - c.t; const age = now - c.t;
if (age < 0 || age >= CONSENT_MAX_AGE) return null; if (age < 0 || age >= CONSENT_MAX_AGE) return null;
return { v: 1, a: c.a, t: c.t, bv: c.bv }; return { v: 1, a: c.a, t: c.t, bv: c.bv };
+13 -2
View File
@@ -8,6 +8,10 @@ const CONSENT_KEY = 'wk-consent';
const CONSENT_MAX_AGE = 183 * 86400000; const CONSENT_MAX_AGE = 183 * 86400000;
const BANNER_VERSION = '2026-10-02'; const BANNER_VERSION = '2026-10-02';
const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst
// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt.
const BANNER_VERSION_EIGEN = '2026-10-17';
const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
const BANNER_LINK_LABEL = 'Datenschutz'; const BANNER_LINK_LABEL = 'Datenschutz';
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung'; const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
const SETTINGS_LABEL = 'Datenschutz-Einstellungen'; const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
@@ -91,13 +95,20 @@ function contactKind(a, ownHost) {
return stripWww(host) === stripWww(own || '') ? null : 'out'; return stripWww(host) === stripWww(own || '') ? null : 'out';
} }
function readConsent(storage, now) { // Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung.
function bannerFor(variant) {
return variant === 'eigen'
? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN }
: { version: BANNER_VERSION, text: BANNER_TEXT };
}
function readConsent(storage, now, version = BANNER_VERSION) {
try { try {
const raw = storage.getItem(CONSENT_KEY); const raw = storage.getItem(CONSENT_KEY);
if (!raw) return null; if (!raw) return null;
const c = JSON.parse(raw); const c = JSON.parse(raw);
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null; if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen if (c.bv !== version) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
const age = now - c.t; const age = now - c.t;
if (age < 0 || age >= CONSENT_MAX_AGE) return null; if (age < 0 || age >= CONSENT_MAX_AGE) return null;
return { v: 1, a: c.a, t: c.t, bv: c.bv }; return { v: 1, a: c.a, t: c.t, bv: c.bv };
+24 -8
View File
@@ -8,6 +8,10 @@ const CONSENT_KEY = 'wk-consent';
const CONSENT_MAX_AGE = 183 * 86400000; const CONSENT_MAX_AGE = 183 * 86400000;
const BANNER_VERSION = '2026-10-02'; const BANNER_VERSION = '2026-10-02';
const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst
// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt.
const BANNER_VERSION_EIGEN = '2026-10-17';
const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
const BANNER_LINK_LABEL = 'Datenschutz'; const BANNER_LINK_LABEL = 'Datenschutz';
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung'; const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
const SETTINGS_LABEL = 'Datenschutz-Einstellungen'; const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
@@ -91,13 +95,20 @@ function contactKind(a, ownHost) {
return stripWww(host) === stripWww(own || '') ? null : 'out'; return stripWww(host) === stripWww(own || '') ? null : 'out';
} }
function readConsent(storage, now) { // Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung.
function bannerFor(variant) {
return variant === 'eigen'
? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN }
: { version: BANNER_VERSION, text: BANNER_TEXT };
}
function readConsent(storage, now, version = BANNER_VERSION) {
try { try {
const raw = storage.getItem(CONSENT_KEY); const raw = storage.getItem(CONSENT_KEY);
if (!raw) return null; if (!raw) return null;
const c = JSON.parse(raw); const c = JSON.parse(raw);
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null; if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen if (c.bv !== version) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
const age = now - c.t; const age = now - c.t;
if (age < 0 || age >= CONSENT_MAX_AGE) return null; if (age < 0 || age >= CONSENT_MAX_AGE) return null;
return { v: 1, a: c.a, t: c.t, bv: c.bv }; return { v: 1, a: c.a, t: c.t, bv: c.bv };
@@ -134,6 +145,8 @@ function main() {
if (!/^[a-z0-9]{16,40}$/.test(site)) return; if (!/^[a-z0-9]{16,40}$/.test(site)) return;
const colorAttr = String(SELF.getAttribute('data-color') || ''); const colorAttr = String(SELF.getAttribute('data-color') || '');
const accent = contrastOk(colorAttr) ? colorAttr : '#111111'; const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
// data-variant="eigen": Banner-Fassung fuer WEBklars eigene Websites (Sie-Form, ohne Auftragsverarbeiter)
const banner = bannerFor(String(SELF.getAttribute('data-variant') || ''));
const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, ''); const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
const base = src.replace(/[^/]*$/, ''); const base = src.replace(/[^/]*$/, '');
const endpoint = base + 'e'; const endpoint = base + 'e';
@@ -173,12 +186,12 @@ function main() {
const dnt = (v) => v === '1' || v === 'yes'; const dnt = (v) => v === '1' || v === 'yes';
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack); const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks
const stored = readConsent(storage, Date.now()); const stored = readConsent(storage, Date.now(), banner.version);
let memChoice = null; let memChoice = null;
let tracking = false; let tracking = false;
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt) // Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
function choiceNow() { function choiceNow() {
const c = readConsent(storage, Date.now()); const c = readConsent(storage, Date.now(), banner.version);
return c ? c.a : (memChoice ? memChoice.a : null); return c ? c.a : (memChoice ? memChoice.a : null);
} }
const counting = () => choiceNow() !== 'reject'; const counting = () => choiceNow() !== 'reject';
@@ -207,7 +220,10 @@ function main() {
if (navTimer || !counting()) return; if (navTimer || !counting()) return;
navTimer = setTimeout(safe(function () { navTimer = setTimeout(safe(function () {
navTimer = null; navTimer = null;
if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false); if (!counting() || cleanPath(WIN.location.pathname) === lastPath) return;
// SPA-Seitenwechsel (Router mit preventDefault, onLinkClick greift dann nicht): en der vorigen Seite zuerst
sendEn();
sendPv(false);
}), 300); }), 300);
} }
let hooksOn = false; let hooksOn = false;
@@ -312,7 +328,7 @@ function main() {
function choose(a) { function choose(a) {
memChoice = null; memChoice = null;
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: banner.version })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
send({ t: 'bn', site, a }); send({ t: 'bn', site, a });
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking(); if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
closeBanner(); closeBanner();
@@ -352,7 +368,7 @@ function main() {
const root = bannerHost.attachShadow({ mode: 'open' }); const root = bannerHost.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, CSS)); root.appendChild(el('style', {}, CSS));
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA }); const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA });
const p = el('p', { class: 't' }, BANNER_TEXT); const p = el('p', { class: 't' }, banner.text);
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL)); p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
const row = el('div', { class: 'r' }); const row = el('div', { class: 'r' });
const no = el('button', { type: 'button' }, 'Ablehnen'); const no = el('button', { type: 'button' }, 'Ablehnen');
@@ -433,7 +449,7 @@ function main() {
// Einwilligung in anderem Tab geaendert // Einwilligung in anderem Tab geaendert
WIN.addEventListener('storage', safe(function (e) { WIN.addEventListener('storage', safe(function (e) {
if (e.key !== null && e.key !== CONSENT_KEY) return; if (e.key !== null && e.key !== CONSENT_KEY) return;
const c = readConsent(storage, Date.now()); const c = readConsent(storage, Date.now(), banner.version);
if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking(); if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
if (c) closeBanner(); if (c) closeBanner();
})); }));
+12 -7
View File
@@ -1,7 +1,7 @@
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites. // WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
// Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen; // Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen;
// nach Ablehnen oder bei GPC/DNT geht nichts mehr raus. // nach Ablehnen oder bei GPC/DNT geht nichts mehr raus.
import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY, BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js'; import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, bannerFor, CONSENT_KEY, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js';
function main() { function main() {
const DOC = document; const DOC = document;
@@ -15,6 +15,8 @@ function main() {
if (!/^[a-z0-9]{16,40}$/.test(site)) return; if (!/^[a-z0-9]{16,40}$/.test(site)) return;
const colorAttr = String(SELF.getAttribute('data-color') || ''); const colorAttr = String(SELF.getAttribute('data-color') || '');
const accent = contrastOk(colorAttr) ? colorAttr : '#111111'; const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
// data-variant="eigen": Banner-Fassung fuer WEBklars eigene Websites (Sie-Form, ohne Auftragsverarbeiter)
const banner = bannerFor(String(SELF.getAttribute('data-variant') || ''));
const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, ''); const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
const base = src.replace(/[^/]*$/, ''); const base = src.replace(/[^/]*$/, '');
const endpoint = base + 'e'; const endpoint = base + 'e';
@@ -54,12 +56,12 @@ function main() {
const dnt = (v) => v === '1' || v === 'yes'; const dnt = (v) => v === '1' || v === 'yes';
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack); const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks
const stored = readConsent(storage, Date.now()); const stored = readConsent(storage, Date.now(), banner.version);
let memChoice = null; let memChoice = null;
let tracking = false; let tracking = false;
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt) // Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
function choiceNow() { function choiceNow() {
const c = readConsent(storage, Date.now()); const c = readConsent(storage, Date.now(), banner.version);
return c ? c.a : (memChoice ? memChoice.a : null); return c ? c.a : (memChoice ? memChoice.a : null);
} }
const counting = () => choiceNow() !== 'reject'; const counting = () => choiceNow() !== 'reject';
@@ -88,7 +90,10 @@ function main() {
if (navTimer || !counting()) return; if (navTimer || !counting()) return;
navTimer = setTimeout(safe(function () { navTimer = setTimeout(safe(function () {
navTimer = null; navTimer = null;
if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false); if (!counting() || cleanPath(WIN.location.pathname) === lastPath) return;
// SPA-Seitenwechsel (Router mit preventDefault, onLinkClick greift dann nicht): en der vorigen Seite zuerst
sendEn();
sendPv(false);
}), 300); }), 300);
} }
let hooksOn = false; let hooksOn = false;
@@ -193,7 +198,7 @@ function main() {
function choose(a) { function choose(a) {
memChoice = null; memChoice = null;
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: banner.version })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
send({ t: 'bn', site, a }); send({ t: 'bn', site, a });
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking(); if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
closeBanner(); closeBanner();
@@ -233,7 +238,7 @@ function main() {
const root = bannerHost.attachShadow({ mode: 'open' }); const root = bannerHost.attachShadow({ mode: 'open' });
root.appendChild(el('style', {}, CSS)); root.appendChild(el('style', {}, CSS));
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA }); const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA });
const p = el('p', { class: 't' }, BANNER_TEXT); const p = el('p', { class: 't' }, banner.text);
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL)); p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
const row = el('div', { class: 'r' }); const row = el('div', { class: 'r' });
const no = el('button', { type: 'button' }, 'Ablehnen'); const no = el('button', { type: 'button' }, 'Ablehnen');
@@ -314,7 +319,7 @@ function main() {
// Einwilligung in anderem Tab geaendert // Einwilligung in anderem Tab geaendert
WIN.addEventListener('storage', safe(function (e) { WIN.addEventListener('storage', safe(function (e) {
if (e.key !== null && e.key !== CONSENT_KEY) return; if (e.key !== null && e.key !== CONSENT_KEY) return;
const c = readConsent(storage, Date.now()); const c = readConsent(storage, Date.now(), banner.version);
if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking(); if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
if (c) closeBanner(); if (c) closeBanner();
})); }));
+70 -4
View File
@@ -7,7 +7,7 @@ import { dirname, join } from 'node:path';
import { import {
vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml, vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml,
} from '../client/core.js'; } from '../client/core.js';
import { BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from '../client/core.js'; import { BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL, BANNER_VERSION_EIGEN, BANNER_TEXT_EIGEN, bannerFor } from '../client/core.js';
import { buildAll } from '../client/build.js'; import { buildAll } from '../client/build.js';
const BV = '2026-10-02'; const BV = '2026-10-02';
@@ -170,12 +170,12 @@ function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n))
const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0]; const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0];
const settingsBtn = (h) => findAll(h.body, (n) => n.tagName === 'BUTTON' && n.attrs['aria-label'] === SETTINGS_LABEL)[0]; const settingsBtn = (h) => findAll(h.body, (n) => n.tagName === 'BUTTON' && n.attrs['aria-label'] === SETTINGS_LABEL)[0];
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad } = {}) { function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad, variant } = {}) {
const sent = []; const reads = { referrer: 0, innerWidth: 0 }; const beacon = []; const fetches = []; const store = {}; let now = 1_700_000_000_000; const sent = []; const reads = { referrer: 0, innerWidth: 0 }; const beacon = []; const fetches = []; const store = {}; let now = 1_700_000_000_000;
if (stored) store['wk-consent'] = JSON.stringify(stored); if (stored) store['wk-consent'] = JSON.stringify(stored);
const body = makeEl('body'); if (bodyPad !== undefined) body.style.paddingBottom = bodyPad; const html = makeEl('html'); const body = makeEl('body'); if (bodyPad !== undefined) body.style.paddingBottom = bodyPad; const html = makeEl('html');
const timers = []; const timers = [];
const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); script.src = src; const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); if (variant !== undefined) script.setAttribute('data-variant', variant); script.src = src;
const docL = {}; const winL = {}; const docL = {}; const winL = {};
const link = withConsentLink ? makeEl('a') : null; if (link) link.setAttribute('data-webklar-consent', ''); const link = withConsentLink ? makeEl('a') : null; if (link) link.setAttribute('data-webklar-consent', '');
const injected = []; const injected = [];
@@ -565,7 +565,9 @@ test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () =>
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } }); const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.win.location.pathname = '/zwei'; h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f()); h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/zwei']); // en der vorigen Seite vor dem pv der neuen (Router-Links verhindern den Standard-Klick, onLinkClick greift nicht)
assert.deepEqual(h.sent.map((s) => `${s.d.t}:${s.d.path}`), ['pv:/start', 'en:/start', 'pv:/zwei']);
assert.equal(h.sent[1].d.scroll, 55);
h.ck(target()); h.ck(target());
assert.equal(h.sent.at(-1).d.path, '/zwei'); assert.equal(h.sent.at(-1).d.path, '/zwei');
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }); h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
@@ -641,3 +643,67 @@ test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => {
g.ck(lnk('/kontakt')); g.ck(lnk('/kontakt'));
assert.equal(enCount(g), 0); assert.equal(enCount(g), 0);
}); });
// ---------- Variante "eigen" (WEBklars eigene Websites, z. B. webklar.com) ----------
test('core: bannerFor und readConsent je Fassung', () => {
assert.deepEqual(bannerFor('eigen'), { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN });
for (const v of ['', 'kunde', 'EIGEN', 'x', undefined]) assert.deepEqual(bannerFor(v), { version: BANNER_VERSION, text: BANNER_TEXT });
assert.equal(BANNER_VERSION_EIGEN, '2026-10-17');
assert.notEqual(BANNER_VERSION_EIGEN, BANNER_VERSION);
const now = 1_700_000_000_000;
const st = (o) => ({ getItem: () => JSON.stringify(o) });
assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BV, t: now }), now, BANNER_VERSION_EIGEN), null, 'Kunden-Fassung gilt nicht fuer eigen');
assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: now }), now, BANNER_VERSION_EIGEN).a, 'accept');
assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: now }), now), null, 'ohne Fassung gilt die Kunden-Fassung');
});
test('core: Banner-Text eigen (Sie-Form, ohne Auftragsverarbeiter)', () => {
assert.match(BANNER_TEXT_EIGEN, /^Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung\./);
assert.match(BANNER_TEXT_EIGEN, /Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung\./);
assert.match(BANNER_TEXT_EIGEN, /Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben\./);
assert.match(BANNER_TEXT_EIGEN, /über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen\. Details: $/);
assert.doesNotMatch(BANNER_TEXT_EIGEN, /\b(Du|Dein|Deine|Deiner|Dich|Dir)\b/);
assert.doesNotMatch(BANNER_TEXT_EIGEN, /betreibt für uns/);
});
test('w.js: data-variant="eigen" => Sie-Text, eigene Fassung im Speicher', () => {
const h = harness({ variant: 'eigen' });
const host = hostEl(h);
const p = findAll(host.shadow, (n) => n.tagName === 'P')[0];
assert.ok(p.textContent.startsWith(BANNER_TEXT_EIGEN));
assert.ok(!p.textContent.startsWith(BANNER_TEXT));
btn(host.shadow, 'Annehmen').click();
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: 1_700_000_000_000 });
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
});
test('w.js: eigen fragt neu bei Wahl aus der Kunden-Fassung, gilt mit eigener Fassung', () => {
const alt = harness({ variant: 'eigen', stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
assert.ok(hostEl(alt), 'Banner erscheint erneut');
const ok = harness({ variant: 'eigen', stored: { v: 1, a: 'reject', bv: BANNER_VERSION_EIGEN, t: 1_700_000_000_000 - 1000 } });
assert.equal(hostEl(ok), undefined);
assert.deepEqual(ok.types(), []);
});
test('w.js: unbekannte Variante => Kunden-Fassung', () => {
const h = harness({ variant: 'sonstwas' });
const p = findAll(hostEl(h).shadow, (n) => n.tagName === 'P')[0];
assert.ok(p.textContent.startsWith(BANNER_TEXT));
});
test('w.js: SPA-Wechsel ohne Einwilligung sendet nur pv, kein en', () => {
const h = harness();
h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'pv']);
});
test('w.js: en nach SPA-Wechsel nur einmal je Seite (Link-Klick und Router)', () => {
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
h.leave();
h.win.location.pathname = '/zwei';
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
assert.deepEqual(h.sent.map((s) => `${s.d.t}:${s.d.path}`), ['pv:/start', 'en:/start', 'pv:/zwei']);
h.leave();
assert.deepEqual(h.sent.map((s) => `${s.d.t}:${s.d.path}`).slice(-1), ['en:/zwei']);
});
Binary file not shown.
@@ -1 +1 @@
8facae22cea59e23f6a9350374e03f0c42d0cba2e3adfda6062034d47081617a 21d406e6836a7d0c1afdb99dc259e4174fb3fe499afcbdac30c8a45a1aa8d605
+8
View File
@@ -8,6 +8,14 @@ Jede ausgelieferte Fassung bleibt hier nachvollziehbar. `BANNER_VERSION` im Clie
Änderung der Zusatzseite am 02.10.2026 (Fassung bleibt 2026-10-02, Banner-Text unverändert): Der Knopf zum Wiederöffnen des Banners ist jetzt ein Keks-Symbol (Name für Screenreader und Beschriftung beim Darüberfahren weiter „Datenschutz-Einstellungen“). Die Zusatzseite nennt deshalb bei Widerruf und Widerspruch zusätzlich „(das Keks-Symbol)“. Die vorige Formulierung war bis dahin nur auf der WEBklar-Testseite (test-website.project.webklar.com) ausgeliefert, keiner Kunden-Website: [datenschutz-analyse-2026-10-02-erstfassung.html](datenschutz-analyse-2026-10-02-erstfassung.html). In WOMS → Rechtliches als neue Version der Karte „datenschutz-website-analyse“ hinterlegt. Änderung der Zusatzseite am 02.10.2026 (Fassung bleibt 2026-10-02, Banner-Text unverändert): Der Knopf zum Wiederöffnen des Banners ist jetzt ein Keks-Symbol (Name für Screenreader und Beschriftung beim Darüberfahren weiter „Datenschutz-Einstellungen“). Die Zusatzseite nennt deshalb bei Widerruf und Widerspruch zusätzlich „(das Keks-Symbol)“. Die vorige Formulierung war bis dahin nur auf der WEBklar-Testseite (test-website.project.webklar.com) ausgeliefert, keiner Kunden-Website: [datenschutz-analyse-2026-10-02-erstfassung.html](datenschutz-analyse-2026-10-02-erstfassung.html). In WOMS → Rechtliches als neue Version der Karte „datenschutz-website-analyse“ hinterlegt.
## Eigene Website webklar.com
Auf webklar.com läuft dieselbe Messung mit WEBklar als Verantwortlichem (keine AVV). Das Skript-Tag trägt `data-variant="eigen"`; der Banner hat dann eine eigene Sie-Fassung (`BANNER_VERSION_EIGEN`), die Zusatzseite liegt im Repository der Website (`public/datenschutz-analyse.html`), die Kopie hier ist byte-gleich zu der ausgelieferten Fassung. Der Abschnitt 4a der Datenschutzerklärung fasst sie zusammen.
| Fassung | gültig ab | Banner-Text | Zusatzseite |
|---|---|---|---|
| 2026-10-17 | offen bis Livegang | [banner-eigen-2026-10-17.md](banner-eigen-2026-10-17.md) (`BANNER_VERSION_EIGEN` 2026-10-17) | [datenschutz-analyse-webklar-com-2026-10-17.html](datenschutz-analyse-webklar-com-2026-10-17.html) |
AVV und Leistungsbeschreibung je Fassung: `public/rechtliches/avv-website-analyse-<Fassung>.html|pdf` und `leistungsbeschreibung-website-analyse-<Fassung>.html|pdf`. „Gültig ab“ wird beim Livegang eingetragen. AVV und Leistungsbeschreibung je Fassung: `public/rechtliches/avv-website-analyse-<Fassung>.html|pdf` und `leistungsbeschreibung-website-analyse-<Fassung>.html|pdf`. „Gültig ab“ wird beim Livegang eingetragen.
## Terminplaner ## Terminplaner
@@ -0,0 +1,13 @@
# Banner-Text Fassung 2026-10-17 (eigene Websites der WEBklar GbR)
Quelle: `analytics/client/core.js` (Konstanten `BANNER_TEXT_EIGEN`, `BANNER_VERSION_EIGEN`). Gilt nur, wenn das Skript-Tag `data-variant="eigen"` trägt, derzeit nur auf webklar.com. Kunden-Websites behalten die Fassung 2026-10-02 ([banner-2026-10-02.md](banner-2026-10-02.md)).
- `BANNER_VERSION_EIGEN`: 2026-10-17
## Fließtext (BANNER_TEXT_EIGEN)
Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: [Datenschutz](/datenschutz-analyse.html)
## aria-label, Schaltflächen, Einstellungs-Link
Wie in der Kunden-Fassung: „Einwilligung zur Reichweitenmessung“; „Ablehnen“ · „Annehmen“ (gleich gestaltet, Ablehnen zuerst); Keks-Symbol „Datenschutz-Einstellungen“ unten rechts.
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Datenschutz bei der Reichweitenmessung – WEBklar</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 12rem; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 2px solid #6d1fd1; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
</style>
</head>
<body>
<main>
<nav class="top"><a href="/">← Zur Startseite</a> · <a href="https://project.webklar.com/datenschutz.html">Datenschutzerklärung</a> · <a href="/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Datenschutz bei der Reichweitenmessung</h1>
<p class="meta">Ergänzung zu unserer Datenschutzerklärung (Abschnitt 4a) · gilt für webklar.com · Stand 17.10.2026</p>
<p>Diese Seite beschreibt nur die Reichweitenmessung auf webklar.com. Alle übrigen Hinweise finden Sie in unserer <a href="https://project.webklar.com/datenschutz.html">Datenschutzerklärung</a>.</p>
<h2>Verantwortlicher</h2>
<p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler<br>
E-Mail: <a href="mailto:support@webklar.com">support@webklar.com</a></p>
<p>Einen Datenschutzbeauftragten haben wir nicht benannt, weil wir dazu gesetzlich nicht verpflichtet sind.</p>
<h2>Worum es geht</h2>
<p>Wir möchten verstehen, wie unsere Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo wird geklickt? Dafür nutzen wir unsere eigene Website-Analyse, dieselbe, die wir auch unseren Kunden anbieten. Sie kommt ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken aus. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Die Messung läuft auf unserem Server in Deutschland.</p>
<p>Damit die Messung arbeiten kann, lädt Ihr Browser bei jedem Seitenaufruf ein Skript von unserem Server (project.webklar.com). Dabei erhält dieser Server, wie bei jeder Datei, die eine Website lädt, technisch Ihre IP-Adresse und Ihr Browser-Kennzeichen (User-Agent). Das gilt auch, wenn Sie ablehnen oder „Global Privacy Control“ aktiviert haben. Beim Abruf des Skripts und bei der Messung speichern und protokollieren wir beides nicht.</p>
<h2>Solange Sie im Banner nichts ausgewählt haben</h2>
<p>Dann zählen wir den Aufruf der Seite. Das Skript übermittelt dafür nur die Adresse der Seite und die Angabe, dass das Banner angezeigt wurde. Gespeichert werden:</p>
<ul>
<li>der Seitenpfad (ohne Suchbegriffe oder Parameter hinter dem „?“),</li>
<li>das Land, das wir aus Ihrer IP-Adresse ableiten,</li>
<li>die Geräteklasse (Handy, Tablet oder Computer), die wir aus dem Browser-Kennzeichen ableiten,</li>
<li>der Zeitpunkt, auf die volle Stunde gerundet, und</li>
<li>ein Zähler dafür, dass das Banner angezeigt wurde.</li>
</ul>
<p>IP-Adresse und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher: um Land und Geräteklasse zu bestimmen, um automatische Abrufe durch Programme (Bots) auszusortieren und um zu verhindern, dass die Messung durch massenhafte Anfragen verfälscht wird. Für diese Mengenbegrenzung bilden wir aus Ihrer IP-Adresse einen Prüfwert (Hash), den wir nur im Arbeitsspeicher halten und nicht speichern. Eine Kennung, mit der wir Sie wiedererkennen könnten, speichern wir ohne Ihre Einwilligung nicht.</p>
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen.</p>
<h2>Wenn Sie „Ablehnen“ wählen</h2>
<p>Dann zählen wir einmal, dass abgelehnt wurde, und das Skript sendet danach nichts mehr, auch keine Seitenaufrufe. Sendet Ihr Browser das Signal „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner gar nicht an und zählen nichts.</p>
<h2>Wenn Sie „Annehmen“ wählen</h2>
<p>Dann messen wir zusätzlich:</p>
<ul>
<li>von welcher Website Sie gekommen sind (nur der Domainname),</li>
<li>die Größenklasse Ihres Browserfensters,</li>
<li>eine Besucher-Kennung, die täglich wechselt. Wir berechnen sie aus IP-Adresse und Browser-Kennzeichen mit einem Schlüssel, der jeden Tag neu erzeugt und nicht gespeichert wird. Am selben Tag könnten wir die Kennung damit nachrechnen. Nach dem Wechsel des Schlüssels lässt sie sich nicht mehr aus Ihrer IP-Adresse berechnen und nicht mit Kennungen anderer Tage verknüpfen,</li>
<li>wie lange Sie auf einer Seite bleiben (ab dem Laden der Seite) und wie weit Sie scrollen,</li>
<li>wo auf der Seite geklickt wird: die Klickposition und den Aufbau des angeklickten Elements, ohne Texte und ohne Eingaben in Formulare (für eine „Heatmap“),</li>
<li>ob Sie auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klicken (dann mit deren Domainname, nicht mit der Nummer oder Adresse selbst), und</li>
<li>einen Zähler dafür, dass Sie „Annehmen“ gewählt haben.</li>
</ul>
<p>Rechtsgrundlage ist Ihre Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO.</p>
<h2>Was in Ihrem Browser gespeichert wird</h2>
<p>Wir speichern Ihre Auswahl („Annehmen“ oder „Ablehnen“) mit Datum und der Fassung des Banner-Textes im lokalen Speicher Ihres Browsers (Eintrag „wk-consent“), damit wir Sie nicht bei jedem Seitenaufruf erneut fragen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt 183 Tage, danach fragen wir erneut. Sie können den Eintrag jederzeit selbst löschen, indem Sie die Website-Daten in Ihrem Browser löschen.</p>
<h2>Speicherdauer</h2>
<p>Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte, also Summen je Tag, etwa Aufrufe je Seite oder je Land, ohne Besucher-Kennung. Wir löschen sie nach 25 Monaten. Beenden wir die Messung, löschen wir alle Messdaten spätestens rund 60 Tage danach. Unser Dienstleister sichert den Server täglich; gelöschte Daten bleiben dort bis zu sieben Tage erhalten.</p>
<h2>Empfänger</h2>
<p>Die Messung betreiben wir selbst. Die Daten sehen nur unsere Mitarbeiter. Der Server steht bei der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland; Hetzner ist für uns als Auftragsverarbeiter tätig. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
<h2>Freiwilligkeit</h2>
<p>Sie sind weder gesetzlich noch vertraglich verpflichtet, uns diese Daten bereitzustellen. Wenn Sie ablehnen oder Ihre Einwilligung widerrufen, können Sie unsere Website trotzdem uneingeschränkt nutzen. Eine automatisierte Entscheidungsfindung findet nicht statt.</p>
<div class="box">
<h2 style="border:0;margin-top:0;padding-top:0">Einwilligung widerrufen</h2>
<p>Sie können Ihre Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen: Klicken Sie auf „Datenschutz-Einstellungen“ (das Keks-Symbol unten rechts auf jeder Seite) und wählen Sie „Ablehnen“. Die Rechtmäßigkeit der Verarbeitung bis zum Widerruf bleibt unberührt.</p>
<h2 style="border:0;padding-top:0">Widerspruchsrecht</h2>
<p>Sie können der Zählung nach Art. 6 Abs. 1 lit. f DSGVO jederzeit aus Gründen widersprechen, die sich aus Ihrer besonderen Situation ergeben (Art. 21 DSGVO). Am einfachsten wählen Sie im Banner oder über „Datenschutz-Einstellungen“ (das Keks-Symbol) „Ablehnen“ oder aktivieren in Ihrem Browser das Signal „Global Privacy Control“. Dann sendet das Skript keine Daten mehr. Sie können Ihren Widerspruch auch an die oben genannte E-Mail-Adresse richten.</p>
</div>
<h2>Ihre Rechte</h2>
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). In den Messdaten speichern wir keine Namen, keine IP-Adressen und keine Kennung, die länger als einen Tag gilt, und wir runden Zeitpunkte auf die volle Stunde. Deshalb können wir gespeicherte Messdaten in der Regel nicht Ihnen zuordnen (Art. 11 DSGVO). Anders kann es sein, solange die Zugriffsprotokolle unseres Webservers noch vorhanden sind (Abschnitt 3 der Datenschutzerklärung). Wenn Sie uns Datum, ungefähre Uhrzeit, besuchte Seiten und, wenn Sie möchten, Ihre IP-Adresse nennen, prüfen wir, ob eine Zuordnung möglich ist. Ist das nicht möglich, teilen wir Ihnen das mit.</p>
<p>Mit Ihren Anliegen können Sie sich jederzeit an die oben genannte Adresse wenden. Außerdem haben Sie das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO), für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz.</p>
</main>
</body>
</html>
@@ -6,18 +6,18 @@ Diese Liste ändert die Datenschutzerklärung nicht. `public/datenschutz.html` b
Je Prüfpunkt: **Fundstelle**, was dort steht, was sich ändert, und die Frage an den Inhaber oder die Kanzlei. Je Prüfpunkt: **Fundstelle**, was dort steht, was sich ändert, und die Frage an den Inhaber oder die Kanzlei.
**D1** · Fundstelle `public/datenschutz.html:120`. Nachweis bei der Bestellung einer Funktion. Dort steht der „Klick auf ‚Zahlungspflichtig bestellen‘“, die Stripe-Sitzung und eine Funktionsliste nur mit News-Pinnwand und Terminplaner. Künftig löst das Einschalten einer Funktion im Abo keine Zahlung aus und hat keine Stripe-Sitzung; gespeichert werden der Wortlaut der angezeigten Zeilen, die Fassungen, Zeitpunkt des Klicks auf „Einschalten“, IP-Adresse, Browserkennung, Konto, angemeldete Person und die Kundenart (Fassungen: Abo-Bedingungen 2026-10-16-v6, Funktionsunterlagen 2026-10-16). Die Funktionsliste muss alle Funktionen nennen (News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live, Website-Analyse). Frage: Neuer Absatz „Funktion einschalten“ neben dem Absatz für Bestellungen, damit der Bestand (Funktionen, die einzeln gekauft wurden) unverändert beschrieben bleibt? **D1** · Fundstelle `public/datenschutz.html:127`. Nachweis bei der Bestellung einer Funktion. Dort steht der „Klick auf ‚Zahlungspflichtig bestellen‘“, die Stripe-Sitzung und eine Funktionsliste nur mit News-Pinnwand und Terminplaner. Künftig löst das Einschalten einer Funktion im Abo keine Zahlung aus und hat keine Stripe-Sitzung; gespeichert werden der Wortlaut der angezeigten Zeilen, die Fassungen, Zeitpunkt des Klicks auf „Einschalten“, IP-Adresse, Browserkennung, Konto, angemeldete Person und die Kundenart (Fassungen: Abo-Bedingungen 2026-10-16-v6, Funktionsunterlagen 2026-10-16). Die Funktionsliste muss alle Funktionen nennen (News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live, Website-Analyse). Frage: Neuer Absatz „Funktion einschalten“ neben dem Absatz für Bestellungen, damit der Bestand (Funktionen, die einzeln gekauft wurden) unverändert beschrieben bleibt?
**D2** · Fundstelle `public/datenschutz.html:119`. Nachweis bei Abo, Wechsel und Zusatzspeicher. Dort stehen „gegebenenfalls Ihre Angaben zum Leistungsbeginn“ sowie Zeitpunkt, Version, IP-Adresse und Browserkennung. Künftig kommen der Wortlaut der angezeigten Zeilen (Linkzeile, Häkchen für Privatkunden) und die Kundenart zum Klickzeitpunkt hinzu (§ 2 Abs. 6 der Abo-Bedingungen Fassung 6). Frage: Genügt es, die Kundenart in den Aufzählungssatz aufzunehmen, oder braucht sie wegen D3 einen eigenen Hinweis? **D2** · Fundstelle `public/datenschutz.html:126`. Nachweis bei Abo, Wechsel und Zusatzspeicher. Dort stehen „gegebenenfalls Ihre Angaben zum Leistungsbeginn“ sowie Zeitpunkt, Version, IP-Adresse und Browserkennung. Künftig kommen der Wortlaut der angezeigten Zeilen (Linkzeile, Häkchen für Privatkunden) und die Kundenart zum Klickzeitpunkt hinzu (§ 2 Abs. 6 der Abo-Bedingungen Fassung 6). Frage: Genügt es, die Kundenart in den Aufzählungssatz aufzunehmen, oder braucht sie wegen D3 einen eigenen Hinweis?
**D3** · Fundstelle `public/datenschutz.html:81`. Abschnitt 6 „Kundenportal“, Kundenkonto-Absatz in der Zeile darunter. Die Kundenart („Betrieb“ oder „privat“) ist ein neues Datum im Kundenkonto. Das Team legt sie nach den Angaben des Kunden fest (Entscheidung E3); ist sie leer, gilt der Kunde als privat (E4). Zu ergänzen sind Zweck (Bestellseiten richten sich danach, Nachweis), Rechtsgrundlage (Art. 6 Abs. 1 lit. b und f DSGVO), Quelle (Angaben des Kunden an das Team) und Dauer (wie das Kundenkonto; im Nachweis wie in D1). Frage: Eigener Satz im Kundenkonto-Absatz oder in Abschnitt 7? **D3** · Fundstelle `public/datenschutz.html:88`. Abschnitt 6 „Kundenportal“, Kundenkonto-Absatz in der Zeile darunter. Die Kundenart („Betrieb“ oder „privat“) ist ein neues Datum im Kundenkonto. Das Team legt sie nach den Angaben des Kunden fest (Entscheidung E3); ist sie leer, gilt der Kunde als privat (E4). Zu ergänzen sind Zweck (Bestellseiten richten sich danach, Nachweis), Rechtsgrundlage (Art. 6 Abs. 1 lit. b und f DSGVO), Quelle (Angaben des Kunden an das Team) und Dauer (wie das Kundenkonto; im Nachweis wie in D1). Frage: Eigener Satz im Kundenkonto-Absatz oder in Abschnitt 7?
**D4** · Fundstelle `public/datenschutz.html:121`, ergänzend `public/datenschutz.html:108` und `public/datenschutz.html:114`. Der Stripe-Absatz beschreibt Zahlungen für Abos und Zusatzspeicher; das Einschalten einer Funktion im Abo löst keine Zahlung und keine Übermittlung an Stripe aus. Die Abschnitte zu Stripe Connect beim Webshop und beim Buchungskalender (Zahlungen der Käufer und Gäste auf das eigene Stripe-Konto des Kunden) bleiben inhaltlich wie sie sind. Frage: Genügt ein Satz, dass Funktionen im Abo ohne Zahlung eingeschaltet werden, oder sollen die Abschnitte nur an der Einleitung („bestellen“ statt „einschalten“) angepasst werden? **D4** · Fundstelle `public/datenschutz.html:128`, ergänzend `public/datenschutz.html:115` und `public/datenschutz.html:121`. Der Stripe-Absatz beschreibt Zahlungen für Abos und Zusatzspeicher; das Einschalten einer Funktion im Abo löst keine Zahlung und keine Übermittlung an Stripe aus. Die Abschnitte zu Stripe Connect beim Webshop und beim Buchungskalender (Zahlungen der Käufer und Gäste auf das eigene Stripe-Konto des Kunden) bleiben inhaltlich wie sie sind. Frage: Genügt ein Satz, dass Funktionen im Abo ohne Zahlung eingeschaltet werden, oder sollen die Abschnitte nur an der Einleitung („bestellen“ statt „einschalten“) angepasst werden?
**D5** · Fundstelle `public/datenschutz.html:95`. Abschnitt zur Website-Analyse: „Kunden mit Wartungs-Abo“ können die Reichweitenmessung einschalten. Künftig steckt die Website-Analyse im Hosting-Abo und im Wartungs-Abo (Leistungsbeschreibung Website-Analyse Nr. 1 Abs. 1). Der Absatz muss beide Abos nennen und beschreiben, dass die Funktion mit dem Abo endet (Löschfrist 60 Tage, AVV Website-Analyse Nr. 9). Frage: Reicht eine Anpassung der Einleitung? **D5** · Fundstelle `public/datenschutz.html:102`. Abschnitt zur Website-Analyse: „Kunden mit Wartungs-Abo“ können die Reichweitenmessung einschalten. Künftig steckt die Website-Analyse im Hosting-Abo und im Wartungs-Abo (Leistungsbeschreibung Website-Analyse Nr. 1 Abs. 1). Der Absatz muss beide Abos nennen und beschreiben, dass die Funktion mit dem Abo endet (Löschfrist 60 Tage, AVV Website-Analyse Nr. 9). Frage: Reicht eine Anpassung der Einleitung?
**D6** · Fundstellen `public/datenschutz.html:98` (Terminplaner), `public/datenschutz.html:106` (Webshop), `public/datenschutz.html:112` (Buchungskalender), außerdem der Abschnitt zur News-Pinnwand und zu Öffnungszeiten live. Die Funktionsabschnitte sprechen von „Kunden mit Wartungs-Abo“, vom „Freischalten“ und vom Ende mit dem Vertrag. Künftig gilt: Funktionen sind Teil des Wartungs-Abos, enden mit dem Abo (Terminplaner, News-Pinnwand, Webshop und Buchungskalender spätestens am Folgetag, Öffnungszeiten live nach 30 Tagen Weiterlauf) und werden nach den Fristen der Leistungsbeschreibungen gelöscht (30 Tage Terminplaner, News-Pinnwand und Öffnungszeiten live; 90 Tage Webshop und Buchungskalender). Frage: Die Fristen in den Abschnitten und in Abschnitt 7 (Speicherdauer) müssen übereinstimmen; soll Abschnitt 7 die Fristen künftig als Tabelle führen? **D6** · Fundstellen `public/datenschutz.html:105` (Terminplaner), `public/datenschutz.html:113` (Webshop), `public/datenschutz.html:119` (Buchungskalender), außerdem der Abschnitt zur News-Pinnwand und zu Öffnungszeiten live. Die Funktionsabschnitte sprechen von „Kunden mit Wartungs-Abo“, vom „Freischalten“ und vom Ende mit dem Vertrag. Künftig gilt: Funktionen sind Teil des Wartungs-Abos, enden mit dem Abo (Terminplaner, News-Pinnwand, Webshop und Buchungskalender spätestens am Folgetag, Öffnungszeiten live nach 30 Tagen Weiterlauf) und werden nach den Fristen der Leistungsbeschreibungen gelöscht (30 Tage Terminplaner, News-Pinnwand und Öffnungszeiten live; 90 Tage Webshop und Buchungskalender). Frage: Die Fristen in den Abschnitten und in Abschnitt 7 (Speicherdauer) müssen übereinstimmen; soll Abschnitt 7 die Fristen künftig als Tabelle führen?
**D7** · Fundstelle `public/datenschutz.html:131`, ergänzend die Beschreibung des Stundenkontingents im Kundenkonto-Absatz. Das Stundenkonto und der Vorschuss für die Einrichtung (§ 4 Abs. 8 der Abo-Bedingungen Fassung 6) sind Abrechnungsdaten: Arbeitszeit, Verbrauch, Vorschuss, Ablaufdaten. Sie stehen heute unter „gegebenenfalls Ihr Stundenkontingent“. Frage: Ist der Vorschuss als Abrechnungsdatum mit demselben Zweck, derselben Rechtsgrundlage (Art. 6 Abs. 1 lit. b DSGVO) und derselben Dauer ausreichend beschrieben, oder braucht er einen eigenen Satz? **D7** · Fundstelle `public/datenschutz.html:138`, ergänzend die Beschreibung des Stundenkontingents im Kundenkonto-Absatz. Das Stundenkonto und der Vorschuss für die Einrichtung (§ 4 Abs. 8 der Abo-Bedingungen Fassung 6) sind Abrechnungsdaten: Arbeitszeit, Verbrauch, Vorschuss, Ablaufdaten. Sie stehen heute unter „gegebenenfalls Ihr Stundenkontingent“. Frage: Ist der Vorschuss als Abrechnungsdatum mit demselben Zweck, derselben Rechtsgrundlage (Art. 6 Abs. 1 lit. b DSGVO) und derselben Dauer ausreichend beschrieben, oder braucht er einen eigenen Satz?
**D8** · Fundstelle `public/datenschutz.html:123`. Abschnitt zu den Seiten Kündigen und Widerrufen. Die Seiten erscheinen in der neuen Kundenansicht in der du-Form (Entscheidung R16) und zeigen angemeldeten Kunden die laufenden Widerrufsfristen aus bereits vorhandenen Daten (Sammelabfrage der Fristen im Kundenportal, `GET /api/fristen`). Das ist ein Lesen vorhandener Daten ohne neues Datum und ohne neuen Zweck; der Absatz bleibt inhaltlich unverändert. Frage: Genügt die Feststellung, dass kein neuer Absatz nötig ist, oder soll die Anzeige der Fristen im Konto erwähnt werden? **D8** · Fundstelle `public/datenschutz.html:130`. Abschnitt zu den Seiten Kündigen und Widerrufen. Die Seiten erscheinen in der neuen Kundenansicht in der du-Form (Entscheidung R16) und zeigen angemeldeten Kunden die laufenden Widerrufsfristen aus bereits vorhandenen Daten (Sammelabfrage der Fristen im Kundenportal, `GET /api/fristen`). Das ist ein Lesen vorhandener Daten ohne neues Datum und ohne neuen Zweck; der Absatz bleibt inhaltlich unverändert. Frage: Genügt die Feststellung, dass kein neuer Absatz nötig ist, oder soll die Anzeige der Fristen im Konto erwähnt werden?
@@ -279,6 +279,11 @@ Kürzel in den Fundstellen: `Task n` und `VFn` = Task bzw. Vertragsfrage n im ge
- **N50 Commit-Zeile (Global Constraints).** Jede ausführende Sitzung nutzt die `Co-Authored-By`-Zeile aus ihrem eigenen Systemhinweis. Commit-Beispiele in den Plänen mit `Claude Opus 5.5` und Formulierungen wie „wörtlich laut Übersicht“ stehen dafür als Platzhalter. - **N50 Commit-Zeile (Global Constraints).** Jede ausführende Sitzung nutzt die `Co-Authored-By`-Zeile aus ihrem eigenen Systemhinweis. Commit-Beispiele in den Plänen mit `Claude Opus 5.5` und Formulierungen wie „wörtlich laut Übersicht“ stehen dafür als Platzhalter.
(alle Pläne, Global Constraints „Commits“; P8 Schlussvermerk; F43) (alle Pläne, Global Constraints „Commits“; P8 Schlussvermerk; F43)
### Pilot-Nachträge
- **N52 Kurzinfo in „Meine“ (V5, Pilot-Feedback 2026-10-17, Strang kn/meine-pilot).** Mit neuer Ansicht und nur auf Anfrage (`GET /api/funktionen?kurzinfo=1`, sendet allein „Meine“; Etagen, Einschalt-Ablauf und Module fragen ohne den Parameter, dann fehlt das Feld und nichts wird gelesen; Runde 1, 17.10.) trägt jede Funktion unter `projects[].features.<news|termine|shop|unterkunft|zeiten>` zusätzlich `kurzinfo: string | null`, den Text der zweiten Zeile in „Meine“. Nur aktive Funktionen bekommen einen Text, alle anderen `null`; die Analyse trägt kein Feld (ihre Zahlen kommen aus dem Auswertungsdienst, nicht aus Appwrite). Quelle `server/services/kundenansicht/kurzinfo.js`: Terminplaner „3 Termine heute · nächster 10:30“ bzw. „Heute keine Termine“ (bestätigte und erledigte Termine ohne Sperren, Berliner Tag), Webshop „2 neue Bestellungen“ bzw. „Keine offenen Bestellungen“ (Status `bezahlt`), News „4 Beiträge sichtbar“ bzw. „Keine Beiträge sichtbar“ (`isHanging`), Buchungskalender „1 offene Anfrage“ (Status `neu`), sonst „Nächste Anreise 24.10.“ („Anreise heute“, „Anreise morgen“), sonst „Keine offenen Anfragen“, Öffnungszeiten live der Status der Website (`zeitenStatus.text`, ohne eigene Abfrage; ohne gespeicherte Zeiten „Noch keine Öffnungszeiten eingetragen“). Je Funktion höchstens zwei schmale Abfragen (`limit`, `select`, Filter auf Index-Felder), alle gleichzeitig; jede Funktion einzeln auf `kurzinfoDeps.limitMs` (1500 ms) begrenzt, Fehler oder Zeitüberschreitung ergibt `null`, eine volle Seite (Zahl unbekannt) ebenfalls. Die Texte enthalten nie Namen, Adressen oder Beträge. Die Oberfläche zeigt bei `null` „Aktiv“ und setzt die Zeile nur als Text (höchstens 80 Zeichen); eine Pille trägt nur noch eine Zeile, deren Funktion nicht läuft. Ohne neue Ansicht bleibt die Antwort bytegleich und es wird nichts gelesen.
(Pilot-Feedback 2026-10-17; `test/kundenansichtKurzinfo.test.js`, `test/knFunktionen*.test.js`)
### Zuständigkeit ### Zuständigkeit
- **N51 Zusätzliche Dateien und geteilte Stellen.** Die folgende Untertabelle ergänzt die Zuständigkeitstabelle um alle in Plänen und Vertragsfragen genannten Zusatzdateien und legt für die Hotspots eine feste Reihenfolge fest. Stubs aus P1 gehören nach der Anlage P2, P3 und P4; P7 übernimmt `ansicht-speicher.js` und `ansicht-gutschrift.js`; P0 besitzt `server/services/kundenansicht/` ganz. - **N51 Zusätzliche Dateien und geteilte Stellen.** Die folgende Untertabelle ergänzt die Zuständigkeitstabelle um alle in Plänen und Vertragsfragen genannten Zusatzdateien und legt für die Hotspots eine feste Reihenfolge fest. Stubs aus P1 gehören nach der Anlage P2, P3 und P4; P7 übernimmt `ansicht-speicher.js` und `ansicht-gutschrift.js`; P0 besitzt `server/services/kundenansicht/` ganz.
@@ -491,6 +496,7 @@ katalog: [{
}] }]
``` ```
- Je Projekt und Funktion zusätzlich `anfrageStatus: ''|'offen'|'angenommen'|'abgelehnt'|'zurueckgezogen'` (nur Shop und Unterkunft). - Je Projekt und Funktion zusätzlich `anfrageStatus: ''|'offen'|'angenommen'|'abgelehnt'|'zurueckgezogen'` (nur Shop und Unterkunft).
- Auf Anfrage (`?kurzinfo=1`, nur „Meine“) je Projekt und Funktion zusätzlich `kurzinfo: string | null` (Live-Detail der Zeile in „Meine“, nur aktive Funktionen, Fehler → `null`; ohne den Parameter fehlt das Feld; N52).
- Funktionen mit `verfuegbar: false` stehen trotzdem im Katalog. Die Oberfläche blendet sie aus (C9). - Funktionen mit `verfuegbar: false` stehen trotzdem im Katalog. Die Oberfläche blendet sie aus (C9).
- Die Analyse erscheint im Katalog nur, wenn `ANALYSE_ENABLED === 'on'`. - Die Analyse erscheint im Katalog nur, wenn `ANALYSE_ENABLED === 'on'`.
+48 -11
View File
@@ -44,9 +44,9 @@
<div class="brand">WEBKLAR</div> <div class="brand">WEBKLAR</div>
<h1>Datenschutzerklärung</h1> <h1>Datenschutzerklärung</h1>
<p class="meta">Stand 07.10.2026</p> <p class="meta">Stand 22.10.2026</p>
<p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen oder mit uns einen Vertrag schließen, und welche Rechte Sie haben.</p> <p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen, mit uns einen Vertrag schließen oder wenn wir auf Ihre öffentliche Anfrage nach einer Website antworten, und welche Rechte Sie haben.</p>
<h2>1. Verantwortlicher</h2> <h2>1. Verantwortlicher</h2>
<p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein<br> <p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein<br>
@@ -55,6 +55,7 @@
<h2>2. Geltungsbereich</h2> <h2>2. Geltungsbereich</h2>
<p>Diese Erklärung gilt für die Website webklar.com, für das Kundenportal project.webklar.com einschließlich der Vorschauseiten unter *.project.webklar.com und für unsere Leistungen, insbesondere unsere Abos (Hosting-Abo und Wartungs-Abo).</p> <p>Diese Erklärung gilt für die Website webklar.com, für das Kundenportal project.webklar.com einschließlich der Vorschauseiten unter *.project.webklar.com und für unsere Leistungen, insbesondere unsere Abos (Hosting-Abo und Wartungs-Abo).</p>
<p>Die Erklärung gilt auch für Menschen und Betriebe, die im Netz öffentlich nach einer Website suchen und auf deren Anfrage wir antworten (Abschnitt 11). Das können auch Privatpersonen sein, zum Beispiel in Frage-Foren.</p>
<p>Websites, die wir im Auftrag unserer Kunden betreiben, verantwortet der jeweilige Kunde. Dort verarbeiten wir Daten als Auftragsverarbeiter nach den Weisungen des Kunden. Informationen dazu finden Sie in der Datenschutzerklärung der jeweiligen Website.</p> <p>Websites, die wir im Auftrag unserer Kunden betreiben, verantwortet der jeweilige Kunde. Dort verarbeiten wir Daten als Auftragsverarbeiter nach den Weisungen des Kunden. Informationen dazu finden Sie in der Datenschutzerklärung der jeweiligen Website.</p>
<h2>3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit</h2> <h2>3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit</h2>
@@ -65,17 +66,24 @@
<p>Mit UptimeRobot haben wir einen Vertrag über die Auftragsverarbeitung geschlossen; er ist Teil der Nutzungsbedingungen von UptimeRobot. UptimeRobot verarbeitet diese Daten auch außerhalb der EU, insbesondere in den USA. Grundlage dafür sind die Standardvertragsklauseln der EU-Kommission (Art. 46 Abs. 2 lit. c DSGVO). Sie sind Teil des Auftragsverarbeitungsvertrags von UptimeRobot (uptimerobot.com/dpa); eine Kopie erhalten Sie auch bei uns. Rechtsgrundlage ist für die Websites unserer Abo-Kunden Art. 6 Abs. 1 lit. b DSGVO, weil die Überwachung zu den Leistungen des Abos gehört, im Übrigen Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt auch hier im sicheren und funktionsfähigen Betrieb. Die Adresse einer Website entfernen wir bei UptimeRobot, sobald wir die Website nicht mehr betreiben.</p> <p>Mit UptimeRobot haben wir einen Vertrag über die Auftragsverarbeitung geschlossen; er ist Teil der Nutzungsbedingungen von UptimeRobot. UptimeRobot verarbeitet diese Daten auch außerhalb der EU, insbesondere in den USA. Grundlage dafür sind die Standardvertragsklauseln der EU-Kommission (Art. 46 Abs. 2 lit. c DSGVO). Sie sind Teil des Auftragsverarbeitungsvertrags von UptimeRobot (uptimerobot.com/dpa); eine Kopie erhalten Sie auch bei uns. Rechtsgrundlage ist für die Websites unserer Abo-Kunden Art. 6 Abs. 1 lit. b DSGVO, weil die Überwachung zu den Leistungen des Abos gehört, im Übrigen Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt auch hier im sicheren und funktionsfähigen Betrieb. Die Adresse einer Website entfernen wir bei UptimeRobot, sobald wir die Website nicht mehr betreiben.</p>
<h2>4. Cookies und Speicher im Browser</h2> <h2>4. Cookies und Speicher im Browser</h2>
<p>Auf unseren eigenen Seiten (webklar.com und dem Kundenportal) setzen wir keine Analyse-, Tracking- oder Marketing-Werkzeuge ein und binden keine Inhalte fremder Anbieter wie Schriftarten oder Karten ein. Alle Bestandteile unserer Seiten werden von unserem eigenen Server geladen.</p> <p>Auf webklar.com messen wir mit unserer eigenen Website-Analyse, wie die Seite genutzt wird (Abschnitt 4a). Darüber hinaus setzen wir auf unseren eigenen Seiten (webklar.com und dem Kundenportal) keine Analyse-, Tracking- oder Marketing-Werkzeuge ein und binden keine Inhalte fremder Anbieter wie Schriftarten oder Karten ein. Alle Bestandteile unserer Seiten werden von unserem eigenen Server geladen.</p>
<table> <table>
<tr><th>Name</th><th>Zweck</th><th>Speicherdauer</th></tr> <tr><th>Name</th><th>Zweck</th><th>Speicherdauer</th></tr>
<tr><td>webklar_portal_session (Cookie)</td><td>hält Sie im Kundenportal angemeldet</td><td>7 Tage bzw. bis zur Abmeldung</td></tr> <tr><td>webklar_portal_session (Cookie)</td><td>hält Sie im Kundenportal angemeldet</td><td>7 Tage bzw. bis zur Abmeldung</td></tr>
<tr><td>preview_session (Cookie)</td><td>Zugang zu Ihren Vorschauseiten unter *.project.webklar.com</td><td>7 Tage bzw. bis zur Abmeldung</td></tr> <tr><td>preview_session (Cookie)</td><td>Zugang zu Ihren Vorschauseiten unter *.project.webklar.com</td><td>7 Tage bzw. bis zur Abmeldung</td></tr>
<tr><td>theme (lokaler Speicher, webklar.com)</td><td>merkt sich die von Ihnen gewählte helle oder dunkle Darstellung</td><td>bis Sie ihn im Browser löschen</td></tr> <tr><td>theme (lokaler Speicher, webklar.com)</td><td>merkt sich die von Ihnen gewählte helle oder dunkle Darstellung</td><td>bis Sie ihn im Browser löschen</td></tr>
<tr><td>wk-consent (lokaler Speicher, webklar.com)</td><td>merkt sich Ihre Auswahl im Banner der Reichweitenmessung („Annehmen“ oder „Ablehnen“) mit Datum und Fassung des Banner-Textes (Abschnitt 4a)</td><td>183 Tage, danach fragen wir erneut</td></tr>
</table> </table>
<p>Diese Speicherungen sind unbedingt erforderlich, damit wir die von Ihnen gewünschten Funktionen bereitstellen können (§ 25 Abs. 2 Nr. 2 TDDDG). Eine Einwilligung ist dafür nicht nötig. Die Verarbeitung stützt sich auf Art. 6 Abs. 1 lit. b DSGVO (Portal) bzw. lit. f DSGVO (Darstellung).</p> <p>Diese Speicherungen sind unbedingt erforderlich, damit wir die von Ihnen gewünschten Funktionen bereitstellen können (§ 25 Abs. 2 Nr. 2 TDDDG). Eine Einwilligung ist dafür nicht nötig. Die Verarbeitung stützt sich auf Art. 6 Abs. 1 lit. b DSGVO (Portal) bzw. lit. f DSGVO (Darstellung, Auswahl im Banner).</p>
<h2 id="reichweitenmessung">4a. Reichweitenmessung auf webklar.com</h2>
<p>Auf webklar.com messen wir mit unserer eigenen Website-Analyse, wie unsere Website genutzt wird, um sie zu verbessern. Es ist dieselbe Messung, die wir unseren Kunden anbieten (Abschnitt 6b); für webklar.com sind wir selbst Verantwortlicher. Die Messung läuft auf unserem Server bei der Hetzner Online GmbH in Deutschland (Abschnitt 3), ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Beim ersten Aufruf fragt ein Banner nach Ihrer Zustimmung.</p>
<p>Solange Sie im Banner nichts ausgewählt haben, zählen wir nur den Seitenaufruf: Gespeichert werden der Seitenpfad, das Land, das wir aus Ihrer IP-Adresse ableiten, die Geräteklasse, die wir aus dem Browser-Kennzeichen ableiten, der Zeitpunkt, auf die volle Stunde gerundet, und ein Zähler dafür, dass das Banner angezeigt wurde. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen. Wählen Sie „Ablehnen“, zählen wir einmal die Ablehnung und danach nichts mehr. Sendet Ihr Browser „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner nicht an und zählen nichts.</p>
<p>Wählen Sie „Annehmen“, messen wir zusätzlich, von welcher Website Sie gekommen sind (nur der Domainname), die Größenklasse Ihres Browserfensters, wie lange Sie auf einer Seite bleiben und wie weit Sie scrollen, wo Sie klicken (Klickposition und Aufbau des angeklickten Elements, ohne Texte und Formulareingaben, für eine „Heatmap“), ob Sie auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klicken (dann nur mit deren Domainname), und eine Besucher-Kennung, die täglich wechselt. Rechtsgrundlage ist Ihre Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO. Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen: über „Datenschutz-Einstellungen“ (das Keks-Symbol unten rechts) und „Ablehnen“.</p>
<p>IP-Adressen und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher und speichern sie in den Messdaten nicht. Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen, zusammengefasste Tageswerte ohne Besucher-Kennung nach 25 Monaten. Alle Einzelheiten, auch zum Widerspruch und zu Ihren Rechten, finden Sie auf unserer Zusatzseite <a href="https://webklar.com/datenschutz-analyse.html">webklar.com/datenschutz-analyse.html</a>.</p>
<h2>5. Kontaktaufnahme</h2> <h2>5. Kontaktaufnahme</h2>
<p>Über das Kontaktformular auf webklar.com erheben wir Name, E-Mail-Adresse, Firma (freiwillig) und Ihre Nachricht. Die Angaben speichern wir in unserer eigenen Datenbank auf unserem Server. Schreiben Sie uns per E-Mail oder rufen Sie an, verarbeiten wir die Angaben, die Sie uns dabei mitteilen.</p> <p>Über das Kontaktformular auf webklar.com erheben wir Name, E-Mail-Adresse, Firma (freiwillig) und Ihre Nachricht. Die Angaben speichern wir in unserer eigenen Datenbank auf unserem Server und schicken sie zugleich per E-Mail an unser Postfach (Abschnitt 8), damit wir von Ihrer Anfrage erfahren. Schreiben Sie uns per E-Mail oder rufen Sie an, verarbeiten wir die Angaben, die Sie uns dabei mitteilen.</p>
<p>Wir nutzen die Daten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn Ihre Anfrage auf einen Vertrag zielt, sonst Art. 6 Abs. 1 lit. f DSGVO (Interesse an der Beantwortung). Entsteht kein Vertrag, löschen wir die Anfrage spätestens sechs Monate nach der letzten Nachricht.</p> <p>Wir nutzen die Daten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn Ihre Anfrage auf einen Vertrag zielt, sonst Art. 6 Abs. 1 lit. f DSGVO (Interesse an der Beantwortung). Entsteht kein Vertrag, löschen wir die Anfrage spätestens sechs Monate nach der letzten Nachricht.</p>
<h2>6. Kundenportal</h2> <h2>6. Kundenportal</h2>
@@ -134,14 +142,43 @@
<h2>10. Domains unserer Kunden</h2> <h2>10. Domains unserer Kunden</h2>
<p>Registrieren wir für Sie eine Domain, geben wir die Daten des Domaininhabers (Name, Anschrift, E-Mail-Adresse, Telefonnummer) an den Domain-Registrar, die Hetzner Online GmbH, und an die zuständige Vergabestelle weiter, bei .de-Domains an die DENIC eG in Frankfurt am Main. Sie verarbeiten die Daten als eigene Verantwortliche. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p> <p>Registrieren wir für Sie eine Domain, geben wir die Daten des Domaininhabers (Name, Anschrift, E-Mail-Adresse, Telefonnummer) an den Domain-Registrar, die Hetzner Online GmbH, und an die zuständige Vergabestelle weiter, bei .de-Domains an die DENIC eG in Frankfurt am Main. Sie verarbeiten die Daten als eigene Verantwortliche. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p>
<h2>11. Interessenten</h2> <a id="interessenten"></a>
<p>Sprechen wir Unternehmen als mögliche Kunden an, verarbeiten wir öffentlich verfügbare Geschäftsdaten: Firmenname, Ansprechpartner, geschäftliche Kontaktdaten und Angaben zur bestehenden Website. Quellen sind etwa die Website des Unternehmens und öffentliche Branchenverzeichnisse. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der Anbahnung von Geschäftsbeziehungen. Besteht kein Interesse oder widersprechen Sie, löschen wir die Daten. Ohne Kontakt löschen wir sie spätestens nach zwölf Monaten.</p> <h2>11. Interessenten: Antworten auf öffentliche Anfragen</h2>
<p>Manche Menschen und Betriebe suchen öffentlich im Netz jemanden, der ihnen eine Website baut, zum Beispiel in einer Kleinanzeige, auf einem Portal für Freelancer oder in einem Frage-Forum. Auf solche Gesuche melden wir uns von uns aus nur einmal und bieten unsere Leistungen an. Eine Ausnahme sind Frage-Foren: Dort antworten wir nicht im Forum (siehe unten). Weil wir Ihre Daten dabei nicht bei Ihnen erheben, sondern aus Ihrem öffentlichen Beitrag, informieren wir Sie hier und mit einem Hinweis am Ende unserer Antwort darüber (Art. 14 DSGVO).</p>
<p>So läuft es ab: Ein KI-Assistent (das Sprachmodell Claude von Anthropic) bewertet, ob die Anfrage zu unseren Leistungen passt, und entwirft eine Antwort (für Beiträge aus Frage-Foren nicht, siehe unten). Ein Mitarbeiter von WEBklar liest den Entwurf, ändert ihn bei Bedarf und sendet ihn einmal von Hand: über die Plattform, auf der Sie gefragt haben, oder per E-Mail, wenn Ihr Beitrag darum bittet. Automatische E-Mails verschicken wir nicht. Für E-Mails gilt Abschnitt 8. Passt Ihre Anfrage nach der Bewertung der KI nicht zu uns, schreiben wir Ihnen nicht und kürzen die Daten spätestens am Tag nach dem Fund (das ist der Tag, an dem wir Ihre Anfrage gefunden haben; siehe unten).</p>
<p>Am Ende unserer Antwort steht ein Hinweis. Er sagt, dass eine KI-Assistentin von WEBklar antwortet, dass wir die Angaben aus Ihrer Anfrage spätestens 90 Tage nach dem Tag, an dem wir Ihre Anfrage gefunden haben, in unserer Datenbank kürzen, das heißt, Titel, Text, Link und E-Mail-Adresse löschen, und er verweist auf diesen Abschnitt. Außerdem nennt er Ihr Widerspruchsrecht: Wenn Sie keine weitere Nachricht von uns möchten oder der Speicherung widersprechen, antworten Sie uns bitte kurz; sobald wir Ihre Antwort lesen, schreiben wir Ihnen zu dieser Anfrage nicht mehr und kürzen Ihre Daten (bei einem Widerspruch sofort, sonst innerhalb von 30 Tagen). Unsere Mitarbeiter sind angewiesen, diesen Hinweis in jede Antwort aufzunehmen, auch in eine selbst geschriebene, und beim Bearbeiten eines Entwurfs nicht zu entfernen.</p>
<p>Beiträge in Frage-Foren (derzeit gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org) beantworten wir nicht im Forum. Die KI entwirft dafür keine Antwort. Ein Mitarbeiter sieht den Beitrag an und meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich um Angebote per E-Mail bittet; unsere Mitarbeiter sind angewiesen, in diese E-Mail am Ende den Hinweis aufzunehmen, der oben beschrieben ist. Bittet der Beitrag nicht darum, melden wir uns nicht.</p>
<p>Die Gesuche finden wir auf zwei Wegen. Erstens schicken uns Plattformen auf unseren Suchauftrag Benachrichtigungs-E-Mails (derzeit Kleinanzeigen, freelancermap und freelance.de); diese Plattformen rufen wir nicht selbst ab. Zweitens findet ein KI-Assistent mit einer Websuche öffentliche Beiträge im Netz und in Frage-Foren, derzeit in den Foren gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org sowie auf anderen öffentlichen Webseiten. Die Suche und den Abruf der Seiten führt Anthropic aus. Von der Websuche und vom Abruf ausgenommen sind die Plattformen Kleinanzeigen, freelancermap, freelance.de, Facebook, Instagram, LinkedIn, Xing, Reddit, Upwork, Fiverr und MyHammer sowie Seiten, deren Betreiber automatische Abrufe in der Datei robots.txt untersagen. Wir suchen nach Gesuchen, nicht nach bestimmten Personen.</p>
<p>Wir speichern den Titel des Beitrags, den Beitragstext (höchstens 4000 Zeichen), den Link, das Datum, Ort und Postleitzahl, wenn sie genannt sind, die Plattform, eine Punktzahl mit kurzer Begründung und, wenn der Beitrag um eine Antwort per E-Mail bittet, die E-Mail-Adresse aus dem Beitrag. Wir lesen Ihren Namen nicht gesondert aus; steht er im Titel oder im Text, speichern wir ihn damit. Telefonnummern entfernen wir beim Speichern automatisch aus dem Text; das folgt einer Faustregel und erkennt nicht jede Schreibweise. Dazu kommen der Entwurf unserer Antwort, die gesendete Nachricht mit Text und Zeitpunkt und ein Protokoll der einzelnen Arbeitsschritte, zum Beispiel „Neue Anfrage: Titel des Beitrags“.</p>
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, auf öffentliche Gesuche nach Website-Leistungen zu antworten. Wir melden uns je Anfrage von uns aus nur einmal, und Sie können der Verarbeitung jederzeit widersprechen.</p>
<p>Wir speichern die Daten auf unserem Server bei der Hetzner Online GmbH in Deutschland (Abschnitt 3). Für die Bewertung, den Entwurf und die Websuche setzen wir das Sprachmodell Claude der Anthropic Ireland, Limited, Dublin, Irland, als Auftragsverarbeiter ein. Anthropic erhält dafür den Titel, den Text und den Ort des Beitrags sowie bei der Websuche unsere Suchaufträge und die gefundenen Seiten. Dabei können Daten in die USA übermittelt werden, abgesichert durch Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO); eine Kopie erhalten Sie bei uns (support@webklar.com).</p>
<p>Die KI sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Ob und was wir einer passenden Anfrage antworten, entscheidet ein Mitarbeiter (Abschnitt 14).</p>
<p>Wie lange wir die Daten speichern: Wir kürzen Ihre Daten in unserer Datenbank</p>
<ul>
<li>spätestens 90 Tage nach dem Fund;</li>
<li>spätestens am Tag nach dem Fund, wenn Ihre Anfrage nicht zu uns passt;</li>
<li>innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben, wenn Sie uns sagen, dass Sie kein Interesse haben;</li>
<li>sofort, wenn Sie widersprechen: Wir sperren und kürzen sie, sobald wir Ihren Widerspruch lesen.</li>
</ul>
<p>Kürzen heißt: Wir löschen Titel, Beitragstext, Link, E-Mail-Adresse, Entwurf und unsere Nachricht an Sie und bereinigen die zugehörigen Einträge im Protokoll. Übrig bleiben nur pseudonymisierte Prüfwerte (Hash-Werte) des Beitrags, Ihrer E-Mail-Adresse und, wenn es eine Firmenadresse ist (also nicht bei Anbietern wie gmail.com oder web.de), der Domain dieser Adresse, das ist der Teil hinter dem @, zum Beispiel firma.de. Dazu bleiben Ort, Plattform, Punktzahl, Status und die Zeitpunkte von Beitrag und Fund, jedoch ohne Titel, Text, Link und Kontaktdaten.</p>
<p>Ein Hash-Wert ist ein Prüfwert, aus dem sich der ursprüngliche Inhalt nicht ablesen lässt. Wir erkennen damit nur, wenn derselbe Beitrag, dieselbe E-Mail-Adresse oder, bei einer Firmenadresse, eine andere Adresse derselben Firma noch einmal auftaucht; wir schreiben dann nicht noch einmal und nehmen den Beitrag nicht noch einmal auf. Diese Prüfwerte bewahren wir so lange auf, wie wir verhindern müssen, dass wir Sie wegen desselben Beitrags oder derselben E-Mail-Adresse (bei einer Firmenadresse: derselben Firma) erneut ansprechen. Ort, Plattform, Punktzahl, Status und die Zeitpunkte behalten wir für unsere Auswertung, damit unsere Zähler und der zeitliche Verlauf stimmen.</p>
<p>Haben Sie uns geantwortet und übernehmen wir Ihre Angaben als Kontakt oder Kunde in unsere Kundenverwaltung, gelten für diese Daten stattdessen Abschnitt 5 und, wenn ein Vertrag zustande kommt, die Abschnitte 7 und 9.</p>
<p>Für Kopien außerhalb der Datenbank gilt Folgendes. Die Benachrichtigungs-E-Mails der Plattformen enthalten die Anzeigentexte und gehen in unserem Postfach support@webklar.com bei der Hetzner Online GmbH ein (Abschnitt 8). Unser Dienst liest dort nur den Posteingang. Er lädt dazu jede neue E-Mail dieses Postfachs kurz, um den Absender zu prüfen; übernommen und gespeichert werden nur die Benachrichtigungen der Plattformen, alle anderen E-Mails verwirft er sofort. Die Benachrichtigungs-E-Mails der Plattformen löschen wir im Postfach spätestens 90 Tage nach dem Eingang, auch wenn wir die Anfrage in der Datenbank schon früher gekürzt haben.</p>
<p>Eine E-Mail, die ein Mitarbeiter Ihnen sendet, liegt außerdem in unserem Postfach; dafür gilt Abschnitt 8 und, soweit die E-Mail ein Geschäftsbrief ist, die Frist aus Abschnitt 9. Kürzen wir Daten, bleiben sie in unseren Datensicherungen noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Antwort, die wir auf einer Plattform abschicken, sehen auch deren Betreiber. Dafür gelten die Datenschutzhinweise der Plattform.</p>
<div class="box widerspruch">
<strong>Ihr Widerspruchsrecht gegen diese Verarbeitung (Art. 21 DSGVO)</strong>
<p>Sie können der Verarbeitung Ihrer Daten jederzeit widersprechen, ohne Gründe anzugeben. Antworten Sie dazu auf unsere Nachricht oder schreiben Sie an support@webklar.com.</p>
<p>Sobald wir Ihre Nachricht lesen, sperren wir den Beitrag und, soweit wir sie gespeichert haben, Ihre E-Mail-Adresse, schreiben Ihnen dazu nicht mehr und kürzen Ihre Daten wie oben beschrieben. Ist es eine Firmenadresse und nicht die eines Anbieters wie gmail.com oder web.de, sperren wir auch deren Domain, also auch andere Adressen derselben Firma. Wir erkennen Sie dabei nur am Beitrag, an Ihrer E-Mail-Adresse und, bei einer Firmenadresse, an deren Domain: Ein neues Gesuch, das Sie später einstellen, ist für uns ein neuer Beitrag, wenn es keine dieser Angaben enthält; sagen Sie uns dann bitte noch einmal Bescheid.</p>
<p>Die Benachrichtigung der Plattform in unserem Postfach erfasst das Kürzen in der Datenbank nicht; wir löschen sie dort spätestens 90 Tage nach dem Eingang (siehe oben). Weitere Rechte (Art. 15 bis 20 DSGVO) und das Beschwerderecht beschreibt Abschnitt 13.</p>
</div>
<h2>12. Empfänger im Überblick</h2> <h2>12. Empfänger im Überblick</h2>
<ul> <ul>
<li>Hetzner Online GmbH: Hosting, E-Mail und Datensicherung (Auftragsverarbeiter)</li> <li>Hetzner Online GmbH: Hosting, E-Mail (auch das Postfach für die Benachrichtigungen der Plattformen, Abschnitt 11) und Datensicherung (Auftragsverarbeiter)</li>
<li>UptimeRobot s. r. o.: Überwachung der Erreichbarkeit, Daten auch außerhalb der EU (insbesondere USA), Auftragsverarbeiter (Abschnitt 3)</li> <li>UptimeRobot s. r. o.: Überwachung der Erreichbarkeit, Daten auch außerhalb der EU (insbesondere USA), Auftragsverarbeiter (Abschnitt 3)</li>
<li>Stripe: Zahlungsabwicklung und Eröffnung des Stripe-Kontos für den Webshop und den Buchungskalender unserer Kunden (Abschnitte 6d, 6f und 7)</li> <li>Stripe: Zahlungsabwicklung und Eröffnung des Stripe-Kontos für den Webshop und den Buchungskalender unserer Kunden (Abschnitte 6d, 6f und 7)</li>
<li>Anthropic Ireland, Limited: Bewertung öffentlicher Anfragen, Entwurf von Antworten sowie Websuche und Seitenabruf durch ein Sprachmodell (Auftragsverarbeiter, Daten auch in den USA, Abschnitt 11)</li>
<li>Plattformen, auf denen wir Ihre Anfrage beantworten, zum Beispiel Kleinanzeigen: Unsere Antwort läuft über deren Dienst (Abschnitt 11)</li>
<li>Porkbun LLC: bisheriger E-Mail-Dienst, nur noch bis zur Löschung älterer E-Mails (Abschnitt 8)</li> <li>Porkbun LLC: bisheriger E-Mail-Dienst, nur noch bis zur Löschung älterer E-Mails (Abschnitt 8)</li>
<li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li> <li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li>
<li>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li> <li>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li>
@@ -149,15 +186,15 @@
<p>Wir verkaufen keine Daten und geben sie nicht zu Werbezwecken weiter.</p> <p>Wir verkaufen keine Daten und geben sie nicht zu Werbezwecken weiter.</p>
<h2>13. Ihre Rechte</h2> <h2>13. Ihre Rechte</h2>
<p>Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18) und Datenübertragbarkeit (Art. 20). Eine Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3). Derzeit stützen wir keine Verarbeitung auf eine Einwilligung.</p> <p>Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18) und Datenübertragbarkeit (Art. 20). Eine Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3). Auf eine Einwilligung stützen wir derzeit nur die erweiterte Reichweitenmessung auf webklar.com (Abschnitt 4a).</p>
<div class="box widerspruch"> <div class="box widerspruch">
<strong>Widerspruchsrecht (Art. 21 DSGVO)</strong> <strong>Widerspruchsrecht (Art. 21 DSGVO)</strong>
<p>Verarbeiten wir Ihre Daten auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO, können Sie aus Gründen, die sich aus Ihrer besonderen Situation ergeben, jederzeit widersprechen. Verarbeiten wir Ihre Daten zur Direktwerbung, können Sie dem jederzeit ohne Angabe von Gründen widersprechen. Dann nutzen wir Ihre Daten nicht mehr für diesen Zweck. Ein formloser Hinweis an die Kontaktdaten in Abschnitt 1 genügt.</p> <p>Verarbeiten wir Ihre Daten auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO, können Sie aus Gründen, die sich aus Ihrer besonderen Situation ergeben, jederzeit widersprechen. Verarbeiten wir Ihre Daten zur Direktwerbung, können Sie dem jederzeit ohne Angabe von Gründen widersprechen. Dann nutzen wir Ihre Daten nicht mehr für diesen Zweck. Ein formloser Hinweis an die Kontaktdaten in Abschnitt 1 genügt.</p>
</div> </div>
<p>Sie können sich außerdem bei einer Datenschutz-Aufsichtsbehörde beschweren. Für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz, Hintere Bleiche 34, 55116 Mainz, poststelle@datenschutz.rlp.de.</p> <p>Sie können sich außerdem bei einer Datenschutz-Aufsichtsbehörde beschweren. Für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz, Hintere Bleiche 34, 55116 Mainz, poststelle@datenschutz.rlp.de.</p>
<h2>14. Pflicht zur Bereitstellung, keine automatisierten Entscheidungen</h2> <h2>14. Pflicht zur Bereitstellung, keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO</h2>
<p>Für einen Vertrag mit uns brauchen wir die dafür nötigen Angaben. Ohne sie können wir den Vertrag nicht schließen. Darüber hinaus sind Sie nicht verpflichtet, uns Daten bereitzustellen. Automatisierte Entscheidungen einschließlich Profiling im Sinne von Art. 22 DSGVO finden nicht statt.</p> <p>Für einen Vertrag mit uns brauchen wir die dafür nötigen Angaben. Ohne sie können wir den Vertrag nicht schließen. Darüber hinaus sind Sie nicht verpflichtet, uns Daten bereitzustellen. Entscheidungen, die Ihnen gegenüber rechtliche Wirkung entfalten oder Sie in ähnlicher Weise erheblich beeinträchtigen (Art. 22 DSGVO), treffen wir nicht automatisiert. Die Bewertung öffentlicher Anfragen durch einen KI-Assistenten (Abschnitt 11) sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Für alle anderen bereitet sie höchstens eine Antwort vor; ob und was wir antworten, entscheidet ein Mitarbeiter, der die Antwort von Hand abschickt.</p>
<h2>15. Änderungen</h2> <h2>15. Änderungen</h2>
<p>Wir passen diese Erklärung an, wenn sich unsere Verarbeitung oder die Rechtslage ändert. Es gilt die jeweils hier veröffentlichte Fassung.</p> <p>Wir passen diese Erklärung an, wenn sich unsere Verarbeitung oder die Rechtslage ändert. Es gilt die jeweils hier veröffentlichte Fassung.</p>
+19 -2
View File
@@ -58,20 +58,36 @@ export function verdrahteEingabe({ win = globalThis.window, doc = globalThis.doc
} }
} }
let aktiv = null // der laufende Chat: { timer, composer, eingabe, beobachter } /**
* Druck auf einen Knopf der Eingabezeile (Senden, Anhang, Mikrofon) nimmt dem Textfeld den Fokus nicht. Sonst endet mit dem Fokus die Tastatur-Stellung der Hülle
* (leiste.js): die Leiste kommt zurück und schiebt die fest stehende Eingabe um ihre Höhe nach oben, bevor der Finger loslässt, und der Tipp auf „Senden“
* geht ins Leere (Android). Das Textfeld behält den Fokus, die Tastatur bleibt wie in jedem Chat offen. Tab und Tastatur-Bedienung bleiben unberührt.
* Rückgabe { abbau } entfernt den Hörer.
*/
export function haltFokus(wurzel) {
const beiDruck = (ev) => {
const knopf = ev.target?.closest?.('button')
if (knopf && wurzel.contains(knopf)) ev.preventDefault()
}
wurzel.addEventListener('mousedown', beiDruck)
return { abbau() { wurzel.removeEventListener('mousedown', beiDruck) } }
}
let aktiv = null // der laufende Chat: { timer, composer, eingabe, beobachter, fokus }
function raeumeAuf() { function raeumeAuf() {
if (!aktiv) return if (!aktiv) return
if (aktiv.timer) clearInterval(aktiv.timer) if (aktiv.timer) clearInterval(aktiv.timer)
try { aktiv.composer?.destroy?.() } catch { /* schon abgebaut */ } try { aktiv.composer?.destroy?.() } catch { /* schon abgebaut */ }
try { aktiv.eingabe?.abbau() } catch { /* schon abgebaut */ } try { aktiv.eingabe?.abbau() } catch { /* schon abgebaut */ }
try { aktiv.fokus?.abbau() } catch { /* schon abgebaut */ }
try { aktiv.beobachter?.disconnect() } catch { /* schon abgebaut */ } try { aktiv.beobachter?.disconnect() } catch { /* schon abgebaut */ }
aktiv = null aktiv = null
} }
export async function zeigeChat(wurzel, { route, me, nav } = {}) { export async function zeigeChat(wurzel, { route, me, nav } = {}) {
raeumeAuf() raeumeAuf()
const eigener = { timer: null, composer: null, eingabe: null, beobachter: null } const eigener = { timer: null, composer: null, eingabe: null, beobachter: null, fokus: null }
aktiv = eigener aktiv = eigener
const gilt = () => aktiv === eigener const gilt = () => aktiv === eigener
leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …')) leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …'))
@@ -160,6 +176,7 @@ export async function zeigeChat(wurzel, { route, me, nav } = {}) {
// Eingabe über der Tastatur halten; wird der Verlauf kleiner (Tastatur, Leiste), bleibt die neueste Nachricht sichtbar, sofern man unten war (E-Fix M5) // Eingabe über der Tastatur halten; wird der Verlauf kleiner (Tastatur, Leiste), bleibt die neueste Nachricht sichtbar, sofern man unten war (E-Fix M5)
eigener.eingabe = verdrahteEingabe({ win: globalThis.window, doc: globalThis.document, wurzel: composerWurzel }) eigener.eingabe = verdrahteEingabe({ win: globalThis.window, doc: globalThis.document, wurzel: composerWurzel })
eigener.fokus = haltFokus(composerWurzel)
if (typeof globalThis.ResizeObserver === 'function') { if (typeof globalThis.ResizeObserver === 'function') {
eigener.beobachter = new globalThis.ResizeObserver(() => { if (verlauf.dataset.stick !== 'false') verlauf.scrollTop = verlauf.scrollHeight }) eigener.beobachter = new globalThis.ResizeObserver(() => { if (verlauf.dataset.stick !== 'false') verlauf.scrollTop = verlauf.scrollHeight })
eigener.beobachter.observe(verlauf) eigener.beobachter.observe(verlauf)
+29 -15
View File
@@ -40,30 +40,39 @@ function aboEndeText(f) {
return tag ? `Dein Abo ist beendet. Deine Daten bleiben bis ${tag} gespeichert.` : 'Dein Abo ist beendet.' return tag ? `Dein Abo ist beendet. Deine Daten bleiben bis ${tag} gespeichert.` : 'Dein Abo ist beendet.'
} }
/** Pille und Zeile einer Funktion an einer Website (nur für Websites, bei denen istInMeine gilt). */ const KURZINFO_MAX = 80 // wie der Server (kurzinfo.js): länger ist kein Detail mehr, sondern ein Fehler
/** Kurzinfo einer aktiven Funktion aus der Antwort des Servers: nur ein nicht leerer, kurzer Text; sonst '' (die Zeile zeigt dann „Aktiv“). */
function kurzinfoVon(quelle) {
const text = typeof quelle?.kurzinfo === 'string' ? quelle.kurzinfo.trim() : ''
return text !== '' && text.length <= KURZINFO_MAX ? text : ''
}
/**
* Pille und Detailzeile einer Funktion an einer Website (nur für Websites, bei denen istInMeine gilt). Läuft die Funktion, gibt es keine
* Pille: die Detailzeile ist die Kurzinfo des Servers („3 Termine heute · nächster 10:30“) oder „Aktiv“. Alles andere trägt eine Pille.
*/
function statusVon(eintrag, projekt) { function statusVon(eintrag, projekt) {
if (eintrag.key === 'analyse') { if (eintrag.key === 'analyse') {
const a = projekt.analyse const a = projekt.analyse
if (a.state === 'active') return { pille: { text: 'Aktiv', ton: 'gruen' }, status: 'Läuft auf deiner Website' } if (a.state === 'active') return { pille: null, status: kurzinfoVon(a) || 'Aktiv' }
return { pille: { text: 'Gesperrt', ton: 'grau' }, status: a.disabledReason === 'abo_ende' ? aboEndeText(a) : 'Schreib uns im Chat' } return { pille: { text: 'Gesperrt', ton: 'grau' }, status: a.disabledReason === 'abo_ende' ? aboEndeText(a) : 'Schreib uns im Chat' }
} }
const f = projekt.features[eintrag.key] const f = projekt.features[eintrag.key]
switch (f.state) { switch (f.state) {
case 'setup': case 'setup':
return { pille: { text: 'In Einrichtung', ton: 'blau' }, status: f.ticketWoid ? `Einrichtung läuft · Ticket #${f.ticketWoid}` : 'Einrichtung läuft' } return { pille: { text: 'In Einrichtung', ton: 'blau' }, status: 'Wir richten sie gerade ein' }
case 'ordered': case 'ordered':
return { pille: { text: 'Zahlung offen', ton: 'orange' }, status: 'Wir warten auf deine Zahlung' } return { pille: { text: 'Zahlung offen', ton: 'orange' }, status: 'Wir warten auf deine Zahlung' }
case 'active': { case 'active':
const seit = datumDe(f.activatedAt) return { pille: null, status: kurzinfoVon(f) || 'Aktiv' }
return { pille: { text: 'Aktiv', ton: 'gruen' }, status: seit ? `Aktiv seit ${seit}` : 'Läuft auf deiner Website' }
}
case 'disabled': case 'disabled':
return { pille: { text: 'Abgeschaltet', ton: 'grau' }, status: f.disabledReason === 'abo_ende' ? aboEndeText(f) : 'Schreib uns im Chat, wenn du die Funktion wieder möchtest.' } return { pille: { text: 'Abgeschaltet', ton: 'grau' }, status: f.disabledReason === 'abo_ende' ? aboEndeText(f) : 'Im Chat wieder anfragen' }
default: { default: {
if (eintrag.ablauf === 'fragebogen') return { pille: { text: 'Entwurf', ton: 'grau' }, status: 'Fragebogen fortsetzen' } if (eintrag.ablauf === 'fragebogen') return { pille: { text: 'Entwurf', ton: 'grau' }, status: 'Fragebogen fortsetzen' }
const anfrage = anfrageStatusVon(projekt, eintrag.key) const anfrage = anfrageStatusVon(projekt, eintrag.key)
if (anfrage === 'angenommen') return { pille: { text: 'Angenommen', ton: 'blau' }, status: 'Jetzt einschalten' } if (anfrage === 'angenommen') return { pille: { text: 'Angenommen', ton: 'blau' }, status: 'Jetzt einschalten' }
if (anfrage === 'abgelehnt') return { pille: { text: 'Abgelehnt', ton: 'rot' }, status: 'Schreib uns im Chat' } if (anfrage === 'abgelehnt') return { pille: { text: 'Abgelehnt', ton: 'rot' }, status: 'Anfrage abgelehnt · Im Chat nachfragen' }
return { pille: { text: 'Angefragt', ton: 'violett' }, status: 'Wir prüfen deine Anfrage' } return { pille: { text: 'Angefragt', ton: 'violett' }, status: 'Wir prüfen deine Anfrage' }
} }
} }
@@ -71,8 +80,9 @@ function statusVon(eintrag, projekt) {
/** /**
* Die Zeilen von „Meine“: je Funktion und Website eine, in Katalogreihenfolge, dann Websitereihenfolge. Funktionen mit * Die Zeilen von „Meine“: je Funktion und Website eine, in Katalogreihenfolge, dann Websitereihenfolge. Funktionen mit
* verfuegbar !== true fehlen (C9). zeile2 ist bei mehreren Websites der Websitename, sonst die Statuszeile. * verfuegbar !== true fehlen (C9). pille ist null, solange die Funktion läuft; status ist die Detailzeile (Kurzinfo oder Zustandstext),
* @returns {Array<{ key: string, name: string, projectId: string, projektName: string, zustand: string, anfrageStatus: string, pille: { text: string, ton: string }, status: string, zeile2: string }>} * zeile2 ist bei mehreren Websites „Websitename · status“, sonst der Status allein.
* @returns {Array<{ key: string, name: string, projectId: string, projektName: string, zustand: string, anfrageStatus: string, pille: { text: string, ton: string }|null, status: string, zeile2: string }>}
*/ */
export function meineZeilen(daten) { export function meineZeilen(daten) {
const seiten = websites(daten) const seiten = websites(daten)
@@ -82,16 +92,17 @@ export function meineZeilen(daten) {
for (const projekt of seiten) { for (const projekt of seiten) {
if (!istInMeine(eintrag, projekt)) continue if (!istInMeine(eintrag, projekt)) continue
const { pille, status } = statusVon(eintrag, projekt) const { pille, status } = statusVon(eintrag, projekt)
const projektName = String(projekt.projectName ?? '')
zeilen.push({ zeilen.push({
key: eintrag.key, key: eintrag.key,
name: String(eintrag.name ?? ''), name: String(eintrag.name ?? ''),
projectId: projekt.projectId, projectId: projekt.projectId,
projektName: String(projekt.projectName ?? ''), projektName,
zustand: String(zustandVon(projekt, eintrag.key) ?? ''), zustand: String(zustandVon(projekt, eintrag.key) ?? ''),
anfrageStatus: eintrag.key === 'analyse' ? '' : anfrageStatusVon(projekt, eintrag.key), anfrageStatus: eintrag.key === 'analyse' ? '' : anfrageStatusVon(projekt, eintrag.key),
pille, pille,
status, status,
zeile2: mehrere ? String(projekt.projectName ?? '') : status, zeile2: mehrere && projektName !== '' ? `${projektName} · ${status}` : status,
}) })
} }
} }
@@ -138,6 +149,9 @@ export function vergissMerker() {
const BILD = /^\/neu\/bilder\/[a-z0-9-]+\.webp$/ const BILD = /^\/neu\/bilder\/[a-z0-9-]+\.webp$/
/** Nur diese Seite (Meine, Detailzeile) bittet den Server um die Kurzinfo; Etagen, Einschalt-Ablauf und Module laden GET /api/funktionen ohne den Parameter. */
const FUNKTIONEN_PFAD = '/api/funktionen?kurzinfo=1'
/** Stück für die Pille einer Zeile. */ /** Stück für die Pille einer Zeile. */
const pille = (p) => h('span', { class: `kn-fn-pille kn-fn-pille--${p.ton}` }, p.text) const pille = (p) => h('span', { class: `kn-fn-pille kn-fn-pille--${p.ton}` }, p.text)
@@ -168,7 +182,7 @@ export async function zeigeFunktionen(wurzel, { route, me, nav } = {}, abh = {})
} else { } else {
leeren(wurzel).append(h('p', { class: 'kn-leer' }, 'Wird geladen …')) leeren(wurzel).append(h('p', { class: 'kn-leer' }, 'Wird geladen …'))
try { try {
[daten, offen] = await Promise.all([A.api('/api/funktionen'), ladeOffeneRechnungen(A.api)]) [daten, offen] = await Promise.all([A.api(FUNKTIONEN_PFAD), ladeOffeneRechnungen(A.api)])
} catch { } catch {
if (!aktuell()) return if (!aktuell()) return
leeren(wurzel).append( leeren(wurzel).append(
@@ -260,7 +274,7 @@ export async function zeigeFunktionen(wurzel, { route, me, nav } = {}, abh = {})
}, },
}, },
h('span', { class: 'kn-fn-text' }, h('span', { class: 'kn-fn-name' }, z.name), h('span', { class: 'kn-fn-zeile2' }, z.zeile2)), h('span', { class: 'kn-fn-text' }, h('span', { class: 'kn-fn-name' }, z.name), h('span', { class: 'kn-fn-zeile2' }, z.zeile2)),
pille(z.pille))))) h('span', { class: 'kn-fn-rechts' }, z.pille ? pille(z.pille) : null, h('span', { class: 'kn-fn-pfeil', 'aria-hidden': 'true' }, '›'))))))
} }
function eintragKarte({ eintrag, kandidaten }) { function eintragKarte({ eintrag, kandidaten }) {
+2 -1
View File
@@ -4,7 +4,8 @@
import { api } from './api.js' import { api } from './api.js'
import { h, leeren } from './dom.js' import { h, leeren } from './dom.js'
import { K, ersetze, zuAnsicht, meldeTitel, fmtDatumZeit, sichereAdresse } from './ansicht-hilfen.js' import { K, ersetze, zuAnsicht, meldeTitel, fmtDatumZeit, sichereAdresse } from './ansicht-hilfen.js'
import { baueAktivitaet, baueProjektDetails, ladeProjektDetails } from './website-details.js' import { baueProjektDetails, ladeProjektDetails } from './website-details.js'
import { baueAktivitaet } from './auftrags-zeitleiste.js'
import { ladeOffeneRechnungen, rechnungOffenZeile } from './rechnung-offen.js' import { ladeOffeneRechnungen, rechnungOffenZeile } from './rechnung-offen.js'
import { starteInstallQuelle, installAngebot, baueInstallHinweis, istAusgeblendet, merkeAusgeblendet } from './install-hinweis.js' import { starteInstallQuelle, installAngebot, baueInstallHinweis, istAusgeblendet, merkeAusgeblendet } from './install-hinweis.js'
import { HANDY_ABFRAGE } from './leiste.js' import { HANDY_ABFRAGE } from './leiste.js'
+38 -9
View File
@@ -85,10 +85,7 @@
.kn-projekt-links { display: flex; flex-wrap: wrap; gap: 8px; } .kn-projekt-links { display: flex; flex-wrap: wrap; gap: 8px; }
/* Auftrag, Änderungen, Arbeiten, Aktivität */ /* Auftrag, Änderungen, Arbeiten, Aktivität */
.kn-aktivitaet { margin: 0; }
[data-abschnitt="aktivitaet"] { scroll-margin-top: 16px; } /* #fortschritt: scrollIntoView lässt den Abschnitt nicht hinter dem haftenden Kopf beginnen (am Handy unten) */ [data-abschnitt="aktivitaet"] { scroll-margin-top: 16px; } /* #fortschritt: scrollIntoView lässt den Abschnitt nicht hinter dem haftenden Kopf beginnen (am Handy unten) */
.kn-auftrag-karte { padding: 16px 0; border-top: 1px solid var(--kn-line); }
.kn-auftrag-karte > .kn-auftrag + * { margin-top: 12px; }
.kn-auftrag-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .kn-auftrag-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.kn-auftrag-name { min-width: 0; } .kn-auftrag-name { min-width: 0; }
.kn-auftrag-nr { display: block; font-size: 12px; color: var(--kn-text-2); font-variant-numeric: tabular-nums; } .kn-auftrag-nr { display: block; font-size: 12px; color: var(--kn-text-2); font-variant-numeric: tabular-nums; }
@@ -113,6 +110,32 @@
.kn-eintrag-meta { font-size: 13px; color: var(--kn-text-2); } .kn-eintrag-meta { font-size: 13px; color: var(--kn-text-2); }
.kn-eintrag-text { margin: 4px 0 0; color: var(--kn-text-2); font-size: 13px; } .kn-eintrag-text { margin: 4px 0 0; color: var(--kn-text-2); font-size: 13px; }
.kn-unterstuetzung li { display: flex; justify-content: space-between; gap: 12px; margin-top: 4px; font-size: 13px; color: var(--kn-text-2); } .kn-unterstuetzung li { display: flex; justify-content: space-between; gap: 12px; margin-top: 4px; font-size: 13px; color: var(--kn-text-2); }
/* „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C): je Auftrag Überschrift mit Pille, schmaler Balken in vier Abschnitten, Zeitleiste mit Haarlinie */
.kn-az-liste { margin: 0; }
.kn-az-liste:not(.is-open) > .kn-az-auftraege > [data-extra] { display: none; } /* nur direkte Kinder: die Zeitleisten darin klappen für sich */
.kn-az-auftraege { list-style: none; margin: 0; padding: 0; }
.kn-az { padding: 20px 0; border-top: 1px solid var(--kn-line); }
.kn-az-kopf { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 6px 12px; }
.kn-az-titel { flex: 1 1 12em; min-width: 0; margin: 0; font-size: 16px; line-height: 24px; font-weight: 600; overflow-wrap: anywhere; }
.kn-az-kopf .kn-pille { flex: none; margin-top: 2px; }
.kn-az-info { margin: 8px 0 0; font-size: 14px; line-height: 20px; }
.kn-az-info a { color: var(--kn-orange-fg); text-decoration: none; }
.kn-az-info-link { font-weight: 600; text-decoration: underline; text-decoration-color: currentColor; text-underline-offset: 3px; white-space: nowrap; } /* der Linktext bleibt zusammen */
.kn-az-balken { display: flex; gap: 3px; margin: 12px 0 0; padding: 0; list-style: none; }
.kn-az-balken li { position: relative; flex: 1; height: 4px; }
.kn-az-balken li::before { content: ""; position: absolute; inset: 0; border-radius: 2px; background: var(--kn-line); } /* die Fläche liegt im Pseudo-Element: der Lesetext darunter bleibt auf Weiß */
.kn-az-balken li.is-done::before { background: var(--kn-accent); }
.kn-az-zeitleiste { margin: 14px 0 0; }
.kn-az-zeilen { list-style: none; margin: 0 0 0 3px; padding: 0; border-left: 1px solid var(--kn-line-strong); }
.kn-az-zeilen li { position: relative; display: flex; gap: 10px; padding: 4px 0 4px 16px; font-size: 14px; line-height: 20px; }
.kn-az-zeilen li::before { content: ""; position: absolute; left: -3px; top: 12px; width: 5px; height: 5px; border-radius: 50%; background: var(--kn-line-strong); }
.kn-az-datum { flex: none; min-width: 3.2em; color: var(--kn-text-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
.kn-az-mit-jahr .kn-az-datum { min-width: 4.4em; }
.kn-az-text { min-width: 0; overflow-wrap: anywhere; }
.kn-az-zeitleiste:not(.is-open) > .kn-az-zeilen > [data-extra] { display: none; }
.kn-az-mehr { margin: 4px 0 0 8px; }
.kn-az-leer { margin: 12px 0 0; font-size: 14px; color: var(--kn-text-2); }
.kn-az-aeltere { margin: 8px 0 0; }
.kn-dateien { margin: 0; } .kn-dateien { margin: 0; }
.kn-details { margin: 0; } .kn-details { margin: 0; }
@@ -128,13 +151,16 @@
--chat-surface: var(--kn-bg); --chat-surface: var(--kn-bg);
--chat-line: var(--kn-line-strong); --chat-line: var(--kn-line-strong);
--chat-muted: var(--kn-text-2); --chat-muted: var(--kn-text-2);
--chat-bubble-in: var(--kn-bg-hover); --chat-bubble-in: var(--kn-bg-subtle);
--chat-bubble-out: var(--kn-grau-bg); --chat-bubble-out: var(--kn-grau-bg);
--chat-track: var(--kn-spur); --chat-track: var(--kn-spur);
display: flex; flex-direction: column; gap: var(--kn-sp-2); min-width: 0; display: flex; flex: 1 1 auto; flex-direction: column; gap: var(--kn-sp-2); width: 100%; min-width: 0; min-height: 0; max-width: 760px; margin-inline: auto;
} }
.kn .kn-chat .chat-box { display: flex; flex-direction: column; gap: var(--kn-sp-2); min-width: 0; } .kn .kn-chat .chat-box { display: flex; flex: 1 1 auto; flex-direction: column; gap: var(--kn-sp-2); min-width: 0; min-height: 0; }
.kn .kn-chat .chat-messages { display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto; padding: 4px 0; } /* Der Verlauf füllt die Höhe zwischen Kopf und Eingabe und scrollt in sich; kurze Verläufe stehen unten, bei der Eingabe (Rahmen der Höhe: huelle.css) */
.kn .kn-chat .chat-messages { display: flex; flex: 1 1 0; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 8px 0; }
.kn .kn-chat .chat-messages > :first-child { margin-top: auto; }
.kn .kn-chat .chat-messages > .kn-leer { margin: auto 0; padding: 24px 0; }
.kn .kn-chat .chat-bubble { .kn .kn-chat .chat-bubble {
align-self: flex-start; max-width: 78%; padding: 8px 12px; border: 0; border-radius: var(--kn-radius-lg); align-self: flex-start; max-width: 78%; padding: 8px 12px; border: 0; border-radius: var(--kn-radius-lg);
background: var(--chat-bubble-in); color: var(--kn-text); overflow-wrap: anywhere; background: var(--chat-bubble-in); color: var(--kn-text); overflow-wrap: anywhere;
@@ -158,6 +184,7 @@
.kn .kn-chat .chat-icon-btn:hover:not(:disabled) { background: var(--kn-bg-hover); } .kn .kn-chat .chat-icon-btn:hover:not(:disabled) { background: var(--kn-bg-hover); }
.kn .kn-chat .chat-icon-btn[aria-expanded="true"], .kn .kn-chat .chat-icon-btn[aria-expanded="true"]:hover:not(:disabled) { background: var(--kn-accent-soft); box-shadow: 0 0 0 1px var(--kn-accent); } .kn .kn-chat .chat-icon-btn[aria-expanded="true"], .kn .kn-chat .chat-icon-btn[aria-expanded="true"]:hover:not(:disabled) { background: var(--kn-accent-soft); box-shadow: 0 0 0 1px var(--kn-accent); }
.kn .kn-chat .chat-icon-btn:disabled, .kn .kn-chat .chat-send:disabled { opacity: .55; cursor: not-allowed; } .kn .kn-chat .chat-icon-btn:disabled, .kn .kn-chat .chat-send:disabled { opacity: .55; cursor: not-allowed; }
.kn .kn-chat .chat-composer:not(.is-disabled) .chat-send:disabled:not(.is-busy) { opacity: 1; cursor: default; } /* „Senden“ bleibt der schwarze Hauptknopf, auch bei leerem Feld; gesperrt (Ansichtsmodus) wirkt er weiter grau */
.kn .kn-chat .chat-send.is-busy:disabled { opacity: .8; cursor: progress; } .kn .kn-chat .chat-send.is-busy:disabled { opacity: .8; cursor: progress; }
.kn .kn-chat .chat-rec { border-color: var(--kn-danger-ring); background: var(--kn-rot-bg); } .kn .kn-chat .chat-rec { border-color: var(--kn-danger-ring); background: var(--kn-rot-bg); }
.kn .kn-chat .chat-rec .chat-icon-btn { background: transparent; box-shadow: none; } .kn .kn-chat .chat-rec .chat-icon-btn { background: transparent; box-shadow: none; }
@@ -171,7 +198,7 @@
.kn .kn-chat .chat-tray-item { border: 1px solid transparent; background: var(--kn-bg-hover); } .kn .kn-chat .chat-tray-item { border: 1px solid transparent; background: var(--kn-bg-hover); }
.kn .kn-chat .chat-tray-item.is-error { border-color: var(--kn-danger); background: var(--kn-rot-bg); } .kn .kn-chat .chat-tray-item.is-error { border-color: var(--kn-danger); background: var(--kn-rot-bg); }
.kn .kn-chat.chat-drop-active::after, .kn .kn-chat .chat-box.chat-drop-active::after { background: var(--kn-ablage-hell); color: var(--kn-text); border-color: var(--kn-text-3); } .kn .kn-chat.chat-drop-active::after, .kn .kn-chat .chat-box.chat-drop-active::after { background: var(--kn-ablage-hell); color: var(--kn-text); border-color: var(--kn-text-3); }
.kn .kn-chat .kn-chat-composer { margin-top: 0; } .kn .kn-chat .kn-chat-composer { flex: none; margin-top: 0; }
.kn .kn-chat .kn-chat-kasten { position: relative; } .kn .kn-chat .kn-chat-kasten { position: relative; }
.kn .kn-chat .kn-chat-verlauf { scroll-behavior: auto; } .kn .kn-chat .kn-chat-verlauf { scroll-behavior: auto; }
.kn .kn-chat .chat-composer { margin-top: 8px; } .kn .kn-chat .chat-composer { margin-top: 8px; }
@@ -357,6 +384,9 @@
.kn-zeilenlink { display: inline-flex; align-items: center; min-height: 44px; } .kn-zeilenlink { display: inline-flex; align-items: center; min-height: 44px; }
/* Pflicht- und Zahlungslinks (E-Fix F3): Verträge hier kündigen, Vertrag widerrufen, Vertragsbedingungen, Zahlen in der Zeile „Rechnung offen“ */ /* Pflicht- und Zahlungslinks (E-Fix F3): Verträge hier kündigen, Vertrag widerrufen, Vertragsbedingungen, Zahlen in der Zeile „Rechnung offen“ */
.kn-kuendigen a, .kn-widerruf a, .kn-links a, .kn-rechnung-offen a { display: inline-flex; align-items: center; min-height: 44px; } .kn-kuendigen a, .kn-widerruf a, .kn-links a, .kn-rechnung-offen a { display: inline-flex; align-items: center; min-height: 44px; }
.kn-az-info { margin-top: 0; }
.kn-az-info a { display: block; padding: 12px 0; }
.kn-az-info + .kn-az-balken { margin-top: 0; }
.kn-rechnung-offen { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .kn-rechnung-offen { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.kn-rechnung-offen .kn-trenner { display: none; } .kn-rechnung-offen .kn-trenner { display: none; }
.kn-rechnung-offen a { flex: none; font-weight: 600; } .kn-rechnung-offen a { flex: none; font-weight: 600; }
@@ -377,7 +407,6 @@
.kn-sperrzeile { margin-bottom: 8px; } .kn-sperrzeile { margin-bottom: 8px; }
.kn-bestellen .kn-knopf { width: 100%; min-height: 44px; } .kn-bestellen .kn-knopf { width: 100%; min-height: 44px; }
.kn .kn-chat .chat-bubble { max-width: 88%; } .kn .kn-chat .chat-bubble { max-width: 88%; }
.kn .kn-chat .chat-messages { min-height: 240px; max-height: 55vh; }
.kn .kn-chat .chat-input-row textarea { font-size: 16px; } .kn .kn-chat .chat-input-row textarea { font-size: 16px; }
.kn .ab-portal .ab-karte-aktionen a, .kn .ab-portal .ab-knopf, .kn .ab-portal .ab-details > summary, .kn .ab-portal .ab-links a { min-height: 44px; } .kn .ab-portal .ab-karte-aktionen a, .kn .ab-portal .ab-knopf, .kn .ab-portal .ab-details > summary, .kn .ab-portal .ab-links a { min-height: 44px; }
.kn .ab-portal .ab-knopf { width: 100%; } .kn .ab-portal .ab-knopf { width: 100%; }
+186
View File
@@ -0,0 +1,186 @@
/* Kundenansicht neu: „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C, Pilot-Feedback 17.10.).
Je Auftrag aus GET /api/activity: Überschrift mit Status-Pille, schmaler Fortschrittsbalken, bei Rückfrage der Hinweis zum Chat und eine
kompakte Zeitleiste der Einträge. Die Logik (Reihenfolge, Gruppierung, Kürzen, Fortschritt, Kurztexte) sind reine Funktionen; gezeichnet wird
mit h() und Textknoten, nie als HTML. Kein DOM-Zugriff beim Import.
Antwortform laut server/routes/customerData.js und services/customerProjectView.js:
{ number, topic, status: { label, tone, step }, createdAt, entries: [{ kind, label, date: 'JJJJ-MM-TT', minutes, person, text | changes }] }. */
import { h } from './dom.js'
import { K, berlinTag, zuAnsicht } from './ansicht-hilfen.js'
import { AUFTRAG_SCHRITTE, statusTon } from './website-details.js'
/** Die vier Abschnitte des Balkens (dieselben Namen wie der Fortschritt der Projektdetails). */
export const STUFEN = AUFTRAG_SCHRITTE
/** So viele Aufträge stehen offen, der Rest hinter „Ältere Aufträge anzeigen“. */
export const MAX_AUFTRAEGE = 5
/** So viele Zeilen der Zeitleiste stehen offen, der Rest hinter „mehr anzeigen“. */
export const MAX_ZEILEN = 3
const MAX_KURZTEXT = 90
/* ---------------------------------------------------------------- Fortschritt --- */
/** Die vier Abschnitte des Balkens: erreicht bis zum aktuellen Stand; Stufe 0 (pausiert, storniert) und Unbekanntes erreichen nichts. */
export function fortschritt(status) {
const schritt = Number(status?.step)
const gueltig = Number.isInteger(schritt) && schritt >= 1 && schritt <= STUFEN.length
return STUFEN.map((name, i) => ({ name, erreicht: gueltig && i < schritt, aktuell: gueltig && i === schritt - 1 }))
}
/** Braucht der Auftrag eine Info vom Kunden? Der Server meldet das mit dem Ton „waiting“ (Ticketstatus „awaiting“, „Wartet auf deine Rückmeldung“). */
export const braucheInfo = (auftrag) => auftrag?.status?.tone === 'waiting'
/* ---------------------------------------------------------- Reihenfolge, Kürzen --- */
const eintraegeVon = (auftrag) => (Array.isArray(auftrag?.entries) ? auftrag.entries.filter((e) => e && typeof e === 'object') : [])
// 'JJJJ-MM-TT' (auch als Anfang eines ISO-Zeitpunkts) → { j, m, t } ohne Zeitzonen-Umrechnung; sonst null
function tagTeile(wert) {
const treffer = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(wert ?? ''))
if (!treffer) return null
const [j, m, t] = [Number(treffer[1]), Number(treffer[2]), Number(treffer[3])]
return m >= 1 && m <= 12 && t >= 1 && t <= 31 ? { j, m, t } : null
}
const zweistellig = (zahl) => String(zahl).padStart(2, '0')
const isoTag = (wert) => { const tag = tagTeile(wert); return tag ? `${tag.j}-${zweistellig(tag.m)}-${zweistellig(tag.t)}` : '' }
/** Zeitpunkt der jüngsten Aktivität (ms): der späteste Eintrag oder die Anlage des Auftrags; 0, wenn nichts lesbar ist. */
export function letzteAktivitaet(auftrag) {
const zeiten = [Date.parse(String(auftrag?.createdAt ?? '')), ...eintraegeVon(auftrag).map((e) => { const tag = isoTag(e.date); return tag ? Date.parse(`${tag}T00:00:00Z`) : NaN })]
return Math.max(0, ...zeiten.filter((zeit) => Number.isFinite(zeit)))
}
// 0 = laufend (auch pausiert), 1 = erledigt, 2 = storniert oder abgebrochen
const rang = (auftrag) => (auftrag?.status?.tone === 'done' ? 1 : auftrag?.status?.tone === 'stopped' ? 2 : 0)
/** Laufende Aufträge zuerst, dann erledigte, Storniertes zuletzt; unter den laufenden stehen die mit Rückfrage (braucheInfo) vor den übrigen, damit eine Frage
an den Kunden nie hinter „Ältere Aufträge anzeigen“ verschwindet. Innerhalb jeder Gruppe die jüngste Aktivität vorn, bei Gleichstand die Reihenfolge des Servers. */
export function sortiereAuftraege(tickets) {
const liste = Array.isArray(tickets) ? tickets.filter((t) => t && typeof t === 'object') : []
return liste
.map((auftrag, i) => ({ auftrag, i, rang: rang(auftrag), frage: braucheInfo(auftrag) ? 0 : 1, zeit: letzteAktivitaet(auftrag) }))
.sort((a, b) => a.rang - b.rang || a.frage - b.frage || b.zeit - a.zeit || a.i - b.i)
.map((e) => e.auftrag)
}
/** Die ersten n sichtbar, der Rest getrennt; die Eingabe bleibt unverändert. */
export function kuerze(liste, max) {
const alle = Array.isArray(liste) ? liste : []
return { sichtbar: alle.slice(0, max), rest: alle.slice(max) }
}
/** Sortierte Aufträge: höchstens max offen, der Rest als „älter“. */
export function gruppiereAuftraege(tickets, max = MAX_AUFTRAEGE) {
const { sichtbar, rest } = kuerze(sortiereAuftraege(tickets), max)
return { sichtbar, aeltere: rest }
}
/* ---------------------------------------------------------------- Zeitleiste --- */
/** 'TT.MM.' für einen Tag 'JJJJ-MM-TT'; fällt er nicht ins laufende Jahr, steht 'TT.MM.JJ' da. Unlesbares ergibt ''. */
export function datumKurz(wert, jetzt = new Date()) {
const tag = tagTeile(wert)
if (!tag) return ''
const laufend = berlinTag(jetzt)?.j ?? jetzt.getUTCFullYear()
const jahr = tag.j === laufend ? '' : zweistellig(tag.j % 100)
return `${zweistellig(tag.t)}.${zweistellig(tag.m)}.${jahr}`
}
// erste nichtleere Zeile, Leerraum zusammengezogen
const ersteZeile = (text) => String(text ?? '').split(/\r?\n/).map((zeile) => zeile.replace(/\s+/g, ' ').trim()).find(Boolean) ?? ''
function kuerzeText(text, max) {
if (text.length <= max) return text
const schnitt = text.slice(0, max)
const leer = schnitt.lastIndexOf(' ')
return `${(leer > max * 0.6 ? schnitt.slice(0, leer) : schnitt).replace(/[\s,;:.–-]+$/, '')}…`
}
/** Kurzer Text einer Zeile: die erste Änderung (mit „und n weitere“), sonst der freie Text der Arbeit, sonst die Art der Arbeit. Nie lang, nie ein Name. */
export function eintragsKurztext(eintrag) {
const aenderungen = Array.isArray(eintrag?.changes) ? eintrag.changes.map(ersteZeile).filter(Boolean) : []
if (aenderungen.length) {
const rest = aenderungen.length - 1
const erste = kuerzeText(aenderungen[0], MAX_KURZTEXT)
return rest ? `${erste} und ${rest} weitere ${rest === 1 ? 'Änderung' : 'Änderungen'}` : erste
}
return kuerzeText(ersteZeile(eintrag?.text), MAX_KURZTEXT) || ersteZeile(eintrag?.label) || 'Arbeit am Projekt'
}
/** Zeilen der Zeitleiste eines Auftrags, neueste zuerst; Einträge ohne lesbares Datum stehen unten. */
export function zeitleistenZeilen(auftrag, jetzt = new Date()) {
return eintraegeVon(auftrag)
.map((e, i) => ({ i, iso: isoTag(e.date), datum: datumKurz(e.date, jetzt), text: eintragsKurztext(e) }))
.sort((a, b) => (a.iso === b.iso ? a.i - b.i : !a.iso ? 1 : !b.iso ? -1 : b.iso.localeCompare(a.iso)))
.map(({ datum, iso, text }) => ({ datum, iso, text }))
}
/* ------------------------------------------------------------------------ DOM --- */
const LEER = 'Es wurden noch keine Arbeiten dokumentiert.'
const KEINE_EINTRAEGE = 'Noch keine Arbeiten dokumentiert.'
/** Knopf, der den nächsten Wrapper [data-einklappbar] auf- und zuklappt (is-open); die Zeilen dahinter tragen [data-extra]. Die CSS-Regeln dazu gelten nur für
direkte Kinder (ansichten.css, .kn-az-…): die allgemeine Regel .kn-einklappbar würde bei verschachtelten Wrappern auch offene Zeilen verbergen. */
function umschalter({ attribut, klasse, mehr, weniger, name, steuert }) {
// name: Zusatz für den Namen des Knopfes („mehr anzeigen zu „Titel““, der sichtbare Text steht vorn im Namen); steuert: Id der Liste, die er auf- und zuklappt
const nameFuer = (text) => (name ? `${text} zu „${name}“` : null)
const knopf = h('button', { type: 'button', class: klasse, [attribut]: true, 'aria-expanded': 'false', 'aria-label': nameFuer(mehr), 'aria-controls': steuert || null }, mehr)
knopf.addEventListener('click', () => {
const offen = knopf.closest('[data-einklappbar]').classList.toggle('is-open')
knopf.textContent = offen ? weniger : mehr
knopf.setAttribute('aria-expanded', String(offen))
if (name) knopf.setAttribute('aria-label', nameFuer(offen ? weniger : mehr))
})
return knopf
}
const zustandText = (stufe) => (stufe.aktuell ? 'aktueller Stand' : stufe.erreicht ? 'erreicht' : 'noch offen')
// Vier Abschnitte, erreichte schwarz; die Namen stehen nur für Screenreader als Text. Ohne erreichte Stufe (pausiert, storniert) gibt es keinen Balken.
function baueBalken(status) {
const stufen = fortschritt(status)
if (!stufen.some((stufe) => stufe.erreicht)) return null
return h('ol', { class: 'kn-az-balken', 'aria-label': 'Fortschritt des Auftrags' }, stufen.map((stufe) => h('li',
{ class: stufe.erreicht ? 'is-done' : null, 'aria-current': stufe.aktuell ? 'step' : null },
h('span', { class: 'kn-nur-leser' }, `${stufe.name}, ${zustandText(stufe)}`))))
}
let listenNummer = 0 // Ids der Listen für aria-controls: eindeutig je Seite, auch wenn die Ansicht mehrmals gezeichnet wird
function baueZeitleiste(zeilen, titel) {
const { sichtbar, rest } = kuerze(zeilen, MAX_ZEILEN)
listenNummer += 1
const listenId = `kn-az-zeilen-${listenNummer}`
const zeile = (z, extra) => h('li', { 'data-extra': extra },
z.datum ? h('time', { class: 'kn-az-datum', datetime: z.iso || null }, z.datum) : h('span', { class: 'kn-az-datum', 'aria-hidden': 'true' }),
h('span', { class: 'kn-az-text' }, z.text))
return h('div', { class: 'kn-az-zeitleiste', 'data-einklappbar': true },
// Trägt ein Datum das Jahr (TT.MM.JJ), bekommt die Datumsspalte aller Zeilen die Breite dafür, damit die Texte bündig bleiben
h('ol', { id: listenId, class: zeilen.some((z) => z.datum.length > 6) ? 'kn-az-zeilen kn-az-mit-jahr' : 'kn-az-zeilen' }, sichtbar.map((z) => zeile(z, false)), rest.map((z) => zeile(z, true))),
// Mehrere Knöpfe „mehr anzeigen“ auf einer Seite: der Name nennt den Auftrag, aria-controls die Liste
rest.length ? umschalter({ attribut: 'data-mehr', klasse: 'kn-textknopf kn-az-mehr', mehr: 'mehr anzeigen', weniger: 'weniger anzeigen', name: titel, steuert: listenId }) : null)
}
function baueAuftragsBlock(auftrag, jetzt, extra) {
const status = auftrag.status && typeof auftrag.status === 'object' ? auftrag.status : {}
const zeilen = zeitleistenZeilen(auftrag, jetzt)
const titel = auftrag.topic || 'Dein Auftrag'
return h('li', { class: 'kn-az', 'data-auftrag': auftrag.number || null, 'data-extra': extra },
h('div', { class: 'kn-az-kopf' },
h('h3', { class: 'kn-az-titel' }, titel),
status.label ? h('span', { class: K.pille[statusTon(status.tone)] }, status.label) : null),
// Die ganze Zeile führt in den Chat (große Tippfläche am Handy); unterstrichen ist nur „Im Chat antworten“
braucheInfo(auftrag) ? h('p', { class: 'kn-az-info' }, h('a', zuAnsicht('chat'), 'Wir brauchen eine Info von dir · ', h('span', { class: 'kn-az-info-link' }, 'Im Chat antworten'))) : null,
baueBalken(status),
zeilen.length ? baueZeitleiste(zeilen, titel) : h('p', { class: 'kn-az-leer' }, KEINE_EINTRAEGE))
}
/** „Was wir zuletzt erledigt haben“: ein Block je Auftrag; fünf offen, der Rest hinter „Ältere Aufträge anzeigen“. Ohne Aufträge ein ruhiger Satz. */
export function baueAktivitaet(tickets, jetzt = new Date()) {
const { sichtbar, aeltere } = gruppiereAuftraege(tickets)
if (!sichtbar.length) return h('p', { class: 'kn-hinweis' }, LEER)
return h('div', { class: 'kn-az-liste', 'data-einklappbar': true },
h('ul', { class: 'kn-az-auftraege' }, sichtbar.map((a) => baueAuftragsBlock(a, jetzt, false)), aeltere.map((a) => baueAuftragsBlock(a, jetzt, true))),
aeltere.length ? umschalter({ attribut: 'data-aeltere', klasse: 'kn-knopf kn-az-aeltere', mehr: 'Ältere Aufträge anzeigen', weniger: 'Weniger anzeigen' }) : null)
}
+4 -1
View File
@@ -50,7 +50,10 @@
.kn-fn-zeile-knopf:hover .kn-fn-name { color: var(--kn-accent, #111); text-decoration: underline; text-decoration-color: var(--kn-line-strong, #d8dde3); text-underline-offset: 3px; } .kn-fn-zeile-knopf:hover .kn-fn-name { color: var(--kn-accent, #111); text-decoration: underline; text-decoration-color: var(--kn-line-strong, #d8dde3); text-underline-offset: 3px; }
.kn-fn-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .kn-fn-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.kn-fn-name { font-weight: 600; color: var(--kn-text, #1a1f36); overflow-wrap: anywhere; } .kn-fn-name { font-weight: 600; color: var(--kn-text, #1a1f36); overflow-wrap: anywhere; }
.kn-fn-zeile2 { font-size: 13px; line-height: 20px; color: var(--kn-text-2, #5e6678); overflow-wrap: anywhere; } .kn-fn-zeile2 { font-size: 13px; line-height: 20px; color: var(--kn-text-2, #5e6678); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
/* rechts: die Pille (nur wenn die Funktion nicht läuft) und der Pfeil, der die Zeile als antippbar zeigt */
.kn-fn-rechts { display: flex; align-items: center; flex: none; gap: 12px; }
.kn-fn-pfeil { font-size: 20px; line-height: 20px; color: var(--kn-text-2, #5e6678); }
/* ------------------------------------------------------------------------------------------ Entdecken --- */ /* ------------------------------------------------------------------------------------------ Entdecken --- */
.kn-fn-eintrag { display: flex; flex-direction: column; gap: 16px; padding: 24px 0; border-top: 1px solid var(--kn-line, #ebeef1); } .kn-fn-eintrag { display: flex; flex-direction: column; gap: 16px; padding: 24px 0; border-top: 1px solid var(--kn-line, #ebeef1); }
+19
View File
@@ -173,6 +173,25 @@
} }
.kn-tastatur .kn-leiste { transform: translateY(110%); visibility: hidden; transition: transform 400ms var(--kn-bremse), visibility 0s 400ms; } .kn-tastatur .kn-leiste { transform: translateY(110%); visibility: hidden; transition: transform 400ms var(--kn-bremse), visibility 0s 400ms; }
/* ---------- Chat: Vollbild-Ansicht (E-Fix Chat) ----------
Solange der Bereich Chat zu sehen ist, füllt er die Höhe: Kopf und Eingabe stehen fest, nur der Verlauf scrollt in sich (Aussehen: ansichten.css).
Der Chat hat keine Etage, die Hauptseite ist hier nie inert. Desktop: Spalte in Fensterhöhe, die Eingabe am unteren Rand der zentrierten Spalte
(höchstens 760 px). Handy: der Inhalt steht fest zwischen Kopf und Leiste (wie das Blatt), „Chat“ steht klein im Kopf statt als große Überschrift. */
.kn-spalte:has(#bereich-chat:not([hidden])) { display: flex; flex-direction: column; height: calc(100vh - var(--kn-banner-h)); height: calc(100dvh - var(--kn-banner-h)); min-height: 0; }
.kn-spalte:has(#bereich-chat:not([hidden])) > :not(.kn-haupt) { flex: none; }
.kn-haupt:has(#bereich-chat:not([hidden])) { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; max-width: none; padding-bottom: 24px; }
.kn-haupt > #bereich-chat:not([hidden]) { display: flex; flex: 1 1 auto; flex-direction: column; width: 100%; min-height: 0; max-width: 760px; margin: 0 auto; }
#bereich-chat > .kn-titel { flex: none; margin: 0 0 16px; }
#chat-body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; }
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
.kn-spalte:has(#bereich-chat:not([hidden])) { display: block; height: auto; }
.kn-haupt:has(#bereich-chat:not([hidden])) { position: fixed; inset: calc(var(--kn-banner-h) + var(--kn-kopf-h) + env(safe-area-inset-top)) 0 var(--kn-unten, var(--kn-zeile-h)); padding: 0 max(16px, env(safe-area-inset-right)) 6px max(16px, env(safe-area-inset-left)); background: var(--kn-bg); }
#bereich-chat > .kn-titel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } /* bleibt für Screenreader die Überschrift der Seite */
.kn:has(#bereich-chat:not([hidden])) .kn-kopf { height: calc(var(--kn-kopf-h) + env(safe-area-inset-top)); border-bottom-color: var(--kn-line); } /* feste Höhe wie der Rand oben des Inhalts: im Querformat wäre der Kopf mit Haarlinie 1 px höher */
.kn:has(#bereich-chat:not([hidden])) .kn-kopf-titel { opacity: 1; transform: none; }
}
/* ---------- Querformat (Handy gedreht): niedrigere Zeile, keine große Überschrift ---------- */ /* ---------- Querformat (Handy gedreht): niedrigere Zeile, keine große Überschrift ---------- */
@media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) { @media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) {
+1 -20
View File
@@ -1,4 +1,4 @@
/* Kundenansicht neu (P2): Bausteine der Projektdetails und der Aktivität (Auftrag, Änderungen, Arbeiten, Dateien). /* Kundenansicht neu (P2): Bausteine der Projektdetails (Auftrag, Änderungen, Arbeiten, Dateien); die Aktivität der Website-Seite steht in auftrags-zeitleiste.js.
Alles über h() und Textknoten, nie als HTML. Kein DOM-Zugriff beim Import. */ Alles über h() und Textknoten, nie als HTML. Kein DOM-Zugriff beim Import. */
import { api } from './api.js' import { api } from './api.js'
@@ -8,7 +8,6 @@ import { K, zuAnsicht, fmtBytes, fmtMinuten, fmtUhrzeit, berlinTag } from './ans
export const AUFTRAG_SCHRITTE = ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt'] export const AUFTRAG_SCHRITTE = ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt']
const ANZAHL_AENDERUNGEN = 5 const ANZAHL_AENDERUNGEN = 5
const ANZAHL_EINTRAEGE = 4 const ANZAHL_EINTRAEGE = 4
const ANZAHL_AKTIVITAET = 3
const TON = { new: 'grau', active: 'blau', waiting: 'orange', review: 'violett', done: 'gruen', paused: 'grau', stopped: 'rot' } const TON = { new: 'grau', active: 'blau', waiting: 'orange', review: 'violett', done: 'gruen', paused: 'grau', stopped: 'rot' }
/** Farbton der Status-Pille zum Ton des Servers. */ /** Farbton der Status-Pille zum Ton des Servers. */
@@ -136,24 +135,6 @@ export function baueDateien(files) {
h('tbody', null, zeilen)))) h('tbody', null, zeilen))))
} }
/** Aktivität: je Auftrag Kopf und Arbeiten, die drei neuesten sichtbar, „Alle anzeigen (n)“ zeigt den Rest. */
export function baueAktivitaet(tickets, jetzt = new Date()) {
const liste = Array.isArray(tickets) ? tickets : []
if (!liste.length) return leerText('Es wurden noch keine Arbeiten dokumentiert.')
const sortiert = [...liste].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')))
const behaelter = h('div', { class: 'kn-aktivitaet' })
const zeichne = (alle) => {
const knopf = sortiert.length > ANZAHL_AKTIVITAET
? h('button', { type: 'button', class: K.knopf, on: { click: () => zeichne(!alle) } }, alle ? 'Weniger anzeigen' : `Alle anzeigen (${sortiert.length})`)
: null
behaelter.replaceChildren(
...sortiert.slice(0, alle ? sortiert.length : ANZAHL_AKTIVITAET).map((t) => h('article', { class: 'kn-auftrag-karte' }, baueAuftrag(t), baueArbeiten(t.entries, 'Noch keine Arbeiten dokumentiert.'))),
...(knopf ? [knopf] : []))
}
zeichne(false)
return behaelter
}
/** Die drei Quellen der Projektdetails; jede einzeln, ein Fehler ergibt null. */ /** Die drei Quellen der Projektdetails; jede einzeln, ein Fehler ergibt null. */
export async function ladeProjektDetails(projectId, apiFn = api) { export async function ladeProjektDetails(projectId, apiFn = api) {
const id = encodeURIComponent(projectId) const id = encodeURIComponent(projectId)
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// Lädt die Datenschutzerklärung (PDF und HTML) nach der Freigabe als neue Version in WOMS „Rechtliches“ hoch (Karte datenschutz, Bucket
// legal-files, Collection legalVersions): neue Fassung "current", die bisherige gültige wird "superseded" mit validTo. Nur mit
// --freigabe=JJJJ-MM-TT; dieser Tag muss der „Stand TT.MM.JJJJ“ in public/datenschutz.html entsprechen. Schon hochgeladene Stände (gleiche source,
// also gleicher Commit) werden übersprungen. Den Ablauf teilt sich das Skript mit rechtliches-hochladen.mjs (Funktion hochladen).
// Zwei Sperren gegen eine unfertige Fassung (vor dem Schreiben): (1) steht im Text noch eine „[OFFEN: …]“-Markierung (offene Tatsache im
// Faktenblatt), (2) passt die Prüfsumme neben dem PDF (docs/WEBklar-Datenschutzerklaerung.pdf.sha256) nicht zur HTML, ist das PDF also nicht aus
// dieser HTML gedruckt (scripts/build-legal-pdfs.sh datenschutz), dann Abbruch.
//
// node scripts/datenschutz-hochladen.mjs --probe nur lesen: Schlüssel, Bucket, Karte datenschutz und ihre Versionen
// node scripts/datenschutz-hochladen.mjs --freigabe=JJJJ-MM-TT lädt die Datenschutzerklärung hoch (validFrom = Freigabedatum); ohne Karte,
// ohne beide Dateien, mit nicht committetem Stand oder mit abweichendem
// „Stand“ Abbruch vor dem Schreiben
//
// Schlüssel: kurzlebiger Appwrite-Schlüssel mit files.read/files.write/documents.read/documents.write in /root/.appwrite-rechtliches.key (chmod 600).
// Der Import dieser Datei liest weder den Schlüssel noch nutzt er das Netz; beides geschieht nur in main (deps.schluessel, deps.fetch).
// Dieses Skript wird nur geschrieben und mit Nachbau-deps getestet, nie von einem Agenten ausgeführt (auch nicht mit --probe).
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { BUCKET, KEY_DATEI, alleDokumente, client, ddmmjjjj, hochladen, istKalendertag, versionen } from './rechtliches-hochladen.mjs'
const DOKUMENT_KEY = 'datenschutz'
/** [PDF, HTML]: in dieser Reihenfolge hochgeladen. */
const DATEIEN = Object.freeze(['docs/WEBklar-Datenschutzerklaerung.pdf', 'public/datenschutz.html'])
const QUELLE = 'public/datenschutz.html'
/** Neben dem PDF: SHA-256 der HTML, aus der es gedruckt wurde (scripts/build-legal-pdfs.sh datenschutz). */
const PRUEFSUMME = 'docs/WEBklar-Datenschutzerklaerung.pdf.sha256'
/** Für Kunden sichtbar (WOMS „Rechtliches“): darum weder „Workers“ noch „Hustler“. */
const AENDERUNG = 'Abschnitt 11 Interessenten (Antworten auf öffentliche Anfragen)'
const USAGE = 'Aufruf: node scripts/datenschutz-hochladen.mjs --probe | --freigabe=JJJJ-MM-TT'
/**
* Kommandozeile prüfen: --probe allein oder genau eine --freigabe=JJJJ-MM-TT (ein echter Kalendertag).
* @returns {{ modus: 'probe'|'hochladen', freigabe: string|null }}
*/
export function parseArgs(argv) {
const fremde = argv.filter((a) => a !== '--probe' && !a.startsWith('--freigabe='))
if (fremde.length > 0) throw new Error(`${USAGE}\nUnbekanntes Argument: ${fremde[0].slice(0, 40)}`)
const probe = argv.filter((a) => a === '--probe')
const freigaben = argv.filter((a) => a.startsWith('--freigabe='))
if (probe.length > 0) {
if (probe.length > 1 || freigaben.length > 0) throw new Error(`${USAGE}\n--probe steht allein.`)
return { modus: 'probe', freigabe: null }
}
if (freigaben.length !== 1) throw new Error(`${USAGE}\nFreigabe fehlt: genau eine --freigabe=JJJJ-MM-TT ist beim Hochladen Pflicht.`)
const freigabe = freigaben[0].slice('--freigabe='.length)
if (!istKalendertag(freigabe)) throw new Error(`Freigabe ungültig (erwartet JJJJ-MM-TT, ein echter Kalendertag): ${freigabe.slice(0, 20)}`)
return { modus: 'hochladen', freigabe }
}
/** Der Tag hinter „Stand“ in der Kopfzeile der HTML (TT.MM.JJJJ), sonst null. */
const standIn = (html) => /<p class="meta">\s*Stand (\d{2}\.\d{2}\.\d{4})\s*<\/p>/.exec(html)?.[1] ?? null
/** Einstieg: deps = { fetch, schluessel, log, git, dateien } wie bei rechtliches-hochladen.mjs. @returns {Promise<number>} Exit-Code (0 oder 1) */
export async function main(argv, deps = {}) {
const log = deps.log ?? console.log
let args
try {
args = parseArgs(argv)
} catch (err) {
log(err.message)
return 1
}
try {
const fetchFn = deps.fetch ?? globalThis.fetch
const api = client(fetchFn, (deps.schluessel ?? (() => readFileSync(KEY_DATEI, 'utf8').trim()))())
if (args.modus === 'probe') return await probe(api, log)
const { freigabe } = args
return await hochladen(api, log, {
documentKey: DOKUMENT_KEY,
dateien: DATEIEN,
quelle: QUELLE,
changeNote: `Fassung vom ${ddmmjjjj(freigabe)}: ${AENDERUNG}`,
freigabe,
kartenHinweis: '(die Karte gehört zu WOMS „Rechtliches“; dieses Skript legt keine Karten an)',
pruefeInhalt: (lies) => {
const html = lies(QUELLE)
const stand = standIn(html.toString('utf8'))
if (!stand) return `Abbruch: „Stand TT.MM.JJJJ“ fehlt in ${QUELLE}`
if (stand !== ddmmjjjj(freigabe)) return `Abbruch: Stand ${stand} in ${QUELLE} passt nicht zur Freigabe ${ddmmjjjj(freigabe)}`
// offene Tatsachen sind kein veröffentlichungsfähiger Text: die Markierung zuerst durch die geklärte Angabe ersetzen
const offen = [...new Set(html.toString('utf8').replace(/<!--[\s\S]*?-->/g, '').match(/\[OFFEN[^\]]*\]/g) ?? [])]
if (offen.length > 0) return `Abbruch: offene Markierung im Text von ${QUELLE}: ${offen.join(', ')} (erst klären und ersetzen, dann Stand/Pin setzen, PDF drucken, pushen)`
// das PDF muss aus genau dieser HTML gedruckt sein
let summe = ''
try {
summe = lies(PRUEFSUMME).toString('utf8').trim()
} catch {
return `Abbruch: ${PRUEFSUMME} fehlt oder ist nicht lesbar (PDF mit scripts/build-legal-pdfs.sh datenschutz drucken)`
}
if (summe !== createHash('sha256').update(html).digest('hex')) return `Abbruch: das PDF ist nicht aus dieser HTML gedruckt (Prüfsumme in ${PRUEFSUMME} passt nicht zu ${QUELLE}); scripts/build-legal-pdfs.sh datenschutz`
return null
},
}, deps)
} catch (err) {
log(`Fehler: ${err.message}`)
return 1
}
}
/** --probe: nur lesen. Eine fehlende Karte ist ein Stopp. */
async function probe(api, log) {
await api('GET', `/storage/buckets/${BUCKET}/files`)
const karten = await alleDokumente(api, 'legalDocuments')
log(`${DOKUMENT_KEY}: ${(await versionen(api, DOKUMENT_KEY)).map((v) => `${v.version}:${v.status}`).join(' ') || '—'}`)
if (!karten.some((k) => k.key === DOKUMENT_KEY)) {
log(`STOPP: Karte fehlt: ${DOKUMENT_KEY}`)
return 1
}
log(`Schlüssel ok (files.read + documents.read), Karte ${DOKUMENT_KEY} vorhanden.`)
return 0
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main(process.argv.slice(2)).then((code) => {
process.exitCode = code
})
}
+39 -18
View File
@@ -23,8 +23,8 @@ import { FUNKTIONEN_UNTERLAGEN_V6 } from '../server/services/products.js'
const ENDPOINT = 'https://ticket.webklar.com/v1' const ENDPOINT = 'https://ticket.webklar.com/v1'
const PROJECT = '6a1058610003c5a13a05' const PROJECT = '6a1058610003c5a13a05'
const DB = 'woms-database' const DB = 'woms-database'
const BUCKET = 'legal-files' export const BUCKET = 'legal-files'
const KEY_DATEI = '/root/.appwrite-rechtliches.key' export const KEY_DATEI = '/root/.appwrite-rechtliches.key'
const ROOT = fileURLToPath(new URL('..', import.meta.url)) const ROOT = fileURLToPath(new URL('..', import.meta.url))
const LIVE = 'https://project.webklar.com/' const LIVE = 'https://project.webklar.com/'
@@ -81,7 +81,7 @@ export function dokumentFuer(documentKey) {
const MODI = { '--probe': 'probe', '--karten-anlegen': 'karten', '--pruefen': 'pruefen' } const MODI = { '--probe': 'probe', '--karten-anlegen': 'karten', '--pruefen': 'pruefen' }
const istKalendertag = (iso) => { export const istKalendertag = (iso) => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
const d = new Date(`${iso}T12:00:00Z`) const d = new Date(`${iso}T12:00:00Z`)
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso
@@ -165,7 +165,7 @@ const q = (o) => encodeURIComponent(JSON.stringify(o))
const neueId = () => Array.from(crypto.getRandomValues(new Uint8Array(10)), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 20) const neueId = () => Array.from(crypto.getRandomValues(new Uint8Array(10)), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 20)
/** Kleiner Appwrite-Client über deps.fetch; Fehler werden zu Error mit Methode, Pfad und Status. */ /** Kleiner Appwrite-Client über deps.fetch; Fehler werden zu Error mit Methode, Pfad und Status. */
function client(fetchFn, schluessel) { export function client(fetchFn, schluessel) {
const kopf = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': schluessel } const kopf = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': schluessel }
return async function api(method, pfad, body) { return async function api(method, pfad, body) {
const formular = typeof FormData !== 'undefined' && body instanceof FormData const formular = typeof FormData !== 'undefined' && body instanceof FormData
@@ -180,7 +180,7 @@ function client(fetchFn, schluessel) {
} }
} }
async function alleDokumente(api, sammlung) { export async function alleDokumente(api, sammlung) {
const aus = [] const aus = []
for (let offset = 0; ; offset += 100) { for (let offset = 0; ; offset += 100) {
const r = await api('GET', `/databases/${DB}/collections/${sammlung}/documents?queries[]=${q({ method: 'limit', values: [100] })}&queries[]=${q({ method: 'offset', values: [offset] })}`) const r = await api('GET', `/databases/${DB}/collections/${sammlung}/documents?queries[]=${q({ method: 'limit', values: [100] })}&queries[]=${q({ method: 'offset', values: [offset] })}`)
@@ -189,12 +189,12 @@ async function alleDokumente(api, sammlung) {
} }
} }
async function versionen(api, documentKey) { export async function versionen(api, documentKey) {
const r = await api('GET', `/databases/${DB}/collections/legalVersions/documents?queries[]=${q({ method: 'equal', attribute: 'documentKey', values: [documentKey] })}&queries[]=${q({ method: 'limit', values: [100] })}`) const r = await api('GET', `/databases/${DB}/collections/legalVersions/documents?queries[]=${q({ method: 'equal', attribute: 'documentKey', values: [documentKey] })}&queries[]=${q({ method: 'limit', values: [100] })}`)
return r.documents return r.documents
} }
const ddmmjjjj = (iso) => iso.split('-').reverse().join('.') export const ddmmjjjj = (iso) => iso.split('-').reverse().join('.')
// ---------------------------------------------------------------------------------------------------------- Aufruf // ---------------------------------------------------------------------------------------------------------- Aufruf
@@ -218,7 +218,14 @@ export async function main(argv, deps = {}) {
if (args.modus === 'probe') return await probe(api, log) if (args.modus === 'probe') return await probe(api, log)
if (args.modus === 'karten') return await kartenAnlegen(api, log) if (args.modus === 'karten') return await kartenAnlegen(api, log)
if (args.modus === 'pruefen') return await pruefen(api, log) if (args.modus === 'pruefen') return await pruefen(api, log)
return await hochladen(api, log, args, deps) const d = dokumentFuer(args.documentKey)
return await hochladen(api, log, {
documentKey: args.documentKey,
dateien: d.dateien,
quelle: d.quelle,
changeNote: `Fassung vom ${ddmmjjjj(args.freigabe)}: Kundenansicht neu (Fassung 6)`,
freigabe: args.freigabe,
}, deps)
} catch (err) { } catch (err) {
log(`Fehler: ${err.message}`) log(`Fehler: ${err.message}`)
return 1 return 1
@@ -263,28 +270,42 @@ async function pruefen(api, log) {
return fehlt.length > 0 ? 1 : 0 return fehlt.length > 0 ? 1 : 0
} }
/** <documentKey> --freigabe=…: genau ein Dokument hochladen. Als Erstes die Kartenprüfung, dann Git- und Dateiprüfung, erst dann schreiben. */ /**
async function hochladen(api, log, { documentKey, freigabe }, deps) { * Genau ein Dokument als neue Version hochladen (auch vom Datenschutz-Skript genutzt). Als Erstes die Kartenprüfung, dann Datei- und Git-Prüfung
const d = dokumentFuer(documentKey) * und die Inhaltsprüfung des Aufrufers, erst dann schreiben: erst alle Dateien in den Bucket, dann das Versionsdokument, zuletzt die bisherige
* gültige Version ablösen. deps = { git, dateien } wie bei main.
* @param {object} api Client aus client()
* @param {Function} log
* @param {object} dok documentKey; dateien = [PDF, HTML] (Pfade im Projekt, in dieser Reihenfolge hochgeladen); quelle = Pfad in source
* (kundenbereich@<Commit>:<quelle>); changeNote und freigabe (JJJJ-MM-TT = validFrom) fertig vom Aufrufer; kartenHinweis hängt an der Meldung
* „Karte fehlt“; pruefeInhalt(lies) darf nach der Git-Prüfung mit lies(pfad) → Buffer den Inhalt prüfen und gibt eine Abbruchmeldung oder null zurück
* @returns {Promise<number>} Exit-Code (0 oder 1)
*/
export async function hochladen(api, log, { documentKey, dateien, quelle, changeNote, freigabe, kartenHinweis = '(zuerst --karten-anlegen)', pruefeInhalt }, deps) {
const karten = await alleDokumente(api, 'legalDocuments') const karten = await alleDokumente(api, 'legalDocuments')
if (!karten.some((k) => k.key === documentKey)) { if (!karten.some((k) => k.key === documentKey)) {
log(`Karte fehlt: ${documentKey} (zuerst --karten-anlegen)`) log(`Karte fehlt: ${documentKey} ${kartenHinweis}`)
return 1 return 1
} }
const git = deps.git ?? ((args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' })) const git = deps.git ?? ((args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }))
const dateisystem = deps.dateien ?? { existsSync: (rel) => existsSync(path.join(ROOT, rel)), readFileSync: (rel) => readFileSync(path.join(ROOT, rel)) } const dateisystem = deps.dateien ?? { existsSync: (rel) => existsSync(path.join(ROOT, rel)), readFileSync: (rel) => readFileSync(path.join(ROOT, rel)) }
for (const rel of d.dateien) { for (const rel of dateien) {
if (!dateisystem.existsSync(rel)) { if (!dateisystem.existsSync(rel)) {
log(`Datei fehlt: ${rel}`) log(`Datei fehlt: ${rel}`)
return 1 return 1
} }
} }
if (git(['status', '--porcelain', '--', ...d.dateien]).trim() !== '') { if (git(['status', '--porcelain', '--', ...dateien]).trim() !== '') {
log(`Abbruch: nicht committeter Stand in ${d.dateien.join(' und ')} (git status --porcelain ist nicht leer)`) log(`Abbruch: nicht committeter Stand in ${dateien.join(' und ')} (git status --porcelain ist nicht leer)`)
return 1
}
const inhaltsfehler = pruefeInhalt?.((rel) => dateisystem.readFileSync(rel))
if (inhaltsfehler) {
log(inhaltsfehler)
return 1 return 1
} }
const commit = git(['rev-parse', '--short', 'HEAD']).trim() const commit = git(['rev-parse', '--short', 'HEAD']).trim()
const source = `kundenbereich@${commit}:${d.quelle}` const source = `kundenbereich@${commit}:${quelle}`
const alle = await versionen(api, documentKey) const alle = await versionen(api, documentKey)
const plan = planVersion({ alle, source, validFrom: freigabe }) const plan = planVersion({ alle, source, validFrom: freigabe })
if (plan.schon) { if (plan.schon) {
@@ -292,7 +313,7 @@ async function hochladen(api, log, { documentKey, freigabe }, deps) {
return 0 return 0
} }
const fileIds = [] const fileIds = []
for (const rel of d.dateien) { for (const rel of dateien) {
const form = new FormData() const form = new FormData()
form.append('fileId', neueId()) form.append('fileId', neueId())
form.append('file', new Blob([dateisystem.readFileSync(rel)], { type: rel.endsWith('.pdf') ? 'application/pdf' : 'text/html' }), path.basename(rel)) form.append('file', new Blob([dateisystem.readFileSync(rel)], { type: rel.endsWith('.pdf') ? 'application/pdf' : 'text/html' }), path.basename(rel))
@@ -306,7 +327,7 @@ async function hochladen(api, log, { documentKey, freigabe }, deps) {
validFrom: freigabe, validFrom: freigabe,
validTo: null, validTo: null,
status: 'current', status: 'current',
changeNote: `Fassung vom ${ddmmjjjj(freigabe)}: Kundenansicht neu (Fassung 6)`, changeNote,
fileIds, fileIds,
source, source,
}, },
+10
View File
@@ -1058,6 +1058,16 @@ export const config = {
adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '', adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '',
heatSecret: process.env.ANALYTICS_HEAT_SECRET || '', heatSecret: process.env.ANALYTICS_HEAT_SECRET || '',
avvVersion: '2026-10-02', avvVersion: '2026-10-02',
// WEBklars eigene Website (kein Kundenprojekt, kein Abo): fester Eintrag im Messdienst, Auswertung im WOMS
// (server/services/analyseEigen.js). Der Schluessel ist nicht geheim, er steht im Skript-Tag von webklar.com.
// aktiv: false schaltet die Messung ab (Messdienst liefert dann das leere Skript und verwirft Ereignisse).
eigeneWebsite: {
aktiv: true,
siteKey: 'yiy992rvq7vchfd7bqtqivah',
projectId: 'webklar-eigene-website',
domains: ['webklar.com', 'www.webklar.com'],
host: 'webklar.com',
},
}, },
// Terminplaner (Funktionsschluessel 'termine'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur // Terminplaner (Funktionsschluessel 'termine'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur
+7
View File
@@ -33,6 +33,7 @@ import { purgeViewLog } from './services/customerViewLog.js'
import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js' import { runActivation } from './services/siteFeatures.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js' import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
import { ensureEigeneWebsite } from './services/analyseEigen.js'
import { aboFunktionenTagesJob } from './services/aboFunktionenAbgleich.js' import { aboFunktionenTagesJob } from './services/aboFunktionenAbgleich.js'
import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js' import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js'
import { minutenJob as unterkunftMinutenJob, viertelstundenJob as unterkunftViertelstundenJob, taeglicherJob as unterkunftTaeglicherJob } from './services/unterkunft/jobs.js' import { minutenJob as unterkunftMinutenJob, viertelstundenJob as unterkunftViertelstundenJob, taeglicherJob as unterkunftTaeglicherJob } from './services/unterkunft/jobs.js'
@@ -54,6 +55,7 @@ import publicShopRoutes from './routes/publicShop.js'
import publicUnterkunftRoutes from './routes/publicUnterkunft.js' import publicUnterkunftRoutes from './routes/publicUnterkunft.js'
import publicZeitenRoutes from './routes/publicZeiten.js' import publicZeitenRoutes from './routes/publicZeiten.js'
import adminAnalyseRoutes from './routes/admin/analyse.js' import adminAnalyseRoutes from './routes/admin/analyse.js'
import adminAnalyseEigenRoutes from './routes/admin/analyseEigen.js'
import { legalRouter } from './services/legalDocs.js' import { legalRouter } from './services/legalDocs.js'
import adminFinanzenRoutes from './routes/admin/finanzen.js' import adminFinanzenRoutes from './routes/admin/finanzen.js'
import adminProjektkontenRoutes from './routes/admin/projektkonten.js' import adminProjektkontenRoutes from './routes/admin/projektkonten.js'
@@ -121,6 +123,8 @@ app.use('/api/admin/customers', adminCustomersRoutes)
app.use('/api/admin/employees', adminEmployeesRoutes) app.use('/api/admin/employees', adminEmployeesRoutes)
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes) app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
app.use('/api/admin/analyse', adminAnalyseRoutes) app.use('/api/admin/analyse', adminAnalyseRoutes)
// Website-Analyse der eigenen Website webklar.com (WOMS-Seite Website-Statistik)
app.use('/api/admin/analyse-eigen', adminAnalyseEigenRoutes)
// Projektkonten und Auftraege (Admin): vor dem Finanzen-Router, dessen requireAppwriteStaff sonst jede Anfrage darunter zuerst prueft // Projektkonten und Auftraege (Admin): vor dem Finanzen-Router, dessen requireAppwriteStaff sonst jede Anfrage darunter zuerst prueft
app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes) app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)
app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes) app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)
@@ -241,6 +245,9 @@ const server = app.listen(config.port, '0.0.0.0', () => {
.then(() => runAnalyseReconcile({ now: new Date() })) .then(() => runAnalyseReconcile({ now: new Date() }))
.then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`)) .then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`))
.catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message)) .catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message))
// eigene Website webklar.com: Eintrag im Messdienst nachziehen (ohne Kunde und Abo, config.analyse.eigeneWebsite)
.then(() => ensureEigeneWebsite())
.catch((err) => console.warn('[analyse] Eintrag für webklar.com fehlgeschlagen:', err.message))
setTimeout(analyseAboCheck, 2 * 60 * 1000).unref() setTimeout(analyseAboCheck, 2 * 60 * 1000).unref()
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref() setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
+34
View File
@@ -0,0 +1,34 @@
// Admin-API (WOMS): Auswertung der Website-Analyse fuer WEBklars eigene Website (webklar.com). Jeder Mitarbeiter darf lesen.
import { Router } from 'express'
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
import { eigeneAuswertung, eigenerHeatmapLink, gueltigerPfad } from '../../services/analyseEigen.js'
const router = Router()
router.use(requireAppwriteTeam)
router.get('/', async (req, res) => {
const range = String(req.query.range || '30')
if (!['7', '30', '90'].includes(range)) return res.status(400).json({ error: 'Ungültiger Zeitraum (7, 30 oder 90 Tage)' })
try {
res.json(await eigeneAuswertung(Number(range)))
} catch (err) {
// 404: Messdienst kennt die Website noch nicht (Eintrag entsteht beim Start bzw. im Tagesjob)
if (err.status === 404) return res.status(409).json({ error: 'Die Messung für webklar.com ist noch nicht eingerichtet.' })
console.error('[admin-analyse-eigen] Auswertung:', err.status || '', err.message)
res.status(503).json({ error: 'Auswertung gerade nicht erreichbar' })
}
})
router.post('/heatmap-link', (req, res) => {
const { path, vp } = req.body || {}
if (!gueltigerPfad(path)) return res.status(400).json({ error: 'Ungültiger Pfad' })
if (!['m', 't', 'd'].includes(vp)) return res.status(400).json({ error: 'Ungültige Ansicht' })
try {
res.json({ url: eigenerHeatmapLink({ path, vp }) })
} catch (err) {
console.error('[admin-analyse-eigen] Heatmap-Link:', err.status || '', err.message)
res.status(503).json({ error: 'Der Heatmap-Link konnte gerade nicht erstellt werden.' })
}
})
export default router
+6 -1
View File
@@ -31,6 +31,7 @@ import { anfrageStatusJeProjekt, katalogFuerKunde } from '../services/aboKatalog
import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js' import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js'
import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.js' import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.js'
import { neueAnsicht } from '../services/kundenansicht/schalter.js' import { neueAnsicht } from '../services/kundenansicht/schalter.js'
import { ergaenzeKurzinfos } from '../services/kundenansicht/kurzinfo.js'
import { abowegAnalyse, aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js' import { abowegAnalyse, aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js'
import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js' import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
@@ -253,14 +254,18 @@ router.get('/', requireSession, async (req, res) => {
// dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten" // dem Abo-Stand (inklusive, grund), dem Preis und den Texten des Gratisweges "Einschalten"
zeiten: await zeitenAngebot(req.session.email, customerId, abos), zeiten: await zeitenAngebot(req.session.email, customerId, abos),
} }
// Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt. // Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt,
// dazu auf Anfrage (?kurzinfo=1, nur "Meine") je Projekt und Funktion die Kurzinfo (Live-Detail der Zeile, Text oder null; jede Abfrage
// begrenzt und fehlertolerant). Etagen, Einschalt-Ablauf und Module fragen ohne den Parameter: dann fehlt das Feld und es gibt keine Abfrage dafuer.
// Ohne neue Ansicht bleibt die Antwort wie sie war. Ein Lesefehler beim Abo-Stand ist ein Fehler der Liste (nie ein erfundenes "kein Abo"). // Ohne neue Ansicht bleibt die Antwort wie sie war. Ein Lesefehler beim Abo-Stand ist ein Fehler der Liste (nie ein erfundenes "kein Abo").
if (neu) { if (neu) {
const mitKurzinfo = req.query.kurzinfo === '1' // genau "1"; alles andere (auch doppelt oder als Liste) heisst: ohne
const [kunde, abo, katalog, anfrageStatus] = await Promise.all([ const [kunde, abo, katalog, anfrageStatus] = await Promise.all([
kundenDokument(customerId), kundenDokument(customerId),
aboProdukt(customerId, { now: jetzt }), aboProdukt(customerId, { now: jetzt }),
katalogFuerKunde({ email: req.session.email }), katalogFuerKunde({ email: req.session.email }),
anfrageStatusJeProjekt({ docs, projects }), anfrageStatusJeProjekt({ docs, projects }),
mitKurzinfo ? ergaenzeKurzinfos({ projects, docs, jetzt }).catch((err) => console.error(`[funktionen] Kurzinfo nicht lesbar: ${err?.name ?? 'Fehler'}`)) : null,
]) ])
for (const p of projects) { for (const p of projects) {
p.features.shop.anfrageStatus = anfrageStatus.get(p.projectId)?.shop ?? '' p.features.shop.anfrageStatus = anfrageStatus.get(p.projectId)?.shop ?? ''
+43
View File
@@ -0,0 +1,43 @@
// Website-Analyse fuer WEBklars eigene Website (webklar.com): ohne Kunde, Abo, Funktionsdokument und Tickets.
// Der Messdienst kennt die Website ueber den festen Eintrag config.analyse.eigeneWebsite; das WOMS liest die
// Auswertung ueber /api/admin/analyse-eigen. Rechtlich ist WEBklar hier selbst Verantwortlicher (keine AVV).
import { config } from '../config.js'
import { analyticsClient } from './analyticsClient.js'
// Tests ersetzen den Client
export const eigenDeps = { client: analyticsClient }
const website = () => config.analyse.eigeneWebsite
/** Gleiche Pruefung wie beim Heatmap-Link der Kunden (routes/funktionen.js): Pfad ohne Abfrage, Anker und Steuerzeichen. */
export function gueltigerPfad(path) {
return typeof path === 'string' && path.startsWith('/') && path.length <= 300 && !/[?#\\\s\u0000-\u001f\u007f]/.test(path)
}
/**
* Eintrag im Messdienst anlegen bzw. nachziehen (idempotent), bei aktiv: false abschalten.
* Laeuft taeglich und beim Start; ein Fehler wird geworfen und vom Aufrufer geloggt.
*/
export async function ensureEigeneWebsite() {
const w = website()
if (!w.aktiv) {
await eigenDeps.client.stopSite(w.siteKey)
return { aktiv: false }
}
await eigenDeps.client.putSite(w.siteKey, { projectId: w.projectId, domains: w.domains, active: true })
return { aktiv: true }
}
/** Kennzahlen fuer 7, 30 oder 90 Tage (Form wie analytics/src/stats.js), dazu Adresse und Zustand fuer die Anzeige. */
export async function eigeneAuswertung(range) {
const w = website()
const stats = await eigenDeps.client.getStats(w.siteKey, range)
return { website: { host: w.host, aktiv: w.aktiv }, stats }
}
/** Heatmap-Link fuer eine Seite von webklar.com (neuer Tab; webklar.com laesst sich nicht in fremde Seiten einbetten). */
export function eigenerHeatmapLink({ path, vp }) {
const w = website()
const token = eigenDeps.client.heatToken({ siteKey: w.siteKey, path, vp })
return `https://${w.host}${path}?__wk_heat=${encodeURIComponent(token)}`
}
+158
View File
@@ -0,0 +1,158 @@
import { config } from '../../config.js'
import { Query, countDocuments, listDocuments } from '../appwriteAdmin.js'
import { isHanging } from '../newsPosts.js'
import { heute, plusTage, uhrzeit, zuUtc } from '../termine/zeit.js'
/**
* Kurzinfo der aktiven Funktionen fuer GET /api/funktionen?kurzinfo=1 in der neuen Ansicht (nur "Meine" fragt danach): je Projekt und Funktion `kurzinfo`, ein kurzer
* Text fuer die zweite Zeile in "Meine" ("3 Termine heute · nächster 10:30", "2 neue Bestellungen", ...) oder null. Berliner Zeit.
*
* Regeln:
* - Nur Funktionen im Zustand active bekommen einen Text (und eine Abfrage); alle anderen null. Die Analyse hat keine Kurzinfo
* (ihre Zahlen kommen aus dem Auswertungsdienst, nicht aus Appwrite: eine Netzabfrage je Seitenaufruf lohnt dafuer nicht).
* - Je Funktion hoechstens zwei schmale Abfragen (limit, select, Filter auf Index-Felder), alle Funktionen und Websites gleichzeitig.
* - Jede Funktion ist einzeln begrenzt (kurzinfoDeps.limitMs) und fehlertolerant: Fehler oder Zeitueberschreitung ergibt null, die
* Oberflaeche zeigt dann "Aktiv". Im Log stehen nur Funktion, Art und Status, nie Text oder Daten (Gaeste, Kaeufer).
* - Ist eine Seite voll (limit erreicht), ist die Zahl unbekannt: null statt einer falschen Zahl.
* - Die Texte nennen weder Namen noch Adressen noch Betraege.
*/
/** Einhaengepunkt fuer Tests: Zeitgrenze je Funktion in Millisekunden. */
export const kurzinfoDeps = { limitMs: 1500 }
const SEITE = 100
const SEITE_NEWS = 200
const MAX_ZEICHEN = 80
const mehrzahl = (n, eins, viele) => `${n} ${n === 1 ? eins : viele}`
const istText = (wert) => typeof wert === 'string' && wert.trim() !== ''
const zeitMs = (wert) => Date.parse(wert)
/** Texte der Funktionen (rein): Eingabe sind die gezaehlten Werte, Ausgabe der Satz. */
export const KURZINFO_TEXTE = {
termine: ({ anzahl, naechster }) =>
anzahl === 0 ? 'Heute keine Termine' : `${mehrzahl(anzahl, 'Termin', 'Termine')} heute${naechster ? ` · nächster ${naechster}` : ''}`,
shop: ({ anzahl }) => (anzahl === 0 ? 'Keine offenen Bestellungen' : `${mehrzahl(anzahl, 'neue Bestellung', 'neue Bestellungen')}`),
news: ({ anzahl }) => (anzahl === 0 ? 'Keine Beiträge sichtbar' : `${mehrzahl(anzahl, 'Beitrag', 'Beiträge')} sichtbar`),
unterkunft: ({ offen, anreise }) => {
if (offen > 0) return mehrzahl(offen, 'offene Anfrage', 'offene Anfragen')
return anreise ? anreise : 'Keine offenen Anfragen'
},
zeitenLeer: () => 'Noch keine Öffnungszeiten eingetragen',
}
/** 'JJJJ-MM-TT' → 'TT.MM.' */
const tagKurz = (tag) => `${tag.slice(8, 10)}.${tag.slice(5, 7)}.`
/** Terminplaner: bestaetigte und erledigte Termine von heute (Berliner Tag) ohne Sperren; der naechste beginnt nach jetzt. */
async function termine({ dok, jetzt }) {
if (!dok) return null
const tag = heute(jetzt)
const von = zuUtc(tag, '00:00')
const bis = zuUtc(plusTage(tag, 1), '00:00')
if (!von || !bis) return null
const liste = await listDocuments(config.collections.termineBookings, [
Query.equal('featureId', dok.$id),
Query.equal('status', ['bestaetigt', 'erledigt']),
Query.greaterThanEqual('startsAt', von.toISOString()),
Query.lessThan('startsAt', bis.toISOString()),
Query.limit(SEITE),
Query.select(['startsAt', 'quelle']),
])
if (liste.length >= SEITE) return null
const starts = liste.filter((b) => b.quelle !== 'sperre').map((b) => zeitMs(b.startsAt)).filter(Number.isFinite)
const spaeter = starts.filter((ms) => ms > jetzt.getTime())
const naechster = spaeter.length ? uhrzeit(new Date(Math.min(...spaeter))) : ''
return KURZINFO_TEXTE.termine({ anzahl: starts.length, naechster })
}
/** Webshop: bezahlte Bestellungen, die noch niemand bearbeitet hat (nur die Anzahl, total der Antwort). */
async function shop({ dok }) {
if (!dok) return null
const anzahl = await countDocuments(config.collections.shopOrders, [Query.equal('shopId', dok.$id), Query.equal('status', 'bezahlt')])
return Number.isInteger(anzahl) ? KURZINFO_TEXTE.shop({ anzahl }) : null
}
/** News-Pinnwand: haengende Beitraege dieser Website (Zeiten und Abnahme wie die Website sie auswertet, isHanging). */
async function news({ projekt, jetzt }) {
const liste = await listDocuments(config.collections.newsPosts, [
Query.equal('projectId', projekt.projectId),
Query.limit(SEITE_NEWS),
Query.select(['visibleFrom', 'visibleUntil', 'takenDownAt']),
])
if (liste.length >= SEITE_NEWS) return null
return KURZINFO_TEXTE.news({ anzahl: liste.filter((p) => isHanging(p, jetzt)).length })
}
/** Buchungskalender: offene (neue) Anfragen; sonst die naechste Anreise einer gebuchten oder bezahlten Anfrage. */
async function unterkunft({ dok, jetzt }) {
if (!dok) return null
const tag = heute(jetzt)
const [offen, kommende] = await Promise.all([
countDocuments(config.collections.ukAnfragen, [Query.equal('featureId', dok.$id), Query.equal('status', 'neu')]),
listDocuments(config.collections.ukAnfragen, [
Query.equal('featureId', dok.$id),
Query.equal('status', ['gebucht', 'bezahlt']),
Query.greaterThanEqual('bis', tag),
Query.limit(SEITE),
Query.select(['von', 'bis']),
]),
])
if (!Number.isInteger(offen) || kommende.length >= SEITE) return null
const anreisen = kommende.map((a) => a.von).filter((v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) && v >= tag).sort()
let anreise = ''
if (anreisen.length) {
const erste = anreisen[0]
anreise = erste === tag ? 'Anreise heute' : erste === plusTage(tag, 1) ? 'Anreise morgen' : `Nächste Anreise ${tagKurz(erste)}`
}
return KURZINFO_TEXTE.unterkunft({ offen, anreise })
}
/** Oeffnungszeiten live: der Status, den die Projektliste ohnehin berechnet hat (zeitenStatus), ohne eigene Abfrage. */
function zeiten({ projekt }) {
const text = projekt.zeitenStatus?.text
if (istText(text)) return text
return projekt.zeitenLeer === true ? KURZINFO_TEXTE.zeitenLeer() : null
}
const LESER = { termine, shop, news, unterkunft, zeiten }
/** Nur ein nicht leerer, kurzer Text zaehlt, alles andere ist null. */
const gueltig = (wert) => (istText(wert) && wert.length <= MAX_ZEICHEN ? wert : null)
function logge(key, err) {
console.error(`[funktionen] Kurzinfo (${key}) nicht lesbar: ${err ? `${err?.name ?? 'Fehler'} ${err?.status ?? ''}` : 'Zeitüberschreitung'}`.trim())
}
/** Fuehrt einen Leser mit Zeitgrenze aus: Text oder null, wirft nie. Eine zu spaete Antwort wird verworfen. */
function begrenzt(key, leser) {
return new Promise((resolve) => {
const uhr = setTimeout(() => { logge(key, null); resolve(null) }, kurzinfoDeps.limitMs)
Promise.resolve()
.then(leser)
.then(
(wert) => { clearTimeout(uhr); resolve(gueltig(wert)) },
(err) => { clearTimeout(uhr); logge(key, err); resolve(null) },
)
})
}
/**
* Setzt `kurzinfo` an jede Funktion jedes Projekts (projects[].features[key], die Form von listCustomerFeatures): einen Text fuer aktive
* Funktionen, sonst null. Wirft nie wegen einer einzelnen Funktion; alle Abfragen laufen gleichzeitig.
* @param {{ projects: Array<{ projectId: string, features: object, zeitenStatus?: object|null, zeitenLeer?: boolean }>, docs: object[], jetzt: Date }} p
* docs: portalFeatures-Dokumente des Kunden; jetzt: der eine Zeitpunkt der Antwort
*/
export async function ergaenzeKurzinfos({ projects, docs, jetzt }) {
const aufgaben = []
for (const projekt of projects) {
for (const [key, funktion] of Object.entries(projekt.features ?? {})) {
funktion.kurzinfo = null
const leser = LESER[key]
if (!leser || funktion.state !== 'active') continue
const dok = docs.find((d) => d.projectId === projekt.projectId && d.featureKey === key)
aufgaben.push(begrenzt(key, () => leser({ projekt, dok, jetzt })).then((text) => { funktion.kurzinfo = text }))
}
}
await Promise.all(aufgaben)
}
+96
View File
@@ -0,0 +1,96 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Website-Analyse der eigenen Website webklar.com: fester Messdienst-Eintrag und Auswertung fuers WOMS (/api/admin/analyse-eigen).
const aw = await startAppwriteMock()
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
const { default: routes } = await import('../server/routes/admin/analyseEigen.js')
const eigen = await import('../server/services/analyseEigen.js')
const app = await startTestApp({ '/api/admin/analyse-eigen': routes })
const vorher = { ...config.analyse.eigeneWebsite }
after(() => {
Object.assign(config.analyse.eigeneWebsite, vorher)
return Promise.all([app.close(), aw.close(), st.close()])
})
const aufrufe = []
let statsFehler = null
eigen.eigenDeps.client = {
putSite: async (siteKey, body) => aufrufe.push({ op: 'put', siteKey, ...body }),
stopSite: async (siteKey) => aufrufe.push({ op: 'stop', siteKey }),
getStats: async (siteKey, range) => {
if (statsFehler) throw statsFehler
aufrufe.push({ op: 'stats', siteKey, range })
return { range, totals: { pv: 12 } }
},
heatToken: ({ siteKey, path, vp }) => `tok:${siteKey}:${path}:${vp}`,
}
const staff = { headers: { Authorization: 'Bearer jwt-staff' } }
const W = config.analyse.eigeneWebsite
beforeEach(() => {
aufrufe.length = 0
statsFehler = null
Object.assign(config.analyse.eigeneWebsite, vorher)
})
test('Konfiguration: gueltiger Schluessel und Domains, wie sie der Messdienst annimmt', () => {
assert.match(W.siteKey, /^[a-z0-9]{16,40}$/)
assert.equal(W.aktiv, true)
assert.equal(W.host, 'webklar.com')
assert.deepEqual(W.domains, ['webklar.com', 'www.webklar.com'])
assert.ok(W.projectId.length > 0 && W.projectId.length <= 100)
})
test('ensureEigeneWebsite: legt den Eintrag an bzw. zieht ihn nach; aktiv false schaltet ab', async () => {
assert.deepEqual(await eigen.ensureEigeneWebsite(), { aktiv: true })
assert.deepEqual(aufrufe, [{ op: 'put', siteKey: W.siteKey, projectId: W.projectId, domains: W.domains, active: true }])
aufrufe.length = 0
config.analyse.eigeneWebsite.aktiv = false
assert.deepEqual(await eigen.ensureEigeneWebsite(), { aktiv: false })
assert.deepEqual(aufrufe, [{ op: 'stop', siteKey: W.siteKey }])
})
test('GET: nur fuer Mitarbeiter', async () => {
assert.equal((await rawGet('/api/admin/analyse-eigen')).status, 401)
assert.equal((await rawGet('/api/admin/analyse-eigen', { headers: { Authorization: 'Bearer jwt-kunde' } })).status, 403)
assert.equal((await rawGet('/api/admin/analyse-eigen', { headers: { Authorization: 'Bearer jwt-admin' } })).status, 200)
assert.deepEqual(aufrufe.map((a) => a.op), ['stats'])
})
test('GET: Auswertung mit Zeitraum, Adresse und Zustand', async () => {
const r = await rawGet('/api/admin/analyse-eigen?range=7', staff)
assert.equal(r.status, 200)
assert.deepEqual(r.body, { website: { host: 'webklar.com', aktiv: true }, stats: { range: 7, totals: { pv: 12 } } })
assert.deepEqual(aufrufe, [{ op: 'stats', siteKey: W.siteKey, range: 7 }])
assert.equal((await rawGet('/api/admin/analyse-eigen', staff)).body.stats.range, 30, 'Vorgabe 30 Tage')
for (const bad of ['1', '31', 'abc', '30x']) assert.equal((await rawGet(`/api/admin/analyse-eigen?range=${bad}`, staff)).status, 400)
})
test('GET: Messdienst ohne Eintrag => 409, nicht erreichbar => 503 ohne Details', async () => {
statsFehler = Object.assign(new Error('Auswertung antwortet mit Fehler 404'), { status: 404 })
const a = await rawGet('/api/admin/analyse-eigen', staff)
assert.equal(a.status, 409)
assert.match(a.body.error, /noch nicht eingerichtet/)
statsFehler = Object.assign(new Error('Auswertung nicht erreichbar'), { status: 502 })
const b = await rawGet('/api/admin/analyse-eigen', staff)
assert.deepEqual([b.status, b.body.error], [503, 'Auswertung gerade nicht erreichbar'])
})
test('POST heatmap-link: Link auf webklar.com mit Token; Pfad und Ansicht werden geprueft', async () => {
const r = await post('/api/admin/analyse-eigen/heatmap-link', { path: '/wartungs-abo', vp: 'm' }, staff)
assert.equal(r.status, 200)
assert.equal(r.body.url, `https://webklar.com/wartungs-abo?__wk_heat=${encodeURIComponent(`tok:${W.siteKey}:/wartungs-abo:m`)}`)
for (const path of ['wartungs-abo', '/a?b=1', '/a#x', '/a b', '/a\\b', `/${'x'.repeat(300)}`, 5, null]) {
assert.equal((await post('/api/admin/analyse-eigen/heatmap-link', { path, vp: 'd' }, staff)).status, 400, String(path))
}
assert.equal((await post('/api/admin/analyse-eigen/heatmap-link', { path: '/', vp: 'x' }, staff)).status, 400)
assert.equal((await post('/api/admin/analyse-eigen/heatmap-link', { path: '/', vp: 'd' })).status, 401)
})
+11
View File
@@ -76,3 +76,14 @@ test('Banner-Archiv: Text und aria-label wortgleich zu core.js, README nennt BAN
readFileSync(root('runner/src/templates/datenschutz-analyse.html')) readFileSync(root('runner/src/templates/datenschutz-analyse.html'))
) )
}) })
test('Banner-Archiv eigene Website: Sie-Fassung wortgleich zu core.js, README nennt BANNER_VERSION_EIGEN', () => {
const v = core.BANNER_VERSION_EIGEN
const md = readFileSync(root(`docs/rechtliches/archiv/banner-eigen-${v}.md`), 'utf8')
assert.ok(md.includes(core.BANNER_TEXT_EIGEN))
assert.ok(md.includes(`BANNER_VERSION_EIGEN\`: ${v}`))
const readme = readFileSync(root('docs/rechtliches/archiv/README.md'), 'utf8')
assert.ok(readme.includes('BANNER_VERSION_EIGEN') && readme.includes(`datenschutz-analyse-webklar-com-${v}.html`))
const seite = readFileSync(root(`docs/rechtliches/archiv/datenschutz-analyse-webklar-com-${v}.html`), 'utf8')
assert.ok(seite.includes(`Stand ${v.slice(8, 10)}.${v.slice(5, 7)}.${v.slice(0, 4)}`), 'Stand der Zusatzseite = Fassung')
})
+141
View File
@@ -0,0 +1,141 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
// Hochladen der Datenschutzerklärung nach WOMS "Rechtliches" (scripts/datenschutz-hochladen.mjs): die Tests rufen main nur mit Nachbau-deps
// auf, nie mit dem echten Schlüssel oder dem Netz. Das globale fetch wirft und zählt, damit ein Weg am Nachbau vorbei auffällt.
let netzzugriffe = 0
globalThis.fetch = () => {
netzzugriffe += 1
throw new Error('Netzzugriff im Test')
}
const m = await import('../scripts/datenschutz-hochladen.mjs') // der Import liest weder Schlüssel noch Netz
const frueher = ['v1', 'v2', 'v3', 'v4', 'v5'].map((v, i) => ({ $id: `s${i}`, version: v, validFrom: `2026-09-0${i + 1}`, status: 'superseded', source: `x${i}` }))
const AKTUELL = { $id: 'alt', version: 'v6', validFrom: '2026-10-05', status: 'current', source: 'kundenbereich@aaaaaaa:public/datenschutz.html' }
// stand = das Datum, das im Nachbau der HTML-Datei hinter "Stand" steht (die PDF-Datei ist beliebiger Inhalt)
// zusatz = zusätzlicher Inhalt der HTML (zum Beispiel eine offene Markierung); summe = Inhalt der .sha256 (null = passend zur HTML, 'fehlt' = nicht lesbar)
function backend({ versionen = [...frueher, AKTUELL], karten = [{ $id: 'datenschutz', key: 'datenschutz' }], gitStatus = '', stand = '20.10.2026', vorhanden = () => true, zusatz = '', summe = null } = {}) {
const aufrufe = [], log = []
const antwort = (obj) => ({ ok: true, status: 200, text: async () => JSON.stringify(obj) })
const fetch = async (url, opt = {}) => {
const methode = opt.method ?? 'GET', pfad = new URL(url).pathname.replace(/^\/v1/, '')
aufrufe.push({ methode, pfad, body: opt.body })
if (methode === 'GET' && pfad.endsWith('/collections/legalDocuments/documents')) return antwort({ total: karten.length, documents: karten })
if (methode === 'GET' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ total: versionen.length, documents: versionen })
if (methode === 'GET' && pfad.startsWith('/storage/buckets/legal-files/files')) return antwort({ total: 0, files: [] })
if (methode === 'POST' && pfad === '/storage/buckets/legal-files/files') return antwort({ $id: `f${aufrufe.length}` })
if (methode === 'POST' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ $id: 'v-neu' })
if (methode === 'PATCH' && pfad.includes('/collections/legalVersions/documents/')) return antwort({})
throw new Error(`unerwartet: ${methode} ${pfad}`)
}
const html = () => Buffer.from((stand === null ? '<p class="meta">ohne Datum</p>' : `<p class="meta">Stand ${stand}</p>`) + zusatz)
const pruefsumme = () => {
if (summe === 'fehlt') throw new Error('ENOENT')
return Buffer.from(`${summe ?? createHash('sha256').update(html()).digest('hex')}\n`)
}
return { aufrufe, log, deps: { fetch, schluessel: () => 'test-schluessel', log: (z) => log.push(z), git: (args) => (args[0] === 'status' ? gitStatus : 'abc1234\n'), dateien: { existsSync: vorhanden, readFileSync: (rel) => (rel.endsWith('.html') ? html() : rel.endsWith('.sha256') ? pruefsumme() : Buffer.from('x')) } } }
}
const schreibend = (b) => b.aufrufe.filter((a) => a.methode !== 'GET')
test('parseArgs: --probe allein oder --freigabe=Tag, sonst Aufruf-Fehler', () => {
assert.deepEqual(m.parseArgs(['--probe']), { modus: 'probe', freigabe: null })
assert.deepEqual(m.parseArgs(['--freigabe=2026-10-20']), { modus: 'hochladen', freigabe: '2026-10-20' })
for (const bad of [[], ['--freigabe=20.10.2026'], ['--freigabe=2026-13-40'], ['--probe', '--freigabe=2026-10-20'], ['datenschutz']]) assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe/)
for (const bad of [['--freigabe=2026-10-20', '--freigabe=2026-10-21'], ['--probe', '--probe'], ['--freigabe=2026-10-20', '--karten-anlegen'], ['datenschutz', '--freigabe=2026-10-20']]) assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe/, JSON.stringify(bad))
})
test('Hochladen: Dateien zuerst (PDF, dann HTML), dann Version v7, dann die bisherige ablösen', async () => {
const b = backend()
assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
assert.deepEqual(schreibend(b).map((a) => `${a.methode} ${a.pfad.split('/').slice(-1)[0]}`), ['POST files', 'POST files', 'POST documents', 'PATCH alt'])
const neu = JSON.parse(schreibend(b)[2].body).data
assert.deepEqual([neu.documentKey, neu.version, neu.validFrom, neu.status, neu.source, neu.fileIds.length], ['datenschutz', 'v7', '2026-10-20', 'current', 'kundenbereich@abc1234:public/datenschutz.html', 2])
assert.equal(neu.changeNote, 'Fassung vom 20.10.2026: Abschnitt 11 Interessenten (Antworten auf öffentliche Anfragen)')
assert.deepEqual(JSON.parse(schreibend(b)[3].body).data, { status: 'superseded', validTo: '2026-10-19' })
})
test('Hochladen: die beiden Dateien sind die PDF und die HTML der Datenschutzerklärung, mit Dateiname und Typ', async () => {
const b = backend()
assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
const dateien = schreibend(b).slice(0, 2).map((a) => a.body.get('file'))
assert.deepEqual(dateien.map((f) => [f.name, f.type]), [['WEBklar-Datenschutzerklaerung.pdf', 'application/pdf'], ['datenschutz.html', 'text/html']])
assert.equal(await dateien[1].text(), '<p class="meta">Stand 20.10.2026</p>')
})
test('Changenote nennt weder Workers noch Hustler (sie kann für Kunden sichtbar werden)', async () => {
const b = backend()
assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
assert.doesNotMatch(JSON.parse(schreibend(b)[2].body).data.changeNote, /workers|hustler/i)
})
test('schon hochgeladen, fehlende Karte, nicht eingecheckter Stand und --probe schreiben nichts', async () => {
const schon = backend({ versionen: [{ ...AKTUELL, source: 'kundenbereich@abc1234:public/datenschutz.html' }] })
assert.equal(await m.main(['--freigabe=2026-10-20'], schon.deps), 0)
const ohneKarte = backend({ karten: [] })
assert.equal(await m.main(['--freigabe=2026-10-20'], ohneKarte.deps), 1)
const schmutzig = backend({ gitStatus: ' M public/datenschutz.html\n' })
assert.equal(await m.main(['--freigabe=2026-10-20'], schmutzig.deps), 1)
const probe = backend()
assert.equal(await m.main(['--probe'], probe.deps), 0)
for (const b of [schon, ohneKarte, schmutzig, probe]) assert.equal(schreibend(b).length, 0)
})
test('Fehlende Karte und fehlende Datei brechen mit Meldung ab, bevor etwas geschrieben wird', async () => {
const ohneKarte = backend({ karten: [] })
assert.equal(await m.main(['--freigabe=2026-10-20'], ohneKarte.deps), 1)
assert.ok(ohneKarte.log.some((z) => z.includes('Karte fehlt: datenschutz')))
assert.ok(!ohneKarte.log.some((z) => z.includes('--karten-anlegen')), 'dieses Skript legt keine Karten an')
const ohnePdf = backend({ vorhanden: (rel) => !rel.endsWith('.pdf') })
assert.equal(await m.main(['--freigabe=2026-10-20'], ohnePdf.deps), 1)
assert.ok(ohnePdf.log.some((z) => z.includes('Datei fehlt: docs/WEBklar-Datenschutzerklaerung.pdf')))
assert.equal(schreibend(ohneKarte).length + schreibend(ohnePdf).length, 0)
})
test('Stand in der HTML muss der Freigabe entsprechen, sonst Abbruch vor jedem Schreiben', async () => {
const falsch = backend({ stand: '17.10.2026' })
assert.equal(await m.main(['--freigabe=2026-10-20'], falsch.deps), 1)
assert.ok(falsch.log.some((z) => z.includes('Stand 17.10.2026') && z.includes('20.10.2026')), falsch.log.join(' | '))
const ohneStand = backend({ stand: null })
assert.equal(await m.main(['--freigabe=2026-10-20'], ohneStand.deps), 1)
assert.ok(ohneStand.log.some((z) => z.includes('Stand')), ohneStand.log.join(' | '))
for (const b of [falsch, ohneStand]) assert.equal(schreibend(b).length, 0)
const richtig = backend({ stand: '21.10.2026' })
assert.equal(await m.main(['--freigabe=2026-10-21'], richtig.deps), 0, 'mit passender Freigabe geht derselbe Stand durch')
})
test('offene Markierung im Text: Abbruch vor jedem Schreiben, mit Nennung der Markierung; Kommentare zählen nicht', async () => {
const offen = backend({ zusatz: '<p>bei <span class="offen">[OFFEN: Anbieter Alarm-Postfach]</span></p>' })
assert.equal(await m.main(['--freigabe=2026-10-20'], offen.deps), 1)
assert.ok(offen.log.some((z) => z.includes('offene Markierung') && z.includes('[OFFEN: Anbieter Alarm-Postfach]')), offen.log.join(' | '))
const zweiMal = backend({ zusatz: '<p>[OFFEN: Aufbewahrung der Server-Sicherungen] und noch einmal [OFFEN: Aufbewahrung der Server-Sicherungen]</p>' })
assert.equal(await m.main(['--freigabe=2026-10-20'], zweiMal.deps), 1)
assert.equal(zweiMal.log.filter((z) => z.includes('offene Markierung'))[0].match(/Aufbewahrung der Server-Sicherungen/g).length, 1, 'jede Markierung nur einmal genannt')
for (const b of [offen, zweiMal]) assert.equal(schreibend(b).length, 0)
const kommentar = backend({ zusatz: '<!-- [OFFEN: nur eine Notiz im Quelltext] -->' })
assert.equal(await m.main(['--freigabe=2026-10-20'], kommentar.deps), 0, 'eine Markierung im HTML-Kommentar ist nicht sichtbar')
})
test('Prüfsumme neben dem PDF muss zur HTML passen, sonst Abbruch vor jedem Schreiben (veraltetes PDF)', async () => {
const veraltet = backend({ summe: 'a'.repeat(64) })
assert.equal(await m.main(['--freigabe=2026-10-20'], veraltet.deps), 1)
assert.ok(veraltet.log.some((z) => z.includes('das PDF ist nicht aus dieser HTML gedruckt') && z.includes('build-legal-pdfs.sh datenschutz')), veraltet.log.join(' | '))
const fehlt = backend({ summe: 'fehlt' })
assert.equal(await m.main(['--freigabe=2026-10-20'], fehlt.deps), 1)
assert.ok(fehlt.log.some((z) => z.includes('WEBklar-Datenschutzerklaerung.pdf.sha256') && z.includes('fehlt')), fehlt.log.join(' | '))
for (const b of [veraltet, fehlt]) assert.equal(schreibend(b).length, 0)
// passende Prüfsumme (Standard im Nachbau): der Upload geht durch
const passt = backend()
assert.equal(await m.main(['--freigabe=2026-10-20'], passt.deps), 0)
assert.equal(schreibend(passt).length, 4)
})
test('--probe liest nur: Bucket, Karte und Versionen, meldet eine fehlende Karte als Stopp', async () => {
const b = backend()
assert.equal(await m.main(['--probe'], b.deps), 0)
assert.ok(b.log.includes('datenschutz: v1:superseded v2:superseded v3:superseded v4:superseded v5:superseded v6:current'), b.log.join(' | '))
assert.deepEqual(b.aufrufe.map((a) => a.pfad.replace(/^\/databases\/woms-database/, '')), ['/storage/buckets/legal-files/files', '/collections/legalDocuments/documents', '/collections/legalVersions/documents'])
const ohneKarte = backend({ karten: [] })
assert.equal(await m.main(['--probe'], ohneKarte.deps), 1)
assert.ok(ohneKarte.log.includes('STOPP: Karte fehlt: datenschutz'))
})
test('ungültige Aufrufe enden mit Exit 1 und Hinweis, ohne ein einziges Netz-Zeichen', async () => {
const b = backend()
assert.equal(await m.main([], b.deps), 1)
assert.match(b.log[0], /Aufruf/)
assert.equal(b.aufrufe.length, 0)
})
test('kein Test hat das echte fetch erreicht', () => {
assert.equal(netzzugriffe, 0)
})
+1 -1
View File
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
const WURZEL = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const WURZEL = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const NEU = path.join(WURZEL, 'public', 'neu') const NEU = path.join(WURZEL, 'public', 'neu')
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine'] const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'auftrags-zeitleiste', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine']
// Seit P7 sprechen auch die vier Bestellseiten „du“ (Fassung 6, C14); einzige Ausnahme ist der Rechtstext TAKEOVER_CONDITIONS (hash-gepinnt) // Seit P7 sprechen auch die vier Bestellseiten „du“ (Fassung 6, C14); einzige Ausnahme ist der Rechtstext TAKEOVER_CONDITIONS (hash-gepinnt)
const WORTLAUT = [] const WORTLAUT = []
const BESTELLSEITEN = ['ansicht-bestellen', 'ansicht-wechsel', 'ansicht-speicher', 'ansicht-gutschrift', 'bestell-bausteine'] const BESTELLSEITEN = ['ansicht-bestellen', 'ansicht-wechsel', 'ansicht-speicher', 'ansicht-gutschrift', 'bestell-bausteine']
+2 -2
View File
@@ -6,7 +6,7 @@ import { K } from '../public/neu/ansicht-hilfen.js'
const css = (f) => readFileSync(new URL(`../public/neu/${f}`, import.meta.url), 'utf8') const css = (f) => readFileSync(new URL(`../public/neu/${f}`, import.meta.url), 'utf8')
const lese = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8') const lese = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8')
const A = css('ansichten.css'); const O = css('oeffentlich/oeffentlich.css'); const C = css('chat-composer.css'); const BASIS = css('basis.css'); const TOKENS = css('tokens.css') const A = css('ansichten.css'); const O = css('oeffentlich/oeffentlich.css'); const C = css('chat-composer.css'); const BASIS = css('basis.css'); const TOKENS = css('tokens.css')
const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine'] const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'auftrags-zeitleiste', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine']
const existiert = (n) => existsSync(new URL(`../public/neu/${n}.js`, import.meta.url)) const existiert = (n) => existsSync(new URL(`../public/neu/${n}.js`, import.meta.url))
const lies = (n) => css(`${n}.js`).replace(/\/\*[\s\S]*?\*\//g, '') const lies = (n) => css(`${n}.js`).replace(/\/\*[\s\S]*?\*\//g, '')
const VERBOTEN = /linear-gradient|radial-gradient|conic-gradient|backdrop-filter|prefers-color-scheme:\s*dark|font-weight:\s*800|#9d3dfe|rgba?\(\s*157\s*,\s*61/i const VERBOTEN = /linear-gradient|radial-gradient|conic-gradient|backdrop-filter|prefers-color-scheme:\s*dark|font-weight:\s*800|#9d3dfe|rgba?\(\s*157\s*,\s*61/i
@@ -19,7 +19,7 @@ test('Stil: Farbliterale nur in Custom-Property-Deklarationen, jedes benutzte To
test('Stil: jede kn-Klasse der P2-Module hat eine Regel', () => { test('Stil: jede kn-Klasse der P2-Module hat eine Regel', () => {
const klassen = new Set(); for (const n of MODULE.filter(existiert)) for (const m of lies(n).matchAll(/class:\s*'([^']+)'/g)) m[1].split(/\s+/).filter((k) => k.startsWith('kn-')).forEach((k) => klassen.add(k)) const klassen = new Set(); for (const n of MODULE.filter(existiert)) for (const m of lies(n).matchAll(/class:\s*'([^']+)'/g)) m[1].split(/\s+/).filter((k) => k.startsWith('kn-')).forEach((k) => klassen.add(k))
for (const wert of Object.values(K).flatMap((v) => (typeof v === 'string' ? [v] : Object.values(v)))) wert.split(/\s+/).filter((k) => k.startsWith('kn-')).forEach((k) => klassen.add(k)) for (const wert of Object.values(K).flatMap((v) => (typeof v === 'string' ? [v] : Object.values(v)))) wert.split(/\s+/).filter((k) => k.startsWith('kn-')).forEach((k) => klassen.add(k))
for (const k of klassen) assert.match(BASIS + A, new RegExp(`\\.${k}(?![\\w-])`), `Klasse ohne Regel: ${k}`) for (const k of klassen) assert.match(BASIS + A + css('huelle.css'), new RegExp(`\\.${k}(?![\\w-])`), `Klasse ohne Regel: ${k}`) // huelle.css trägt .kn-nur-leser (Text nur für Vorlesegeräte)
}) })
test('Stil: Aufträge-Abschnitt, Chat-Variablen, hidden und Handy-Abfrage', () => { test('Stil: Aufträge-Abschnitt, Chat-Variablen, hidden und Handy-Abfrage', () => {
const ab = new Set([...(lese('auftrag-dom.js') + lese('auftrag-portal.js')).matchAll(/\bab-[a-z0-9]+(?:-[a-z0-9]+)*/g)].map((m) => m[0])) const ab = new Set([...(lese('auftrag-dom.js') + lese('auftrag-portal.js')).matchAll(/\bab-[a-z0-9]+(?:-[a-z0-9]+)*/g)].map((m) => m[0]))
+16
View File
@@ -32,6 +32,22 @@ test('Website: Aktivität fällt aus, die Projekte bleiben', mit({ ...HAUPT, 'GE
const w = await zeige(fx) const w = await zeige(fx)
assert.match(fx.text(w), /Aktivität konnte nicht geladen werden: Server kaputt/); assert.ok(fx.finde(w, '[data-projekt="p1"]')) assert.match(fx.text(w), /Aktivität konnte nicht geladen werden: Server kaputt/); assert.ok(fx.finde(w, '[data-projekt="p1"]'))
})) }))
const ZEIT_AUFTRAEGE = [
{ number: '100207', topic: 'Öffnungszeiten aktualisieren', createdAt: '2026-10-02T10:00:00.000Z', status: { label: 'Erledigt', tone: 'done', step: 4 }, entries: [{ kind: 'online', label: 'Online erledigt', date: '2026-10-04', minutes: 50, person: 'Lena Vogel', text: 'Neue Öffnungszeiten eingetragen.' }] },
{ number: '100231', topic: 'Neue Fotos für die Startseite', createdAt: '2026-10-13T10:00:00.000Z', status: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 }, entries: [] },
]
test('Website: „Was wir zuletzt erledigt haben“ ist eine Zeitleiste, laufende Aufträge zuerst, Rückfrage mit Link in den Chat', mit({ ...HAUPT, 'GET /api/activity': { tickets: ZEIT_AUFTRAEGE } }, async (fx) => {
const w = await zeige(fx)
const abschnitt = fx.finde(w, '[data-abschnitt="aktivitaet"]')
assert.equal(fx.finde(abschnitt, 'h2').textContent, 'Was wir zuletzt erledigt haben')
assert.deepEqual(fx.alle(abschnitt, '[data-auftrag]').map((b) => b.getAttribute('data-auftrag')), ['100231', '100207'])
assert.equal(fx.finde(abschnitt, '.kn-az-info').textContent, 'Wir brauchen eine Info von dir · Im Chat antworten')
assert.equal(fx.finde(abschnitt, '.kn-az-info a').getAttribute('href'), '#chat')
assert.match(fx.finde(abschnitt, '[data-auftrag="100207"]').textContent, /04\.10\.Neue Öffnungszeiten eingetragen\./)
}))
test('Website: ohne Aufträge steht der ruhige Satz', mit(HAUPT, async (fx) => {
assert.match(fx.text(fx.finde(await zeige(fx), '[data-abschnitt="aktivitaet"]')), /Es wurden noch keine Arbeiten dokumentiert\./)
}))
test('Website: leer', mit({ ...HAUPT, 'GET /api/projects': { projects: [] } }, async (fx) => { assert.match(fx.text(await zeige(fx)), /Noch keine Website-Projekte zugewiesen\./) })) test('Website: leer', mit({ ...HAUPT, 'GET /api/projects': { projects: [] } }, async (fx) => { assert.match(fx.text(await zeige(fx)), /Noch keine Website-Projekte zugewiesen\./) }))
test('Website: offene Rechnung steht als erste Zeile', mit({ ...HAUPT, 'GET /api/invoices': { invoices: [rechnung({ number: 'RE-2026-0042', balance: 50 })] } }, async (fx) => { test('Website: offene Rechnung steht als erste Zeile', mit({ ...HAUPT, 'GET /api/invoices': { invoices: [rechnung({ number: 'RE-2026-0042', balance: 50 })] } }, async (fx) => {
const w = await zeige(fx); assert.match(fx.finde(w, '.kn-rechnung-offen').textContent, /^Rechnung offen · RE-2026-0042/) const w = await zeige(fx); assert.match(fx.finde(w, '.kn-rechnung-offen').textContent, /^Rechnung offen · RE-2026-0042/)
+2 -10
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test' import { test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { import {
AUFTRAG_SCHRITTE, statusTon, tagesUeberschrift, baueAuftrag, baueAenderungen, baueArbeiten, baueDateien, baueAktivitaet, ladeProjektDetails, baueProjektDetails, AUFTRAG_SCHRITTE, statusTon, tagesUeberschrift, baueAuftrag, baueAenderungen, baueArbeiten, baueDateien, ladeProjektDetails, baueProjektDetails,
} from '../public/neu/website-details.js' } from '../public/neu/website-details.js'
import { mit, mitDom } from './support/knDaten.js' import { mit, mitDom } from './support/knDaten.js'
@@ -45,15 +45,7 @@ test('Auftrag: Nummer, Betreff, Status, Fortschritt, Hinweis bei Rückfrage', ()
assert.match(schlicht.textContent, /Dein Auftrag/); assert.equal(fx.alle(schlicht, 'li').length, 0); assert.doesNotMatch(schlicht.textContent, /Wir brauchen/) assert.match(schlicht.textContent, /Dein Auftrag/); assert.equal(fx.alle(schlicht, 'li').length, 0); assert.doesNotMatch(schlicht.textContent, /Wir brauchen/)
})) }))
test('Aktivität: neueste zuerst, drei sichtbar, „Alle anzeigen (n)“ klappt auf; fremde Inhalte bleiben Text', () => mitDom((fx) => { // Die Aktivität der Website-Seite (Zeitleiste) prüfen knAuftragsZeitleiste.test.js und knAuftragsZeitleisteDom.test.js.
const t = (n, createdAt, topic) => ({ number: String(n), topic, createdAt, status: { label: 'Offen', tone: 'active', step: 2 }, entries: [] })
const el = baueAktivitaet([t(1, '2026-10-01T00:00:00Z', 'Eins'), t(4, '2026-10-04T00:00:00Z', '<img src=x onerror=alert(1)>'), t(2, '2026-10-02T00:00:00Z', 'Zwei'), t(3, '2026-10-03T00:00:00Z', 'Drei')], new Date('2026-10-16T09:00:00Z'))
assert.equal(fx.alle(el, 'article').length, 3); assert.match(el.textContent, /Auftrag 4/); assert.doesNotMatch(el.textContent, /Auftrag 1/)
assert.equal(fx.finde(el, 'img'), null)
const alle = fx.finde(el, (e) => e.textContent === 'Alle anzeigen (4)'); assert.ok(alle)
fx.klick(alle); assert.equal(fx.alle(el, 'article').length, 4); assert.ok(fx.finde(el, (e) => e.tagName === 'BUTTON' && e.textContent === 'Weniger anzeigen'))
assert.equal(baueAktivitaet([], new Date()).textContent, 'Es wurden noch keine Arbeiten dokumentiert.')
}))
test('Arbeiten: Unterstützung, Änderungsliste und Text; Zeitangaben', () => mitDom((fx) => { test('Arbeiten: Unterstützung, Änderungsliste und Text; Zeitangaben', () => mitDom((fx) => {
const el = baueArbeiten([{ date: '2026-09-20', kind: 'update', label: 'Aktualisierung', person: 'Kenso', minutes: 90, changes: ['Menü angepasst'], supports: [{ name: 'Justin', task: 'Bilder', minutes: 15 }] }, const el = baueArbeiten([{ date: '2026-09-20', kind: 'update', label: 'Aktualisierung', person: 'Kenso', minutes: 90, changes: ['Menü angepasst'], supports: [{ name: 'Justin', task: 'Bilder', minutes: 15 }] },
+165
View File
@@ -0,0 +1,165 @@
// „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C, Pilot-Feedback 17.10.): reine Logik (Fortschritt, Reihenfolge, Gruppierung, Kürzen).
// Antwortform von GET /api/activity laut server/routes/customerData.js und services/customerProjectView.js. Der DOM-Bau steht in knAuftragsZeitleisteDom.test.js.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
MAX_AUFTRAEGE, MAX_ZEILEN, STUFEN, fortschritt, braucheInfo, letzteAktivitaet, sortiereAuftraege, gruppiereAuftraege, kuerze,
eintragsKurztext, datumKurz, zeitleistenZeilen,
} from '../public/neu/auftrags-zeitleiste.js'
import { AUFTRAG_SCHRITTE } from '../public/neu/website-details.js'
const JETZT = new Date('2026-10-16T09:00:00Z')
const ST = {
neu: { label: 'Eingegangen', tone: 'new', step: 1 },
arbeit: { label: 'In Arbeit', tone: 'active', step: 2 },
wartet: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 },
pruefung: { label: 'Wird geprüft', tone: 'review', step: 3 },
fertig: { label: 'Erledigt', tone: 'done', step: 4 },
pause: { label: 'Pausiert', tone: 'paused', step: 0 },
storniert: { label: 'Storniert', tone: 'stopped', step: 0 },
}
const auftrag = (nr, st, createdAt, entries = [], topic = `Thema ${nr}`) => ({ number: String(nr), topic, status: ST[st], createdAt, entries })
const eintrag = (date, text, over = {}) => ({ kind: 'online', label: 'Online erledigt', date, minutes: 15, person: 'Lena Vogel', text, ...over })
const nummern = (liste) => liste.map((a) => a.number)
/* ------------------------------------------------------------------ reine Logik --- */
test('Stufen: dieselben vier Namen wie der Fortschritt der Projektdetails', () => {
assert.deepEqual(STUFEN, ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt'])
assert.deepEqual(STUFEN, AUFTRAG_SCHRITTE)
})
test('Fortschritt: erreichte Abschnitte bis zum aktuellen, Stufe 0 ist leer', () => {
const stand = (step) => fortschritt({ step }).map((s) => [s.erreicht, s.aktuell])
assert.deepEqual(stand(1), [[true, true], [false, false], [false, false], [false, false]])
assert.deepEqual(stand(2), [[true, false], [true, true], [false, false], [false, false]])
assert.deepEqual(stand(3), [[true, false], [true, false], [true, true], [false, false]])
assert.deepEqual(stand(4), [[true, false], [true, false], [true, false], [true, true]])
assert.deepEqual(fortschritt({ step: 4 }).map((s) => s.name), STUFEN)
for (const leer of [0, undefined, null, 'x', -1, 9]) assert.deepEqual(fortschritt({ step: leer }).map((s) => s.erreicht), [false, false, false, false], `step ${String(leer)}`)
assert.deepEqual(fortschritt(null).map((s) => s.erreicht), [false, false, false, false])
})
test('Info vom Kunden nötig: genau der Ton „waiting“ (Status „awaiting“ des Servers)', () => {
assert.equal(braucheInfo(auftrag(1, 'wartet', '2026-10-01T00:00:00Z')), true)
for (const st of ['neu', 'arbeit', 'pruefung', 'fertig', 'pause', 'storniert']) assert.equal(braucheInfo(auftrag(1, st, '2026-10-01T00:00:00Z')), false, st)
assert.equal(braucheInfo({}), false); assert.equal(braucheInfo(null), false)
})
test('Letzte Aktivität: der jüngste Eintrag oder die Anlage des Auftrags, je nachdem, was später ist', () => {
const a = auftrag(1, 'arbeit', '2026-10-01T10:00:00Z', [eintrag('2026-10-05', 'x'), eintrag('2026-10-09', 'y'), eintrag('', 'ohne Datum')])
assert.equal(letzteAktivitaet(a), Date.parse('2026-10-09T00:00:00Z'))
assert.equal(letzteAktivitaet(auftrag(2, 'arbeit', '2026-10-12T10:00:00Z', [eintrag('2026-10-05', 'x')])), Date.parse('2026-10-12T10:00:00Z'))
assert.equal(letzteAktivitaet(auftrag(3, 'arbeit', '', [])), 0)
assert.equal(letzteAktivitaet({ entries: 'kaputt', createdAt: 'nie' }), 0)
})
test('Reihenfolge: laufende zuerst (letzte Aktivität), dann erledigte (neueste zuerst), Storniertes ganz hinten', () => {
const liste = [
auftrag(1, 'fertig', '2026-09-01T00:00:00Z', [eintrag('2026-10-14', 'fertig, aber jüngster Eintrag')]),
auftrag(2, 'arbeit', '2026-10-01T00:00:00Z', [eintrag('2026-10-03', 'alt')]),
auftrag(3, 'storniert', '2026-10-15T00:00:00Z'),
auftrag(4, 'wartet', '2026-10-02T00:00:00Z', [eintrag('2026-10-13', 'neuer')]),
auftrag(5, 'fertig', '2026-09-20T00:00:00Z', [eintrag('2026-09-25', 'älter')]),
auftrag(6, 'pause', '2026-10-04T00:00:00Z'),
auftrag(7, 'pruefung', '2026-10-05T00:00:00Z'),
auftrag(8, 'neu', '2026-10-12T00:00:00Z'),
]
assert.deepEqual(nummern(sortiereAuftraege(liste)), ['4', '8', '7', '6', '2', '1', '5', '3'])
assert.deepEqual(nummern(liste), ['1', '2', '3', '4', '5', '6', '7', '8'], 'die Eingabe bleibt unverändert')
})
test('Reihenfolge: Aufträge mit Rückfrage stehen vor den übrigen laufenden (auch wenn sie am ältesten sind), unter sich nach Aktivität; erledigte bleiben dahinter', () => {
const liste = [
auftrag(1, 'arbeit', '2026-10-14T00:00:00Z'),
auftrag(2, 'wartet', '2026-09-01T00:00:00Z', [eintrag('2026-09-05', 'alt')]),
auftrag(3, 'neu', '2026-10-13T00:00:00Z'),
auftrag(4, 'fertig', '2026-10-15T00:00:00Z'),
auftrag(5, 'wartet', '2026-09-20T00:00:00Z', [eintrag('2026-10-02', 'etwas neuer')]),
auftrag(6, 'pause', '2026-10-12T00:00:00Z'),
auftrag(7, 'pruefung', '2026-10-11T00:00:00Z'),
auftrag(8, 'storniert', '2026-10-16T00:00:00Z'),
auftrag(9, 'arbeit', '2026-10-10T00:00:00Z'),
]
assert.deepEqual(nummern(sortiereAuftraege(liste)), ['5', '2', '1', '3', '6', '7', '9', '4', '8'])
// ohne Rückfrage ändert sich nichts an der alten Regel (Aktivität, dann Serverreihenfolge)
assert.deepEqual(nummern(sortiereAuftraege(liste.filter((a) => !braucheInfo(a)))), ['1', '3', '6', '7', '9', '4', '8'])
})
test('Gruppierung: eine Rückfrage verschwindet nie hinter „Ältere Aufträge anzeigen“, auch wenn sie die älteste Aktivität hat', () => {
const laufend = Array.from({ length: 6 }, (_, i) => auftrag(200 + i, 'arbeit', `2026-10-0${i + 3}T00:00:00Z`))
const rueckfrage = auftrag(300, 'wartet', '2026-08-01T00:00:00Z', [eintrag('2026-08-02', 'sehr alt')])
const zweite = auftrag(301, 'wartet', '2026-07-01T00:00:00Z')
const g = gruppiereAuftraege([...laufend, rueckfrage, zweite])
assert.deepEqual(nummern(g.sichtbar.slice(0, 2)), ['300', '301'])
assert.equal(g.sichtbar.length, 5)
assert.ok(nummern(g.sichtbar).includes('300') && nummern(g.sichtbar).includes('301'))
assert.ok(g.aeltere.every((a) => !braucheInfo(a)))
// mehr Rückfragen als Plätze: die mit der jüngsten Aktivität stehen vorn, der Rest gehört in „älter“ (kein Auftrag geht verloren)
const viele = Array.from({ length: 7 }, (_, i) => auftrag(400 + i, 'wartet', `2026-10-0${i + 1}T00:00:00Z`))
const g2 = gruppiereAuftraege(viele)
assert.deepEqual(nummern(g2.sichtbar), ['406', '405', '404', '403', '402'])
assert.deepEqual(nummern(g2.aeltere), ['401', '400'])
})
test('Reihenfolge: bei gleicher Aktivität bleibt die Reihenfolge des Servers', () => {
const liste = ['9', '8', '7'].map((n) => auftrag(n, 'arbeit', '2026-10-10T00:00:00Z'))
assert.deepEqual(nummern(sortiereAuftraege(liste)), ['9', '8', '7'])
})
test('Gruppierung: höchstens fünf Aufträge sichtbar, der Rest ist „älter“', () => {
assert.equal(MAX_AUFTRAEGE, 5)
const liste = Array.from({ length: 7 }, (_, i) => auftrag(100 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`))
const g = gruppiereAuftraege(liste)
assert.deepEqual(nummern(g.sichtbar), ['106', '105', '104', '103', '102']); assert.deepEqual(nummern(g.aeltere), ['101', '100'])
const fuenf = gruppiereAuftraege(liste.slice(0, 5)); assert.equal(fuenf.sichtbar.length, 5); assert.deepEqual(fuenf.aeltere, [])
assert.deepEqual(gruppiereAuftraege([]), { sichtbar: [], aeltere: [] }); assert.deepEqual(gruppiereAuftraege(undefined), { sichtbar: [], aeltere: [] })
assert.equal(gruppiereAuftraege(liste, 2).aeltere.length, 5)
})
test('Gruppierung: ein erledigter Auftrag rutscht hinter laufende, auch wenn er neuer ist', () => {
const liste = [auftrag(1, 'fertig', '2026-10-15T00:00:00Z'), ...Array.from({ length: 5 }, (_, i) => auftrag(10 + i, 'arbeit', `2026-09-0${i + 1}T00:00:00Z`))]
const g = gruppiereAuftraege(liste)
assert.deepEqual(nummern(g.aeltere), ['1'])
})
test('Kürzen: die ersten n sichtbar, der Rest getrennt; Eingabe bleibt', () => {
assert.equal(MAX_ZEILEN, 3)
const a = [1, 2, 3, 4, 5]
assert.deepEqual(kuerze(a, 3), { sichtbar: [1, 2, 3], rest: [4, 5] })
assert.deepEqual(kuerze([1, 2], 3), { sichtbar: [1, 2], rest: [] })
assert.deepEqual(kuerze(undefined, 3), { sichtbar: [], rest: [] })
assert.deepEqual(a, [1, 2, 3, 4, 5])
})
test('Datum: TT.MM., mit Jahr nur wenn es nicht das laufende ist', () => {
assert.equal(datumKurz('2026-10-02', JETZT), '02.10.')
assert.equal(datumKurz('2025-12-24', JETZT), '24.12.25')
assert.equal(datumKurz('2026-10-02T08:00:00.000Z', JETZT), '02.10.')
for (const kaputt of ['', null, undefined, 'gestern', '2026-13']) assert.equal(datumKurz(kaputt, JETZT), '', String(kaputt))
})
test('Kurztext: Änderungen, freier Text oder Art der Arbeit; nie lang', () => {
assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['Menü angepasst'] }), 'Menü angepasst')
assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['Menü angepasst', 'Bilder getauscht'] }), 'Menü angepasst und 1 weitere Änderung')
assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['A', 'B', 'C', 'D'] }), 'A und 3 weitere Änderungen')
assert.equal(eintragsKurztext({ kind: 'online', label: 'Online erledigt', text: 'Neue Öffnungszeiten eingetragen.' }), 'Neue Öffnungszeiten eingetragen.')
assert.equal(eintragsKurztext({ kind: 'onsite', label: 'Vor Ort bei dir', text: '' }), 'Vor Ort bei dir')
assert.equal(eintragsKurztext({ kind: 'work', label: '', text: '' }), 'Arbeit am Projekt')
assert.equal(eintragsKurztext({ kind: 'online', label: 'Online erledigt', text: 'Erste Zeile\n\nZweite Zeile mit Lücken' }), 'Erste Zeile')
assert.equal(eintragsKurztext({ kind: 'online', label: 'x', text: ' viel Leerraum ' }), 'viel Leerraum')
const lang = eintragsKurztext({ kind: 'online', label: 'x', text: 'Wort '.repeat(60) })
assert.ok(lang.length <= 91 && lang.endsWith('…') && !lang.includes(' '), lang)
assert.equal(eintragsKurztext(null), 'Arbeit am Projekt')
})
test('Zeitleiste: neueste zuerst, Datum TT.MM., ohne Datum ganz unten, keine Namen und Minuten', () => {
const a = auftrag(1, 'arbeit', '2026-10-01T00:00:00Z', [
eintrag('2026-10-03', 'Alt'), eintrag('2026-10-12', 'Neu', { person: 'Kenso', minutes: 90 }), eintrag('', 'Ohne Datum'), eintrag('2026-10-07', 'Mitte'),
])
const zeilen = zeitleistenZeilen(a, JETZT)
assert.deepEqual(zeilen.map((z) => [z.datum, z.text]), [['12.10.', 'Neu'], ['07.10.', 'Mitte'], ['03.10.', 'Alt'], ['', 'Ohne Datum']])
assert.deepEqual(Object.keys(zeilen[0]).sort(), ['datum', 'iso', 'text'])
assert.equal(zeilen[0].iso, '2026-10-12')
assert.deepEqual(zeitleistenZeilen({ entries: null }, JETZT), []); assert.deepEqual(zeitleistenZeilen(null, JETZT), [])
})
+232
View File
@@ -0,0 +1,232 @@
// „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C): der DOM-Bau mit dem Fake-DOM. Die reine Logik prüft knAuftragsZeitleiste.test.js.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { baueAktivitaet } from '../public/neu/auftrags-zeitleiste.js'
import { mitDom } from './support/knDaten.js'
import { customerOrder, customerWorkEntries } from '../server/services/customerProjectView.js'
const JETZT = new Date('2026-10-16T09:00:00Z')
const ST = {
neu: { label: 'Eingegangen', tone: 'new', step: 1 },
arbeit: { label: 'In Arbeit', tone: 'active', step: 2 },
wartet: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 },
pruefung: { label: 'Wird geprüft', tone: 'review', step: 3 },
fertig: { label: 'Erledigt', tone: 'done', step: 4 },
pause: { label: 'Pausiert', tone: 'paused', step: 0 },
storniert: { label: 'Storniert', tone: 'stopped', step: 0 },
}
const auftrag = (nr, st, createdAt, entries = [], topic = `Thema ${nr}`) => ({ number: String(nr), topic, status: ST[st], createdAt, entries })
const eintrag = (date, text, over = {}) => ({ kind: 'online', label: 'Online erledigt', date, minutes: 15, person: 'Lena Vogel', text, ...over })
/* ---------------------------------------------------------------------- DOM --- */
const dreiAuftraege = () => [
auftrag(100207, 'fertig', '2026-10-02T10:00:00Z', [eintrag('2026-10-04', 'Neue Öffnungszeiten eingetragen.')], 'Öffnungszeiten aktualisieren'),
auftrag(100231, 'wartet', '2026-10-13T10:00:00Z', [eintrag('2026-10-15', 'Fotos erhalten.'), eintrag('2026-10-13', 'Bildauswahl vorbereitet.')], 'Neue Fotos für die Startseite'),
auftrag(100224, 'pruefung', '2026-10-06T10:00:00Z', [
eintrag('2026-10-14', 'Speisekarte gesetzt.'), eintrag('2026-10-13', 'Layout geprüft.'), eintrag('2026-10-12', 'Entwurf erstellt.'), eintrag('2026-10-09', 'Texte gesammelt.'),
], 'Speisekarte als PDF'),
]
test('Aktivität: ein Block je Auftrag, Titel als Überschrift, Pille in Farbe des Status, Reihenfolge laufend vor erledigt', () => mitDom((fx) => {
const el = baueAktivitaet(dreiAuftraege(), JETZT)
const bloecke = fx.alle(el, '[data-auftrag]')
assert.deepEqual(bloecke.map((b) => b.getAttribute('data-auftrag')), ['100231', '100224', '100207'])
const kopf = (b) => ({ titel: fx.finde(b, 'h3').textContent, pille: fx.finde(b, '.kn-pille').textContent, klasse: fx.finde(b, '.kn-pille').className })
assert.deepEqual(kopf(bloecke[0]), { titel: 'Neue Fotos für die Startseite', pille: 'Wartet auf deine Rückmeldung', klasse: 'kn-pille kn-pille-orange' })
assert.deepEqual(kopf(bloecke[1]), { titel: 'Speisekarte als PDF', pille: 'Wird geprüft', klasse: 'kn-pille kn-pille-violett' })
assert.deepEqual(kopf(bloecke[2]), { titel: 'Öffnungszeiten aktualisieren', pille: 'Erledigt', klasse: 'kn-pille kn-pille-gruen' })
assert.equal(fx.finde(bloecke[0], 'h3').className, 'kn-az-titel')
}))
test('Aktivität: In Arbeit ist blau, Eingegangen und Pausiert grau, Storniert rot', () => mitDom((fx) => {
const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z'), auftrag(2, 'neu', '2026-10-04T00:00:00Z'), auftrag(3, 'pause', '2026-10-03T00:00:00Z'), auftrag(4, 'storniert', '2026-10-02T00:00:00Z')], JETZT)
assert.deepEqual(fx.alle(el, '.kn-pille').map((p) => p.className.split(' ')[1]), ['kn-pille-blau', 'kn-pille-grau', 'kn-pille-grau', 'kn-pille-rot'])
}))
test('Aktivität: schmaler Balken mit vier Abschnitten, Stufennamen als Text für Screenreader', () => mitDom((fx) => {
const el = baueAktivitaet(dreiAuftraege(), JETZT)
const [wartet, pruefung, fertig] = fx.alle(el, '[data-auftrag]')
const stand = (b) => fx.alle(fx.finde(b, '.kn-az-balken'), 'li').map((li) => [li.classList.contains('is-done'), li.getAttribute('aria-current')])
assert.deepEqual(stand(wartet), [[true, null], [true, 'step'], [false, null], [false, null]])
assert.deepEqual(stand(pruefung), [[true, null], [true, null], [true, 'step'], [false, null]])
assert.deepEqual(stand(fertig), [[true, null], [true, null], [true, null], [true, 'step']])
const balken = fx.finde(wartet, '.kn-az-balken'); assert.equal(balken.tagName, 'OL'); assert.equal(balken.getAttribute('aria-label'), 'Fortschritt des Auftrags')
assert.deepEqual(fx.alle(balken, '.kn-nur-leser').map((s) => s.textContent), ['Eingegangen, erreicht', 'In Arbeit, aktueller Stand', 'Prüfung, noch offen', 'Erledigt, noch offen'])
assert.deepEqual(fx.alle(fertig, '.kn-nur-leser').map((s) => s.textContent), ['Eingegangen, erreicht', 'In Arbeit, erreicht', 'Prüfung, erreicht', 'Erledigt, aktueller Stand'])
}))
test('Aktivität: Stufe 0 (pausiert, storniert) zeigt keinen Balken, nur die Pille', () => mitDom((fx) => {
const el = baueAktivitaet([auftrag(1, 'pause', '2026-10-05T00:00:00Z'), auftrag(2, 'storniert', '2026-10-04T00:00:00Z')], JETZT)
assert.equal(fx.alle(el, '[data-auftrag]').length, 2); assert.equal(fx.finde(el, '.kn-az-balken'), null)
}))
test('Aktivität: Rückfrage steht unter der Überschrift mit Link in den Chat, sonst keine Zeile', () => mitDom((fx) => {
const el = baueAktivitaet(dreiAuftraege(), JETZT)
const [wartet, pruefung, fertig] = fx.alle(el, '[data-auftrag]')
const info = fx.finde(wartet, '.kn-az-info')
assert.equal(info.textContent, 'Wir brauchen eine Info von dir · Im Chat antworten')
// Die ganze Zeile ist der Link in den Chat (eine große Tippfläche am Handy); nur „Im Chat antworten“ ist unterstrichen
const chat = fx.finde(info, 'a'); assert.deepEqual([chat.textContent, chat.getAttribute('href'), chat.getAttribute('data-view')], ['Wir brauchen eine Info von dir · Im Chat antworten', '#chat', 'chat'])
assert.equal(fx.finde(chat, '.kn-az-info-link').textContent, 'Im Chat antworten'); assert.equal(fx.alle(info, 'a').length, 1)
assert.equal(fx.alle(el, '.kn-az-info').length, 1); assert.equal(fx.finde(pruefung, '.kn-az-info'), null); assert.equal(fx.finde(fertig, '.kn-az-info'), null)
// Reihenfolge im Block: Kopf, Info, Balken, Zeitleiste
const klassen = [...wartet.children].map((k) => k.className.split(' ')[0]); assert.deepEqual(klassen, ['kn-az-kopf', 'kn-az-info', 'kn-az-balken', 'kn-az-zeitleiste'])
}))
test('Aktivität: Auftragsnummer erscheint nicht als Text (ruhiger), bleibt aber als Datenattribut', () => mitDom((fx) => {
const el = baueAktivitaet(dreiAuftraege(), JETZT)
assert.doesNotMatch(fx.text(el), /1002\d\d|Auftrag \d/)
}))
test('Aktivität: Zeitleiste mit Datum und Kurztext, drei sichtbar, „mehr anzeigen“ klappt den Rest auf', () => mitDom((fx) => {
const el = baueAktivitaet(dreiAuftraege(), JETZT)
const pruefung = fx.alle(el, '[data-auftrag]')[1]
const zeilen = fx.alle(pruefung, '.kn-az-zeitleiste li')
assert.deepEqual(zeilen.map((z) => fx.finde(z, 'time').textContent), ['14.10.', '13.10.', '12.10.', '09.10.'])
assert.deepEqual(zeilen.map((z) => fx.finde(z, '.kn-az-text').textContent), ['Speisekarte gesetzt.', 'Layout geprüft.', 'Entwurf erstellt.', 'Texte gesammelt.'])
assert.equal(fx.finde(zeilen[0], 'time').getAttribute('datetime'), '2026-10-14')
assert.deepEqual(zeilen.map((z) => z.hasAttribute('data-extra')), [false, false, false, true])
const wrapper = fx.finde(pruefung, '[data-einklappbar]'); assert.ok(wrapper); assert.ok(!wrapper.classList.contains('is-open'))
const mehr = fx.finde(pruefung, '[data-mehr]'); assert.equal(mehr.textContent, 'mehr anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'false')
fx.klick(mehr)
assert.ok(wrapper.classList.contains('is-open')); assert.equal(mehr.textContent, 'weniger anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'true')
fx.klick(mehr)
assert.ok(!wrapper.classList.contains('is-open')); assert.equal(mehr.textContent, 'mehr anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'false')
// genau drei Zeilen: kein Knopf; weniger: kein Knopf
assert.equal(fx.finde(fx.alle(el, '[data-auftrag]')[0], '[data-mehr]'), null)
assert.equal(fx.alle(el, '[data-mehr]').length, 1)
}))
// Eine Klasse für Text nur für Vorlesegeräte: die vorhandene .kn-nur-leser (huelle.css). Die zweite, .kn-nur-lesen (ansichten.css), ist entfallen.
test('Nur für Vorlesegeräte: die Zeitleiste nutzt .kn-nur-leser aus huelle.css, .kn-nur-lesen gibt es in keinem Stil und keiner Ansicht mehr', () => mitDom((fx) => {
const css = (name) => readFileSync(new URL(`../public/neu/${name}`, import.meta.url), 'utf8')
assert.match(css('huelle.css'), /\n\.kn-nur-leser \{[^}]*position: absolute;[^}]*\}/)
assert.doesNotMatch(css('ansichten.css'), /kn-nur-lesen\b/)
assert.doesNotMatch(readFileSync(new URL('../public/neu/auftrags-zeitleiste.js', import.meta.url), 'utf8'), /kn-nur-lesen\b/)
const balken = fx.finde(baueAktivitaet(dreiAuftraege(), JETZT), '.kn-az-balken')
assert.equal(fx.alle(balken, 'li > .kn-nur-leser').length, 4)
assert.equal(fx.alle(balken, '.kn-nur-lesen').length, 0)
}))
// Barrierefreiheit: Mehrere Knöpfe „mehr anzeigen“ auf einer Seite sind für Screenreader nicht zu unterscheiden. Der Name nennt darum den Auftrag (und enthält den sichtbaren
// Text, WCAG 2.5.3), aria-controls zeigt auf die Liste, die der Knopf aufklappt.
test('Zeitleiste: „mehr anzeigen“ trägt aria-label mit dem Auftragstitel und aria-controls auf die eigene Liste, auch nach dem Umschalten', () => mitDom((fx) => {
const vier = (nr, titel) => auftrag(nr, 'arbeit', '2026-10-06T10:00:00Z', ['a', 'b', 'c', 'd'].map((t, j) => eintrag(`2026-10-1${j}`, t)), titel)
const el = baueAktivitaet([vier(1, 'Speisekarte als PDF'), vier(2, 'Neue Fotos <b>für</b> die Startseite')], JETZT)
const [erster, zweiter] = fx.alle(el, '[data-auftrag]')
const knopf = (block) => fx.finde(block, '[data-mehr]')
const liste = (block) => fx.finde(block, '.kn-az-zeilen')
assert.equal(knopf(erster).textContent, 'mehr anzeigen')
assert.equal(knopf(erster).getAttribute('aria-label'), 'mehr anzeigen zu „Speisekarte als PDF“')
assert.equal(knopf(zweiter).getAttribute('aria-label'), 'mehr anzeigen zu „Neue Fotos <b>für</b> die Startseite“') // nur Text, nie HTML
// aria-controls: die Id der Liste desselben Auftrags, eindeutig auf der Seite
const ids = [liste(erster).getAttribute('id'), liste(zweiter).getAttribute('id')]
assert.ok(ids.every((id) => /^[A-Za-z][\w-]*$/.test(id)) && ids[0] !== ids[1], ids.join())
assert.deepEqual([knopf(erster), knopf(zweiter)].map((k) => k.getAttribute('aria-controls')), ids)
assert.ok(knopf(erster).getAttribute('aria-label').startsWith(knopf(erster).textContent)) // der sichtbare Text steht im Namen
// Umschalten: Text, Zustand und Name ziehen mit, die Verknüpfung bleibt
fx.klick(knopf(erster))
assert.equal(knopf(erster).textContent, 'weniger anzeigen')
assert.equal(knopf(erster).getAttribute('aria-label'), 'weniger anzeigen zu „Speisekarte als PDF“')
assert.equal(knopf(erster).getAttribute('aria-controls'), ids[0])
assert.equal(knopf(zweiter).getAttribute('aria-label'), 'mehr anzeigen zu „Neue Fotos <b>für</b> die Startseite“') // der andere Knopf bleibt
fx.klick(knopf(erster))
assert.equal(knopf(erster).getAttribute('aria-label'), 'mehr anzeigen zu „Speisekarte als PDF“')
// ohne Titel gilt der Ersatztitel der Überschrift; „Ältere Aufträge anzeigen“ bleibt wie es war
const ohneTitel = baueAktivitaet([{ ...vier(3, ''), topic: '' }], JETZT)
assert.equal(fx.finde(ohneTitel, '[data-mehr]').getAttribute('aria-label'), 'mehr anzeigen zu „Dein Auftrag“')
const sechs = baueAktivitaet(Array.from({ length: 6 }, (_, i) => vier(10 + i, `T${i}`)), JETZT)
assert.equal(fx.finde(sechs, '[data-aeltere]').getAttribute('aria-label'), null)
}))
test('Aktivität: Auftrag ohne Einträge zeigt einen ruhigen Satz statt einer leeren Zeitleiste', () => mitDom((fx) => {
const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [])], JETZT)
assert.equal(fx.finde(el, '.kn-az-zeitleiste'), null); assert.match(fx.text(el), /Noch keine Arbeiten dokumentiert\./)
}))
test('Aktivität: fünf Aufträge sichtbar, „Ältere Aufträge anzeigen“ klappt den Rest auf', () => mitDom((fx) => {
const liste = Array.from({ length: 8 }, (_, i) => auftrag(200 + i, i === 7 ? 'fertig' : 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, [eintrag(`2026-10-0${i + 1}`, `Eintrag ${i}`)]))
const el = baueAktivitaet(liste, JETZT)
const bloecke = fx.alle(el, '[data-auftrag]')
assert.equal(bloecke.length, 8)
assert.deepEqual(bloecke.map((b) => b.hasAttribute('data-extra')), [false, false, false, false, false, true, true, true])
assert.deepEqual(bloecke.map((b) => b.getAttribute('data-auftrag')), ['206', '205', '204', '203', '202', '201', '200', '207'])
const alt = fx.finde(el, '[data-aeltere]'); assert.equal(alt.textContent, 'Ältere Aufträge anzeigen'); assert.equal(alt.getAttribute('aria-expanded'), 'false')
const huelle = alt.closest('[data-einklappbar]'); assert.ok(!huelle.classList.contains('is-open'))
fx.klick(alt); assert.ok(huelle.classList.contains('is-open')); assert.equal(alt.textContent, 'Weniger anzeigen'); assert.equal(alt.getAttribute('aria-expanded'), 'true')
fx.klick(alt); assert.ok(!huelle.classList.contains('is-open')); assert.equal(alt.textContent, 'Ältere Aufträge anzeigen')
assert.equal(fx.finde(baueAktivitaet(liste.slice(0, 5), JETZT), '[data-aeltere]'), null)
}))
test('Aktivität: Zeilen-Knopf und Aufträge-Knopf sind getrennt (mehr anzeigen öffnet nicht die älteren Aufträge)', () => mitDom((fx) => {
const viele = Array.from({ length: 6 }, (_, i) => auftrag(300 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, ['a', 'b', 'c', 'd'].map((t, j) => eintrag(`2026-10-1${j}`, t))))
const el = baueAktivitaet(viele, JETZT)
const huelle = fx.finde(el, '[data-aeltere]').closest('[data-einklappbar]')
const ersterMehr = fx.finde(fx.alle(el, '[data-auftrag]')[0], '[data-mehr]')
fx.klick(ersterMehr); assert.ok(!huelle.classList.contains('is-open')); assert.ok(ersterMehr.closest('[data-einklappbar]').classList.contains('is-open'))
}))
test('Aktivität: fremde Inhalte bleiben Text; leer und kaputte Elemente', () => mitDom((fx) => {
const boese = '<img src=x onerror=alert(1)>'
const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', boese), eintrag('2026-10-05', 'x', { changes: [boese] })], boese)], JETZT)
assert.equal(fx.finde(el, 'img'), null); assert.ok(fx.text(el).includes(boese))
assert.equal(baueAktivitaet([], JETZT).textContent, 'Es wurden noch keine Arbeiten dokumentiert.')
assert.equal(baueAktivitaet(undefined, JETZT).textContent, 'Es wurden noch keine Arbeiten dokumentiert.')
const roh = baueAktivitaet([{ number: '9' }, { topic: 'Nur Titel', status: null, entries: null }], JETZT)
assert.equal(fx.alle(roh, '.kn-az').length, 2); assert.match(fx.text(roh), /Dein Auftrag/); assert.doesNotMatch(fx.text(roh), /null|undefined/)
}))
// Vertrag mit dem Server: GET /api/activity baut jeden Auftrag aus customerOrder() und customerWorkEntries() (server/routes/customerData.js).
// Hier läuft die echte Server-Aufbereitung in die Zeitleiste, damit eine geänderte Antwortform den Test rot macht.
test('Vertrag: Aufträge in der Form von /api/activity (customerOrder, customerWorkEntries) erscheinen mit Datum und Kurztext', () => mitDom((fx) => {
const ticket = (woid, status, createdAt, worksheets) => ({ woid, topic: `Thema ${woid}`, status, createdAt, worksheets })
const antwort = [
ticket('100231', 'awaiting', '2026-10-13T10:00:00.000Z', [
{ serviceType: 'REMOTE', date: '14.10.2026', totalTime: 50, employeeName: 'Lena Vogel', details: 'Fotos erhalten und gesichtet.\nWeitere Zeile.', supporters: '' },
{ serviceType: 'GIT', date: '2026-10-12', totalTime: 0, isComment: true, details: '- [abcdef1](https://git.example/x/commit/abcdef1): feat: Menü angepasst (Kenso)\n- [abcdef2](https://git.example/x/commit/abcdef2): Bilder getauscht (Kenso)' },
]),
ticket('100207', 'closed', '2026-10-02T10:00:00.000Z', [{ serviceType: 'ON SITE', date: '04.10.2026', totalTime: 60, employeeName: 'Kenso', details: '' }]),
].map((t) => ({ ...customerOrder(t), createdAt: t.createdAt, entries: customerWorkEntries(t.worksheets) }))
const el = baueAktivitaet(antwort, JETZT)
const [wartet, fertig] = fx.alle(el, '[data-auftrag]')
assert.deepEqual([wartet.getAttribute('data-auftrag'), fertig.getAttribute('data-auftrag')], ['100231', '100207'])
assert.equal(fx.finde(wartet, '.kn-pille').textContent, 'Wartet auf deine Rückmeldung'); assert.ok(fx.finde(wartet, '.kn-az-info'))
assert.deepEqual(fx.alle(wartet, '.kn-az-zeilen li').map((z) => z.textContent), ['14.10.Fotos erhalten und gesichtet.', '12.10.Menü angepasst und 1 weitere Änderung'])
assert.equal(fx.finde(fertig, '.kn-pille').textContent, 'Erledigt'); assert.deepEqual(fx.alle(fertig, '.kn-az-zeilen li').map((z) => z.textContent), ['04.10.Vor Ort bei dir'])
}))
// Regression (Browserlauf 17.10.): Die allgemeine Regel `.kn-einklappbar:not(.is-open) [data-extra]` trifft jeden Nachfahren. Läge sie an den verschachtelten Wrappern
// (Aufträge, darin die Zeitleisten), verbärge ein zugeklappter äußerer Wrapper auch die Zeilen einer aufgeklappten Zeitleiste. Darum tragen beide Wrapper eigene Klassen
// und eigene Regeln nur für direkte Kinder.
test('Einklappen: die Wrapper verschachteln sich nicht über .kn-einklappbar, die Regeln gelten nur für direkte Kinder', () => mitDom((fx) => {
const viele = Array.from({ length: 6 }, (_, i) => auftrag(400 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, ['a', 'b', 'c', 'd'].map((t, j) => eintrag(`2026-10-1${j}`, t))))
const el = baueAktivitaet(viele, JETZT)
assert.deepEqual(fx.alle(el, '[data-einklappbar]').map((w) => w.classList.contains('kn-einklappbar')), new Array(7).fill(false))
assert.equal(el.classList.contains('kn-az-liste'), true); assert.equal(fx.finde(el, '[data-einklappbar] [data-einklappbar]').classList.contains('kn-az-zeitleiste'), true)
const css = readFileSync(new URL('../public/neu/ansichten.css', import.meta.url), 'utf8')
assert.match(css, /\.kn-az-liste:not\(\.is-open\) > \.kn-az-auftraege > \[data-extra\] \{ display: none; \}/)
assert.match(css, /\.kn-az-zeitleiste:not\(\.is-open\) > \.kn-az-zeilen > \[data-extra\] \{ display: none; \}/)
}))
// Browserlauf 17.10.: Ein display:flex am Link zerlegte den Satz in zwei Spalten (Text | „Im Chat antworten“). Der Link ist ein Block; die Tippfläche kommt aus dem Innenabstand.
test('Rückfrage-Link: am Handy ein Block (kein flex) mit Innenabstand für 44 px Tippfläche', () => {
const css = readFileSync(new URL('../public/neu/ansichten.css', import.meta.url), 'utf8')
assert.match(css, /\n \.kn-az-info a \{ display: block; padding: 12px 0; \}/)
assert.doesNotMatch(css, /\.kn-az-info a \{[^}]*display: *(inline-)?flex/)
})
// Browserlauf 17.10. (Prüfbefund): „Im Chat antworten“ brach am schmalen Handy mitten im Text um. Der Linktext bleibt zusammen; passt er nicht mehr in die Zeile, wandert er ganz in die nächste.
test('Rückfrage-Link: der Linktext „Im Chat antworten“ bricht nicht um (white-space: nowrap auf .kn-az-info-link)', () => {
const css = readFileSync(new URL('../public/neu/ansichten.css', import.meta.url), 'utf8')
assert.match(css, /\n\.kn-az-info-link \{[^}]*\bwhite-space: nowrap;[^}]*\}/)
assert.doesNotMatch(css, /\.kn-az-info a \{[^}]*white-space: nowrap/) // der Satz davor darf umbrechen
})
test('Zeitleiste: trägt ein Eintrag das Jahr (Vorjahr), richten sich alle Datumsspalten des Auftrags danach', () => mitDom((fx) => {
const mitJahr = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', 'neu'), eintrag('2025-12-24', 'alt')])], JETZT)
assert.deepEqual(fx.alle(mitJahr, 'time').map((t) => t.textContent), ['06.10.', '24.12.25']); assert.ok(fx.finde(mitJahr, '.kn-az-zeilen').classList.contains('kn-az-mit-jahr'))
const ohne = baueAktivitaet([auftrag(2, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', 'neu')])], JETZT)
assert.ok(!fx.finde(ohne, '.kn-az-zeilen').classList.contains('kn-az-mit-jahr'))
}))
+81
View File
@@ -0,0 +1,81 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { haltFokus } from '../public/neu/ansicht-chat.js'
import { lies, handyTeil } from './support/knHuelle.js'
// Chat als Vollbild-Ansicht (Pilot-Feedback 17.10.: „durchsichtig und verrutscht“). Die Lage prüfen die Ansichten kn-p2-chat-* des Werkzeugs im Browser;
// diese Pins lesen nur den Quelltext und halten die Regeln fest, an denen die Lage hängt.
const ANSICHTEN = lies('ansichten.css')
const HUELLE = lies('huelle.css')
const regel = (css, selektor) => {
const m = css.match(new RegExp(`(?:^|\\n)${selektor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{([^}]*)\\}`))
return m ? m[1] : ''
}
const chatBlock = () => HUELLE.slice(HUELLE.indexOf('Chat: Vollbild-Ansicht'))
test('Chat: der Verlauf füllt die Höhe und scrollt in sich, ohne feste Höhengrenze (kein 60vh/55vh mehr)', () => {
const verlauf = regel(ANSICHTEN, '.kn .kn-chat .chat-messages')
assert.match(verlauf, /flex:\s*1 1 0/)
assert.match(verlauf, /min-height:\s*0/)
assert.match(verlauf, /overflow-y:\s*auto/)
assert.doesNotMatch(verlauf, /max-height/)
assert.doesNotMatch(ANSICHTEN, /\.chat-messages\s*\{[^}]*(?:min-height:\s*(?:240|280)px|max-height:\s*\d+vh)/)
assert.match(ANSICHTEN, /\.kn \.kn-chat \.chat-messages > :first-child\s*\{\s*margin-top:\s*auto/) // kurze Verläufe stehen unten bei der Eingabe
})
test('Chat: Spalte höchstens 760 px, mittig; Eingabe fest unten', () => {
const spalte = ANSICHTEN.match(/\n\.kn-chat \{[^}]*\}/)[0]
assert.match(spalte, /max-width:\s*760px/)
assert.match(spalte, /margin-inline:\s*auto/)
assert.match(regel(ANSICHTEN, '.kn .kn-chat .kn-chat-composer'), /flex:\s*none/)
assert.match(regel(HUELLE, '.kn-haupt > #bereich-chat:not([hidden])'), /max-width:\s*760px[^}]*margin:\s*0 auto/)
})
test('Chat: Blasen ruhig (Fläche #f8f9fb für fremde, #ebeef1 für eigene), über Tokens', () => {
assert.match(ANSICHTEN, /--chat-bubble-in:\s*var\(--kn-bg-subtle\)/)
assert.match(ANSICHTEN, /--chat-bubble-out:\s*var\(--kn-grau-bg\)/)
const tokens = lies('tokens.css')
assert.match(tokens, /--kn-bg-subtle:\s*#f8f9fb/)
assert.match(tokens, /--kn-grau-bg:\s*#ebeef1/)
})
test('Chat: „Senden“ bleibt der schwarze Hauptknopf, auch bei leerem Feld; gesperrt (Ansichtsmodus) und beim Senden bleibt er gedämpft', () => {
assert.match(regel(ANSICHTEN, '.kn .kn-chat .chat-composer:not(.is-disabled) .chat-send:disabled:not(.is-busy)'), /opacity:\s*1\b/)
assert.match(regel(ANSICHTEN, '.kn .kn-chat .chat-icon-btn:disabled, .kn .kn-chat .chat-send:disabled'), /opacity:\s*\.55/)
assert.match(regel(ANSICHTEN, '.kn .kn-chat .chat-send.is-busy:disabled'), /opacity:\s*\.8/)
})
test('Chat in der Hülle: Fensterhöhe am Desktop, am Handy fest zwischen Kopf und Leiste, „Chat“ klein im Kopf statt große Überschrift', () => {
const block = chatBlock()
assert.match(regel(block, '.kn-spalte:has(#bereich-chat:not([hidden]))'), /display:\s*flex[^}]*height:\s*calc\(100dvh - var\(--kn-banner-h\)\)[^}]*min-height:\s*0/)
assert.match(regel(block, '#chat-body'), /flex:\s*1 1 auto[^}]*min-height:\s*0/)
const handy = handyTeil(block)
assert.match(handy, /\.kn-haupt:has\(#bereich-chat:not\(\[hidden\]\)\)\s*\{[^}]*position:\s*fixed[^}]*inset:\s*calc\(var\(--kn-banner-h\) \+ var\(--kn-kopf-h\) \+ env\(safe-area-inset-top\)\) 0 var\(--kn-unten, var\(--kn-zeile-h\)\)/)
assert.match(handy, /\.kn-haupt:has\(#bereich-chat:not\(\[hidden\]\)\)\s*\{[^}]*padding:\s*0 max\(16px, env\(safe-area-inset-right\)\) 6px max\(16px, env\(safe-area-inset-left\)\)/) // höchstens 8 px Luft über der Leiste
assert.match(handy, /#bereich-chat > \.kn-titel\s*\{[^}]*position:\s*absolute[^}]*width:\s*1px[^}]*height:\s*1px[^}]*clip:\s*rect\(0 0 0 0\)/) // nur noch für Screenreader
assert.match(handy, /\.kn:has\(#bereich-chat:not\(\[hidden\]\)\) \.kn-kopf\s*\{[^}]*height:[^}]*border-bottom-color:\s*var\(--kn-line\)/) // deckender Kopf mit Haarlinie
assert.match(handy, /\.kn:has\(#bereich-chat:not\(\[hidden\]\)\) \.kn-kopf-titel\s*\{[^}]*opacity:\s*1[^}]*transform:\s*none/)
assert.doesNotMatch(block, /backdrop-filter|gradient|rgba\(/) // deckend, kein Glas
})
test('haltFokus: Druck auf einen Knopf im Composer nimmt dem Eingabefeld den Fokus nicht (sonst springt die Eingabe, wenn die Leiste zurückkommt, und der Tipp auf „Senden“ geht verloren)', () => {
const hoerer = new Map()
const knopf = { matches: (s) => s === 'button' }
const wurzel = {
addEventListener: (n, f) => hoerer.set(n, f),
removeEventListener: (n) => hoerer.delete(n),
contains: (el) => el === knopf || el === symbol,
}
const symbol = { closest: (s) => (s === 'button' ? knopf : null) } // das Symbol im Knopf ist das Ziel des Drucks
const feld = { closest: () => null }
const draussen = { closest: () => ({}) } // ein Knopf, der nicht im Composer liegt
const h = haltFokus(wurzel)
assert.deepEqual([...hoerer.keys()], ['mousedown'])
const druck = (target) => { let verhindert = false; hoerer.get('mousedown')({ target, preventDefault: () => { verhindert = true } }); return verhindert }
assert.equal(druck(symbol), true, 'Knopf im Composer: Fokus bleibt')
assert.equal(druck(feld), false, 'Textfeld: normal')
assert.equal(druck(draussen), false, 'Knopf außerhalb: normal')
assert.equal(druck(undefined), false)
h.abbau()
assert.equal(hoerer.size, 0)
})
+49 -10
View File
@@ -15,7 +15,7 @@ function umgebung(fx, { d = daten(), abo, api, post = { feature: { state: 'setup
const basis = async (pfad, o = {}) => { const basis = async (pfad, o = {}) => {
u.aufrufe.push([o.method ?? 'GET', pfad]) u.aufrufe.push([o.method ?? 'GET', pfad])
if (o.method === 'POST') return post if (o.method === 'POST') return post
if (pfad === '/api/funktionen') return u.d if (pfad === '/api/funktionen?kurzinfo=1') return u.d // nur „Meine“ fragt die Kurzinfo ab; ein anderer Pfad wäre „unerwartet“
if (pfad === '/api/invoices') { if (u.rechnungenFehler) throw Object.assign(new Error('x'), { status: 500, body: {} }); return { invoices: u.rechnungen, incomplete: false } } if (pfad === '/api/invoices') { if (u.rechnungenFehler) throw Object.assign(new Error('x'), { status: 500, body: {} }); return { invoices: u.rechnungen, incomplete: false } }
if (pfad === '/api/abo') { if (abo instanceof Error) throw abo; return abo ?? { subscription: null } } if (pfad === '/api/abo') { if (abo instanceof Error) throw abo; return abo ?? { subscription: null } }
throw new Error(`unerwartet ${pfad}`) throw new Error(`unerwartet ${pfad}`)
@@ -31,12 +31,23 @@ function umgebung(fx, { d = daten(), abo, api, post = { feature: { state: 'setup
} }
const tab = (fx, u, text) => fx.alle(u.wurzel, '[role="tab"]').find((t) => t.textContent === text) const tab = (fx, u, text) => fx.alle(u.wurzel, '[role="tab"]').find((t) => t.textContent === text)
test('Meine: Liste mit Name, zweiter Zeile und Pille; Tipp öffnet die Etage mit der Website der Zeile, eine angenommene Anfrage die Bestätigung', mit(async (fx) => { test('Meine: Liste mit Name, Detailzeile und Pfeil, Pille nur ohne aktiven Zustand; Tipp öffnet die Etage mit der Website der Zeile, eine angenommene Anfrage die Bestätigung', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active'), shop: feature('off', { anfrageStatus: 'angenommen' }) }), projekt('p2', 'Café', { termine: feature('active') })] }) }) const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active', { kurzinfo: '3 Termine heute · nächster 10:30' }), shop: feature('off', { anfrageStatus: 'angenommen' }) }), projekt('p2', 'Café', { termine: feature('active') })] }) })
await u.zeige(MEINE) await u.zeige(MEINE)
const zeilen = fx.alle(u.wurzel, 'li[data-fn]') const zeilen = fx.alle(u.wurzel, 'li[data-fn]')
assert.deepEqual(zeilen.map((z) => [z.getAttribute('data-fn'), z.getAttribute('data-projekt')]), [['termine', 'p1'], ['termine', 'p2'], ['shop', 'p1']]) assert.deepEqual(zeilen.map((z) => [z.getAttribute('data-fn'), z.getAttribute('data-projekt')]), [['termine', 'p1'], ['termine', 'p2'], ['shop', 'p1']])
assert.match(fx.text(zeilen[1]), /Terminplaner.*Café.*Aktiv/) assert.deepEqual(zeilen.map((z) => [fx.finde(z, '.kn-fn-name').textContent, fx.finde(z, '.kn-fn-zeile2').textContent]), [
['Terminplaner', 'Bäckerei · 3 Termine heute · nächster 10:30'],
['Terminplaner', 'Café · Aktiv'],
['Webshop', 'Bäckerei · Jetzt einschalten'],
])
assert.deepEqual(zeilen.map((z) => fx.finde(z, '.kn-fn-pille')?.textContent ?? null), [null, null, 'Angenommen'])
assert.doesNotMatch(fx.text(u.wurzel), /Aktiv seit/)
for (const z of zeilen) {
const pfeil = fx.finde(z, '.kn-fn-pfeil')
assert.deepEqual([pfeil.textContent, pfeil.getAttribute('aria-hidden')], ['›', 'true'])
assert.equal(fx.finde(z, 'button').lastElementChild.lastElementChild, pfeil, 'der Pfeil steht ganz rechts')
}
await fx.klick(fx.finde(zeilen[0], 'button')) await fx.klick(fx.finde(zeilen[0], 'button'))
await fx.klick(fx.finde(zeilen[1], 'button')) await fx.klick(fx.finde(zeilen[1], 'button'))
assert.deepEqual(u.gegangen, [['#termine?projekt=p1'], ['#termine?projekt=p2']]) assert.deepEqual(u.gegangen, [['#termine?projekt=p1'], ['#termine?projekt=p2']])
@@ -191,20 +202,48 @@ test('Bildadresse: nur /neu/bilder/<name>.webp; alles andere erscheint ohne Bild
} }
})) }))
test('Meine mit zwei Websites zeigt den Websitenamen in der zweiten Zeile, mit einer den Status', mit(async (fx) => { test('Meine: bei einer Website steht die Detailzeile allein, bei mehreren der Websitename davor; ohne Kurzinfo "Aktiv"', mit(async (fx) => {
const einzel = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') })] }) }) const einzel = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active', { kurzinfo: '4 Beiträge sichtbar' }), zeiten: feature('active', { kurzinfo: null }) })] }) })
await einzel.zeige(MEINE) await einzel.zeige(MEINE)
assert.match(fx.text(fx.finde(einzel.wurzel, 'li[data-fn]')), /News-Pinnwand.*Läuft auf deiner Website.*Aktiv/) assert.deepEqual(fx.alle(einzel.wurzel, 'li[data-fn] .kn-fn-zeile2').map((e) => e.textContent), ['4 Beiträge sichtbar', 'Aktiv'])
const zwei = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') }), projekt('p2', 'Café')] }) }) const zwei = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active', { kurzinfo: '4 Beiträge sichtbar' }) }), projekt('p2', 'Café')] }) })
await zwei.zeige(MEINE) await zwei.zeige(MEINE)
assert.match(fx.text(fx.finde(zwei.wurzel, 'li[data-fn]')), /News-Pinnwand.*Bäckerei.*Aktiv/) assert.deepEqual(fx.alle(zwei.wurzel, 'li[data-fn] .kn-fn-zeile2').map((e) => e.textContent), ['Bäckerei · 4 Beiträge sichtbar'])
}))
test('Meine: Pille und Pfeil liegen rechts nebeneinander (Pille zuerst); Namen und Kurzinfo werden nur als Text gesetzt', mit(async (fx) => {
const u = umgebung(fx, { d: daten({ projekte: [projekt('p1', '<u>Café</u>', { news: feature('active', { kurzinfo: '<b>4</b> Beiträge' }), termine: feature('setup', { ticketWoid: '100301' }) })] }) })
await u.zeige(MEINE)
assert.equal(fx.alle(u.wurzel, 'b, u').length, 0)
const [news, termine] = fx.alle(u.wurzel, 'li[data-fn]')
assert.equal(fx.finde(news, '.kn-fn-zeile2').textContent, '<b>4</b> Beiträge')
const rechts = fx.finde(termine, '.kn-fn-rechts')
assert.deepEqual([...rechts.children].map((k) => k.className), ['kn-fn-pille kn-fn-pille--blau', 'kn-fn-pfeil'])
assert.equal(fx.finde(termine, '.kn-fn-zeile2').textContent, 'Wir richten sie gerade ein')
})) }))
/* ---------------------------------------------------------------- Teil 2: Overlay, Erfolgsmeldung, Wechsel --- */ /* ---------------------------------------------------------------- Teil 2: Overlay, Erfolgsmeldung, Wechsel --- */
test('Kurzinfo nur auf Anfrage: die Seite lädt genau einmal GET /api/funktionen?kurzinfo=1, in beiden Reitern und nach „Nochmal versuchen“', mit(async (fx) => {
const u = umgebung(fx)
await u.zeige(MEINE)
assert.deepEqual(u.aufrufe.filter(([, p]) => p.startsWith('/api/funktionen')), [['GET', '/api/funktionen?kurzinfo=1']])
vergissMerker()
const e = umgebung(fx)
await e.zeige(ENTDECKEN)
assert.deepEqual(e.aufrufe.filter(([, p]) => p.startsWith('/api/funktionen')), [['GET', '/api/funktionen?kurzinfo=1']])
vergissMerker()
let versuche = 0
const f = umgebung(fx, { api: async (pfad) => { if (pfad.startsWith('/api/funktionen')) { versuche += 1; assert.equal(pfad, '/api/funktionen?kurzinfo=1'); if (versuche === 1) throw new Error('x'); return daten() } if (pfad === '/api/invoices') return { invoices: [], incomplete: false }; throw new Error(`unerwartet ${pfad}`) } })
await f.zeige(MEINE)
await fx.klick(fx.finde(f.wurzel, '[data-aktion="nochmal"]'))
assert.equal(versuche, 2)
assert.equal(fx.finde(f.wurzel, '.kn-fehler'), null) // die Fehlermeldung ist weg, die Seite steht
}))
test('Bestätigung als Overlay: Zurück-Geste bringt die Liste ohne neuen Abruf zurück, Abbrechen ebenso', mit(async (fx) => { test('Bestätigung als Overlay: Zurück-Geste bringt die Liste ohne neuen Abruf zurück, Abbrechen ebenso', mit(async (fx) => {
const u = umgebung(fx) const u = umgebung(fx)
await u.zeige(ENTDECKEN) await u.zeige(ENTDECKEN)
const geladen = () => u.aufrufe.filter(([m, p]) => m === 'GET' && p === '/api/funktionen').length const geladen = () => u.aufrufe.filter(([m, p]) => m === 'GET' && p === '/api/funktionen?kurzinfo=1').length
await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]')) await fx.klick(fx.finde(u.wurzel, 'section[data-fn="news"] [data-aktion="einschalten"]'))
assert.match(fx.text(u.wurzel), /News-Pinnwand einschalten/) assert.match(fx.text(u.wurzel), /News-Pinnwand einschalten/)
assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 0) assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 0)
+44 -17
View File
@@ -3,41 +3,68 @@ import assert from 'node:assert/strict'
import { meineZeilen, entdeckenEintraege, zeilenWeg, datumDe } from '../public/neu/ansicht-funktionen.js' import { meineZeilen, entdeckenEintraege, zeilenWeg, datumDe } from '../public/neu/ansicht-funktionen.js'
import { daten, projekt, feature } from './support/knFunktionenDaten.js' import { daten, projekt, feature } from './support/knFunktionenDaten.js'
test('Meine: alles außer off, dazu Entwurf und Anfrage; zurückgezogen zählt nicht', () => { test('Meine: alles außer off, dazu Entwurf und Anfrage; zurückgezogen zählt nicht; aktiv ohne Pille, mit Kurzinfo', () => {
const d = daten({ projekte: [projekt('p1', 'Bäckerei', { const d = daten({ projekte: [projekt('p1', 'Bäckerei', {
news: feature('active', { activatedAt: '2026-10-05T08:00:00.000Z' }), news: feature('active', { activatedAt: '2026-10-05T08:00:00.000Z', kurzinfo: '4 Beiträge sichtbar' }),
termine: feature('off', { siteKey: 'abcdefghijklmnop' }), termine: feature('off', { siteKey: 'abcdefghijklmnop' }),
shop: feature('off', { anfrageStatus: 'offen' }), shop: feature('off', { anfrageStatus: 'offen' }),
unterkunft: feature('off', { anfrageStatus: 'zurueckgezogen' }), unterkunft: feature('off', { anfrageStatus: 'zurueckgezogen' }),
zeiten: feature('disabled', { disabledReason: 'abo_ende', loeschungAm: '2026-11-20' }), zeiten: feature('disabled', { disabledReason: 'abo_ende', loeschungAm: '2026-11-20' }),
})] }) })] })
const z = meineZeilen(d) const z = meineZeilen(d)
assert.deepEqual(z.map((r) => [r.key, r.pille.text, r.pille.ton]), [['news', 'Aktiv', 'gruen'], ['termine', 'Entwurf', 'grau'], ['shop', 'Angefragt', 'violett'], ['zeiten', 'Abgeschaltet', 'grau']]) assert.deepEqual(z.map((r) => [r.key, r.pille ? [r.pille.text, r.pille.ton] : null]), [['news', null], ['termine', ['Entwurf', 'grau']], ['shop', ['Angefragt', 'violett']], ['zeiten', ['Abgeschaltet', 'grau']]])
assert.deepEqual(z.map((r) => r.status), ['Aktiv seit 05.10.2026', 'Fragebogen fortsetzen', 'Wir prüfen deine Anfrage', 'Dein Abo ist beendet. Deine Daten bleiben bis 20.11.2026 gespeichert.']) assert.deepEqual(z.map((r) => r.status), ['4 Beiträge sichtbar', 'Fragebogen fortsetzen', 'Wir prüfen deine Anfrage', 'Dein Abo ist beendet. Deine Daten bleiben bis 20.11.2026 gespeichert.'])
assert.equal(z[0].zeile2, z[0].status) assert.equal(z[0].zeile2, z[0].status)
assert.doesNotMatch(JSON.stringify(z), /Aktiv seit|05\.10\.2026/)
}) })
test('Status je Zustand: Pille, Ton, Zeile', () => { test('Status je Zustand: Pille nur wenn nicht aktiv, Ton, Zeile', () => {
const zeile = (key, f) => meineZeilen(daten({ projekte: [projekt('p1', 'B', { [key]: f })] })).find((r) => r.key === key) const zeile = (key, f) => meineZeilen(daten({ projekte: [projekt('p1', 'B', { [key]: f })] })).find((r) => r.key === key)
const faelle = [ const faelle = [
['news', feature('setup', { ticketWoid: '100201' }), 'In Einrichtung', 'blau', 'Einrichtung läuft · Ticket #100201'], ['news', feature('setup', { ticketWoid: '100201' }), 'In Einrichtung', 'blau', 'Wir richten sie gerade ein'],
['news', feature('setup'), 'In Einrichtung', 'blau', 'Einrichtung läuft'], ['news', feature('setup'), 'In Einrichtung', 'blau', 'Wir richten sie gerade ein'],
['news', feature('ordered'), 'Zahlung offen', 'orange', 'Wir warten auf deine Zahlung'], ['news', feature('ordered'), 'Zahlung offen', 'orange', 'Wir warten auf deine Zahlung'],
['news', feature('active'), 'Aktiv', 'gruen', 'Läuft auf deiner Website'], ['news', feature('active'), null, null, 'Aktiv'],
['news', feature('disabled'), 'Abgeschaltet', 'grau', 'Schreib uns im Chat, wenn du die Funktion wieder möchtest.'], ['news', feature('active', { activatedAt: '2026-10-05T08:00:00.000Z' }), null, null, 'Aktiv'],
['termine', feature('active', { kurzinfo: '3 Termine heute · nächster 10:30' }), null, null, '3 Termine heute · nächster 10:30'],
['news', feature('disabled'), 'Abgeschaltet', 'grau', 'Im Chat wieder anfragen'],
['news', feature('disabled', { disabledReason: 'abo_ende' }), 'Abgeschaltet', 'grau', 'Dein Abo ist beendet.'], ['news', feature('disabled', { disabledReason: 'abo_ende' }), 'Abgeschaltet', 'grau', 'Dein Abo ist beendet.'],
['shop', feature('off', { anfrageStatus: 'angenommen' }), 'Angenommen', 'blau', 'Jetzt einschalten'], ['shop', feature('off', { anfrageStatus: 'angenommen' }), 'Angenommen', 'blau', 'Jetzt einschalten'],
['shop', feature('off', { anfrageStatus: 'abgelehnt' }), 'Abgelehnt', 'rot', 'Schreib uns im Chat'], ['shop', feature('off', { anfrageStatus: 'abgelehnt' }), 'Abgelehnt', 'rot', 'Anfrage abgelehnt · Im Chat nachfragen'],
['shop', feature('off', { anfrageStatus: 'offen' }), 'Angefragt', 'violett', 'Wir prüfen deine Anfrage'],
] ]
for (const [key, f, pille, ton, status] of faelle) { for (const [key, f, pille, ton, status] of faelle) {
const r = zeile(key, f) const r = zeile(key, f)
assert.deepEqual([r.pille.text, r.pille.ton, r.status], [pille, ton, status], `${key} ${f.state}`) assert.deepEqual([r.pille?.text ?? null, r.pille?.ton ?? null, r.status], [pille, ton, status], `${key} ${f.state}`)
} }
}) })
test('mehrere Websites: eine Zeile je Funktion und Website, zweite Zeile ist der Websitename', () => { test('Kurzinfo: nur bei aktiver Funktion, nur als kurzer nicht leerer Text; sonst "Aktiv"', () => {
const d = daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active') }), projekt('p2', 'Café', { termine: feature('setup'), news: feature('active') })] }) const status = (f) => meineZeilen(daten({ projekte: [projekt('p1', 'B', { news: f })] })).find((r) => r.key === 'news').status
assert.deepEqual(meineZeilen(d).map((r) => [r.key, r.projectId, r.zeile2]), [['news', 'p2', 'Café'], ['termine', 'p1', 'Bäckerei'], ['termine', 'p2', 'Café']]) assert.equal(status(feature('active', { kurzinfo: ' 4 Beiträge sichtbar ' })), '4 Beiträge sichtbar')
for (const k of [null, undefined, '', ' ', 5, true, {}, ['x'], 'x'.repeat(81)]) assert.equal(status(feature('active', { kurzinfo: k })), 'Aktiv', String(JSON.stringify(k)))
assert.equal(status(feature('active', { kurzinfo: 'x'.repeat(80) })), 'x'.repeat(80))
// ein Text an einer Funktion, die nicht läuft, wird nicht gezeigt (der Server schickt dort null)
assert.equal(status(feature('setup', { kurzinfo: '4 Beiträge sichtbar' })), 'Wir richten sie gerade ein')
assert.equal(status(feature('disabled', { kurzinfo: '4 Beiträge sichtbar' })), 'Im Chat wieder anfragen')
})
test('Kurzinfo der Analyse: aktiv mit Text oder "Aktiv", gesperrt bleibt mit Pille', () => {
const zeile = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((r) => r.key === 'analyse')
assert.deepEqual([zeile({ state: 'active' }).pille, zeile({ state: 'active' }).status], [null, 'Aktiv'])
assert.equal(zeile({ state: 'active', kurzinfo: '120 Besuche in 7 Tagen' }).status, '120 Besuche in 7 Tagen')
assert.deepEqual([zeile({ state: 'disabled' }).pille.text, zeile({ state: 'disabled' }).status], ['Gesperrt', 'Schreib uns im Chat'])
})
test('mehrere Websites: eine Zeile je Funktion und Website, die zweite Zeile beginnt mit dem Websitenamen', () => {
const d = daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active', { kurzinfo: '3 Termine heute · nächster 10:30' }) }), projekt('p2', 'Café', { termine: feature('setup'), news: feature('active') })] })
assert.deepEqual(meineZeilen(d).map((r) => [r.key, r.projectId, r.zeile2]), [
['news', 'p2', 'Café · Aktiv'],
['termine', 'p1', 'Bäckerei · 3 Termine heute · nächster 10:30'],
['termine', 'p2', 'Café · Wir richten sie gerade ein'],
])
// der Websitename allein steht nie in der Zeile, der Status bleibt eigenes Feld
assert.deepEqual(meineZeilen(d).map((r) => r.status), ['Aktiv', '3 Termine heute · nächster 10:30', 'Wir richten sie gerade ein'])
}) })
test('Entdecken: Kandidaten sind die Websites ohne die Funktion; eine einzelne Website mit aktiver Funktion blendet sie aus', () => { test('Entdecken: Kandidaten sind die Websites ohne die Funktion; eine einzelne Website mit aktiver Funktion blendet sie aus', () => {
@@ -56,8 +83,8 @@ test('C9: verfuegbar:false fehlt in Meine und Entdecken; ohne Websites zeigt Ent
}) })
test('Analyse: Zeile nur bei active/disabled; unknown und comingSoon sind kein Kandidat', () => { test('Analyse: Zeile nur bei active/disabled; unknown und comingSoon sind kein Kandidat', () => {
const zeilen = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).filter((r) => r.key === 'analyse').map((r) => r.pille.text) const zeilen = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).filter((r) => r.key === 'analyse').map((r) => r.pille?.text ?? 'ohne Pille')
assert.deepEqual([zeilen({ state: 'active' }), zeilen({ state: 'disabled' }), zeilen({ state: 'off' }), zeilen({ state: 'unknown' }), zeilen({ comingSoon: true })], [['Aktiv'], ['Gesperrt'], [], [], []]) assert.deepEqual([zeilen({ state: 'active' }), zeilen({ state: 'disabled' }), zeilen({ state: 'off' }), zeilen({ state: 'unknown' }), zeilen({ comingSoon: true })], [['ohne Pille'], ['Gesperrt'], [], [], []])
const kand = (analyse) => entdeckenEintraege(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((e) => e.eintrag.key === 'analyse')?.kandidaten.length ?? -1 const kand = (analyse) => entdeckenEintraege(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((e) => e.eintrag.key === 'analyse')?.kandidaten.length ?? -1
assert.deepEqual([kand({ state: 'off' }), kand({ state: 'unknown' }), kand({ comingSoon: true }), kand({ state: 'active' })], [1, 0, 0, -1]) assert.deepEqual([kand({ state: 'off' }), kand({ state: 'unknown' }), kand({ comingSoon: true }), kand({ state: 'active' })], [1, 0, 0, -1])
}) })
@@ -69,7 +96,7 @@ test('zeilenWeg: angenommene Anfrage geht zur Bestätigung, alles andere in die
test('Analyse-Zeilen: Texte, Etage; Abo-Ende mit Löschtag; fehlende Felder brechen nichts', () => { test('Analyse-Zeilen: Texte, Etage; Abo-Ende mit Löschtag; fehlende Felder brechen nichts', () => {
const zeile = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((r) => r.key === 'analyse') const zeile = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((r) => r.key === 'analyse')
assert.deepEqual([zeile({ state: 'active' }).status, zeile({ state: 'disabled' }).status], ['Läuft auf deiner Website', 'Schreib uns im Chat']) assert.deepEqual([zeile({ state: 'active' }).status, zeile({ state: 'disabled' }).status], ['Aktiv', 'Schreib uns im Chat'])
assert.equal(zeile({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-12-01' }).status, 'Dein Abo ist beendet. Deine Daten bleiben bis 01.12.2026 gespeichert.') assert.equal(zeile({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-12-01' }).status, 'Dein Abo ist beendet. Deine Daten bleiben bis 01.12.2026 gespeichert.')
assert.deepEqual(zeilenWeg(zeile({ state: 'active' })), { art: 'etage', etage: 'analyse' }) assert.deepEqual(zeilenWeg(zeile({ state: 'active' })), { art: 'etage', etage: 'analyse' })
const kaputt = { modell: 'abo', katalog: [], projects: [{ projectId: 'p1' }] } const kaputt = { modell: 'abo', katalog: [], projects: [{ projectId: 'p1' }] }
+1 -1
View File
@@ -39,5 +39,5 @@ test('Datenschutz-Prüfpunkte D1 bis D8 mit gültiger Fundstelle in public/daten
const zeilen = lies('public/datenschutz.html').split('\n') const zeilen = lies('public/datenschutz.html').split('\n')
const refs = [...dsg.matchAll(/public\/datenschutz\.html:(\d+)/g)].map((m) => Number(m[1])) const refs = [...dsg.matchAll(/public\/datenschutz\.html:(\d+)/g)].map((m) => Number(m[1]))
assert.ok(refs.length >= 8 && refs.every((n) => n >= 1 && n <= zeilen.length)) assert.ok(refs.length >= 8 && refs.every((n) => n >= 1 && n <= zeilen.length))
assert.ok(zeilen[119].includes('Zahlungspflichtig bestellen') && zeilen[118].includes('Leistungsbeginn') && zeilen[94].includes('Wartungs-Abo'), 'D1, D2, D5 zitieren die richtigen Zeilen') assert.ok(zeilen[126].includes('Zahlungspflichtig bestellen') && zeilen[125].includes('Leistungsbeginn') && zeilen[101].includes('Wartungs-Abo'), 'D1, D2, D5 zitieren die richtigen Zeilen')
}) })
+384
View File
@@ -0,0 +1,384 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
import { UNTERKUNFT_DATETIMES } from '../server/services/unterkunft/schema.js'
// GET /api/funktionen in der neuen Ansicht: je Projekt und Funktion `kurzinfo` (Live-Detail der Zeile in "Meine"). Nur aktive Funktionen
// bekommen einen Text, jede Einzelabfrage ist begrenzt und fehlertolerant (Fehler oder Zeitueberschreitung ergibt null), Berliner Zeit.
// Ohne neue Ansicht bleibt die Antwort wie sie war und nichts davon wird gelesen.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
featureConsents: schemaKeys('featureConsents'),
shopConfig: schemaKeys('shopConfig'),
ukEinstellungen: schemaKeys('ukEinstellungen'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
termineBookings: schemaKeys('termineBookings'),
shopOrders: schemaKeys('shopOrders'),
newsPosts: schemaKeys('newsPosts'),
ukAnfragen: schemaKeys('ukAnfragen'),
},
datetimes: {
...ZEITEN_DATETIMES,
termineBookings: TERMINE_DATETIMES.termineBookings,
shopOrders: SHOP_DATETIMES.shopOrders,
ukAnfragen: UNTERKUNFT_DATETIMES.ukAnfragen,
newsPosts: ['visibleFrom', 'visibleUntil', 'takenDownAt', 'createdAt', 'updatedAt'],
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'],
},
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get } = await import('./support/testApp.js')
const { customerCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const { einschaltenDeps } = await import('../server/services/zeiten/einschalten.js')
const { kurzinfoDeps } = await import('../server/services/kundenansicht/kurzinfo.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-17T08:00:00.000Z') // Samstag 10:00 Uhr in Berlin (Sommerzeit)
const c1 = customerCookie('c1')
const vorher = {
neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] },
termine: config.termine.mode,
shop: { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] },
unterkunft: { mode: config.unterkunft.mode, pilotEmails: [...config.unterkunft.pilotEmails] },
zeiten: { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] },
analyse: config.analyse.enabled,
}
after(() => {
Object.assign(config.kundenansichtNeu, vorher.neu)
config.termine.mode = vorher.termine
Object.assign(config.shop, vorher.shop)
Object.assign(config.unterkunft, vorher.unterkunft)
Object.assign(config.zeiten, vorher.zeiten)
config.analyse.enabled = vorher.analyse
})
const SAMMLUNGEN = ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents', 'shopConfig', 'ukEinstellungen', 'termineBookings', 'shopOrders', 'newsPosts', 'ukAnfragen']
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
const projekt = (id, over = {}) => aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over })
const fid = (p, key) => `f_${p}_${key}`
const funktion = (p, key, state = 'active', over = {}) =>
aw.seed('portalFeatures', { $id: fid(p, key), customerId: 'c1', projectId: p, featureKey: key, siteKey: `${key}${p}`.padEnd(16, 'x').slice(0, 16), state, enabled: state === 'active', ...over })
const NAMEN = ['news', 'termine', 'shop', 'unterkunft', 'zeiten']
const alleAktiv = (p) => NAMEN.forEach((k) => funktion(p, k))
const inStd = (h) => new Date(JETZT.getTime() + h * 3600000).toISOString()
let zaehler = 0
const nr = () => `d${(zaehler += 1)}`
const buchung = (p, over) => aw.seed('termineBookings', { $id: nr(), featureId: fid(p, 'termine'), customerId: 'c1', projectId: p, status: 'bestaetigt', quelle: 'online', endsAt: inStd(1), blockEndsAt: inStd(1), ...over })
const bestellung = (p, status, over = {}) => aw.seed('shopOrders', { $id: nr(), shopId: fid(p, 'shop'), customerId: 'c1', status, bestellnummer: nr(), summeCent: 1000, erstelltAt: inStd(-5), ...over })
const zettel = (p, over = {}) => aw.seed('newsPosts', { $id: nr(), customerId: 'c1', projectId: p, type: 'note', title: 'Hallo', text: 'Text', paper: 'sun', duration: 'open', visibleFrom: inStd(-48), visibleUntil: null, takenDownAt: null, createdAt: inStd(-48), updatedAt: inStd(-48), ...over })
const anfrage = (p, status, von, bis, over = {}) => aw.seed('ukAnfragen', { $id: nr(), featureId: fid(p, 'unterkunft'), customerId: 'c1', projectId: p, unterkunftId: 'u1', nummer: nr(), von, bis, status, personen: 2, ...over })
function plan(woche) {
const tage = {}
for (let d = 1; d <= 7; d += 1) tage[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] }
return { id: 'p1', kurz: 'standard', name: 'Standard', haupt: true, tage }
}
const zeitenDaten = (woche) => ({ land: 'RP', plaene: [plan(woche)], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } })
const zeitenDok = (p, daten) => aw.seed('oeffnungszeiten', { $id: fid(p, 'zeiten'), customerId: 'c1', projectId: p, featureId: fid(p, 'zeiten'), version: 1, teamVersion: 0, daten: JSON.stringify(daten) })
const holen = async (cookie = c1) => (await get('/api/funktionen?kurzinfo=1', { cookie })) // so fragt „Meine“ (kurzinfo nur auf Anfrage)
const holenOhne = async (pfad = '/api/funktionen', cookie = c1) => (await get(pfad, { cookie })) // Etagen, Einschalt-Ablauf, Module
const info = (b, p, key) => b.projects.find((x) => x.projectId === p).features[key].kurzinfo
const abfragen = (sammlung) => aw.listCalls(sammlung)
beforeEach(async () => {
await leere(...SAMMLUNGEN)
resetZeitenCacheForTests()
kurzinfoDeps.limitMs = 1500
zaehler = 0
seedFixtures(aw)
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))
projekt('p1')
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['k@example.invalid']
config.termine.mode = 'on'
config.shop.mode = 'on'
config.unterkunft.mode = 'on'
config.zeiten.mode = 'pilot'
config.zeiten.pilotEmails = ['*']
config.analyse.enabled = false
einschaltenDeps.jetzt = () => JETZT
setMailTransportForTests(mailSink())
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
aw.resetListCalls()
})
afterEach(() => {
mock.restoreAll()
einschaltenDeps.jetzt = () => new Date()
})
/* ------------------------------------------------------------------------------------------ Terminplaner --- */
test('Terminplaner: Termine von heute (Berliner Tag), nächster nach jetzt; Sperren, Abgesagte, andere Tage und andere Funktionen zählen nicht', async () => {
funktion('p1', 'termine'); projekt('p2'); funktion('p2', 'termine')
buchung('p1', { status: 'erledigt', startsAt: '2026-10-17T06:00:00.000Z' }) // 08:00 Berlin, schon vorbei, zählt
buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' }) // 10:30, der nächste
buchung('p1', { startsAt: '2026-10-17T12:00:00.000Z' }) // 14:00
buchung('p1', { status: 'abgesagt', startsAt: '2026-10-17T09:00:00.000Z' })
buchung('p1', { quelle: 'sperre', startsAt: '2026-10-17T09:30:00.000Z' })
buchung('p1', { status: 'angefragt', startsAt: '2026-10-17T10:00:00.000Z' })
buchung('p1', { startsAt: '2026-10-18T08:00:00.000Z' }) // morgen
buchung('p1', { startsAt: '2026-10-16T21:30:00.000Z' }) // 23:30 Berlin, gestern
buchung('p2', { startsAt: '2026-10-17T07:00:00.000Z' }) // andere Website
const b = await holen()
assert.equal(info(b, 'p1', 'termine'), '3 Termine heute · nächster 10:30')
assert.equal(info(b, 'p2', 'termine'), '1 Termin heute')
})
test('Terminplaner: ein Termin, alle vorbei, keiner', async () => {
funktion('p1', 'termine')
assert.equal(info(await holen(), 'p1', 'termine'), 'Heute keine Termine')
buchung('p1', { startsAt: '2026-10-17T11:00:00.000Z' })
assert.equal(info(await holen(), 'p1', 'termine'), '1 Termin heute · nächster 13:00')
await leere('termineBookings')
buchung('p1', { status: 'erledigt', startsAt: '2026-10-17T05:00:00.000Z' })
buchung('p1', { status: 'erledigt', startsAt: '2026-10-17T06:00:00.000Z' })
assert.equal(info(await holen(), 'p1', 'termine'), '2 Termine heute')
})
test('Terminplaner: der Tag ist der Berliner Tag, auch kurz nach Mitternacht (UTC liegt noch beim Vortag)', async () => {
funktion('p1', 'termine')
einschaltenDeps.jetzt = () => new Date('2026-10-17T22:30:00.000Z') // 00:30 Berlin am 18.10.
buchung('p1', { startsAt: '2026-10-17T21:59:00.000Z' }) // 23:59 am 17.10., nicht heute
buchung('p1', { startsAt: '2026-10-17T22:15:00.000Z' }) // 00:15 am 18.10., zählt, vorbei
buchung('p1', { startsAt: '2026-10-18T06:00:00.000Z' }) // 08:00, nächster
buchung('p1', { startsAt: '2026-10-18T22:00:00.000Z' }) // 00:00 am 19.10., nicht heute
assert.equal(info(await holen(), 'p1', 'termine'), '2 Termine heute · nächster 08:00')
})
/* ------------------------------------------------------------------------------------------ Webshop --- */
test('Webshop: neue (bezahlte, noch nicht bearbeitete) Bestellungen dieses Shops', async () => {
funktion('p1', 'shop'); projekt('p2'); funktion('p2', 'shop')
assert.equal(info(await holen(), 'p1', 'shop'), 'Keine offenen Bestellungen')
bestellung('p1', 'bezahlt'); bestellung('p1', 'bezahlt')
bestellung('p1', 'versendet'); bestellung('p1', 'offen'); bestellung('p1', 'abgeschlossen'); bestellung('p1', 'storniert')
bestellung('p2', 'bezahlt')
let b = await holen()
assert.equal(info(b, 'p1', 'shop'), '2 neue Bestellungen')
assert.equal(info(b, 'p2', 'shop'), '1 neue Bestellung')
})
/* ------------------------------------------------------------------------------------------ News --- */
test('News-Pinnwand: sichtbare Beiträge (nicht abgenommen, begonnen, nicht abgelaufen) dieser Website', async () => {
funktion('p1', 'news'); projekt('p2'); funktion('p2', 'news')
assert.equal(info(await holen(), 'p1', 'news'), 'Keine Beiträge sichtbar')
zettel('p1') // ohne Ende
zettel('p1', { visibleFrom: inStd(-24), visibleUntil: inStd(72) })
zettel('p1', { visibleFrom: inStd(-240), visibleUntil: inStd(1) })
zettel('p1', { visibleFrom: inStd(-120), visibleUntil: inStd(480) })
zettel('p1', { visibleFrom: inStd(-240), visibleUntil: inStd(-1) }) // abgelaufen
zettel('p1', { takenDownAt: inStd(-2) }) // abgenommen
zettel('p1', { visibleFrom: inStd(24) }) // beginnt erst morgen
zettel('p2')
const b = await holen()
assert.equal(info(b, 'p1', 'news'), '4 Beiträge sichtbar')
assert.equal(info(b, 'p2', 'news'), '1 Beitrag sichtbar')
})
/* ------------------------------------------------------------------------------------------ Buchungskalender --- */
test('Buchungskalender: offene Anfragen, sonst die nächste Anreise, sonst keine offenen Anfragen', async () => {
funktion('p1', 'unterkunft'); projekt('p2'); funktion('p2', 'unterkunft')
assert.equal(info(await holen(), 'p1', 'unterkunft'), 'Keine offenen Anfragen')
anfrage('p1', 'gebucht', '2026-11-02', '2026-11-05')
anfrage('p1', 'bezahlt', '2026-10-24', '2026-10-27')
anfrage('p1', 'gebucht', '2026-10-01', '2026-10-05') // vorbei
anfrage('p1', 'gebucht', '2026-10-15', '2026-10-19') // läuft schon: keine Anreise mehr
anfrage('p1', 'freigegeben', '2026-10-20', '2026-10-22') // noch nicht gebucht
anfrage('p1', 'abgelehnt', '2026-10-18', '2026-10-20')
assert.equal(info(await holen(), 'p1', 'unterkunft'), 'Nächste Anreise 24.10.')
anfrage('p1', 'neu', '2026-12-01', '2026-12-03')
anfrage('p2', 'neu', '2026-12-01', '2026-12-03')
anfrage('p2', 'neu', '2026-12-05', '2026-12-07')
const b = await holen()
assert.equal(info(b, 'p1', 'unterkunft'), '1 offene Anfrage')
assert.equal(info(b, 'p2', 'unterkunft'), '2 offene Anfragen')
})
test('Buchungskalender: Anreise heute und morgen werden ausgeschrieben', async () => {
funktion('p1', 'unterkunft')
anfrage('p1', 'gebucht', '2026-10-18', '2026-10-20')
assert.equal(info(await holen(), 'p1', 'unterkunft'), 'Anreise morgen')
anfrage('p1', 'bezahlt', '2026-10-17', '2026-10-19')
assert.equal(info(await holen(), 'p1', 'unterkunft'), 'Anreise heute')
})
/* ------------------------------------------------------------------------------------------ Öffnungszeiten --- */
test('Öffnungszeiten live: der Status der Website von jetzt (ohne Zusatzabfrage), ohne Zeiten ein Hinweis', async () => {
funktion('p1', 'zeiten')
assert.equal(info(await holen(), 'p1', 'zeiten'), 'Noch keine Öffnungszeiten eingetragen')
zeitenDok('p1', zeitenDaten({ 6: ['09:00', '18:00'], 1: ['09:00', '18:00'] }))
resetZeitenCacheForTests()
aw.resetListCalls()
assert.equal(info(await holen(), 'p1', 'zeiten'), 'Jetzt geöffnet · schließt um 18 Uhr')
einschaltenDeps.jetzt = () => new Date('2026-10-17T18:00:00.000Z') // Samstag 20:00
assert.equal(info(await holen(), 'p1', 'zeiten'), 'Geschlossen · öffnet Montag um 9 Uhr')
})
/* ------------------------------------------------------------------------------------------ Regeln --- */
test('nur aktive Funktionen bekommen einen Text und eine Abfrage; alle anderen null', async () => {
for (const [key, state] of [['news', 'setup'], ['termine', 'ordered'], ['shop', 'disabled'], ['unterkunft', 'off'], ['zeiten', 'setup']]) funktion('p1', key, state)
buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' })
bestellung('p1', 'bezahlt')
zettel('p1')
anfrage('p1', 'neu', '2026-12-01', '2026-12-03')
zeitenDok('p1', zeitenDaten({ 6: ['09:00', '18:00'] }))
aw.resetListCalls()
const b = await holen()
for (const key of NAMEN) assert.equal(info(b, 'p1', key), null, key)
for (const key of NAMEN) assert.ok(Object.hasOwn(b.projects[0].features[key], 'kurzinfo'), `${key}: Feld da`)
for (const sammlung of ['termineBookings', 'shopOrders', 'newsPosts', 'ukAnfragen']) assert.equal(abfragen(sammlung).length, 0, sammlung)
})
test('eine kleine feste Zahl schmaler Abfragen je aktiver Funktion: limit gesetzt, nur nötige Felder, nur Filter dieser Funktion', async () => {
alleAktiv('p1')
const gesamt = () => ['termineBookings', 'shopOrders', 'newsPosts', 'ukAnfragen'].map((s) => [s, abfragen(s)])
aw.resetListCalls()
await holen()
const [termine, shop, news, uk] = gesamt().map(([, a]) => a)
assert.equal(termine.length, 1)
assert.equal(shop.length, 1)
assert.equal(news.length, 1)
assert.equal(uk.length, 2)
const hat = (aufruf, methode) => aufruf.queries.find((q) => q.method === methode)
for (const aufruf of [...termine, ...news, ...uk]) {
const limit = hat(aufruf, 'limit')
assert.ok(limit && limit.values[0] <= 200, `limit ${JSON.stringify(limit)}`)
}
assert.deepEqual(hat(shop[0], 'limit').values, [1]) // nur die Anzahl (total)
assert.ok(hat(termine[0], 'select') && hat(news[0], 'select'))
assert.deepEqual(hat(termine[0], 'equal').values, ['f_p1_termine'])
assert.deepEqual(hat(shop[0], 'equal').values, ['f_p1_shop'])
assert.deepEqual(hat(news[0], 'equal').values, ['p1'])
for (const aufruf of uk) assert.deepEqual(hat(aufruf, 'equal').values, ['f_p1_unterkunft'])
assert.equal(aw.listCalls('oeffnungszeiten').length <= 1, true) // Zeiten: höchstens das Lesen für zeitenStatus, nichts zusätzlich
})
test('mehrere Websites: jede bekommt ihre eigene Zeile, die Abfragen laufen für alle gleichzeitig', async () => {
alleAktiv('p1'); projekt('p2'); alleAktiv('p2'); projekt('p3'); funktion('p3', 'termine')
buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' })
buchung('p3', { startsAt: '2026-10-17T07:00:00.000Z' })
buchung('p3', { startsAt: '2026-10-17T13:00:00.000Z' })
const b = await holen()
assert.deepEqual(['p1', 'p2', 'p3'].map((p) => info(b, p, 'termine')).sort(), ['1 Termin heute · nächster 10:30', '2 Termine heute · nächster 15:00', 'Heute keine Termine'].sort())
assert.equal(info(b, 'p3', 'shop'), null)
})
test('Fehler einer Einzelabfrage ergibt null für diese Funktion; der Rest der Liste bleibt', async () => {
alleAktiv('p1')
buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' })
bestellung('p1', 'bezahlt')
zettel('p1')
aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, message: 'geheimer Gästename im Fehlertext' })
const b = await holen()
assert.equal(info(b, 'p1', 'termine'), null)
assert.equal(info(b, 'p1', 'shop'), '1 neue Bestellung')
assert.equal(info(b, 'p1', 'news'), '1 Beitrag sichtbar')
assert.equal(b.modell, 'abo')
assert.ok(Array.isArray(b.katalog))
const log = console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n')
assert.match(log, /Kurzinfo \(termine\)/)
assert.doesNotMatch(log, /geheimer/)
// ein Teilfehler im Buchungskalender (eine der zwei Abfragen) ergibt null, nie eine halbe Aussage
anfrage('p1', 'neu', '2026-12-01', '2026-12-03')
aw.failNext({ collection: 'ukAnfragen', method: 'GET', status: 500 })
assert.equal(info(await holen(), 'p1', 'unterkunft'), null)
})
test('Zeitüberschreitung ergibt null, ohne die Liste aufzuhalten', async () => {
alleAktiv('p1')
bestellung('p1', 'bezahlt'); zettel('p1')
kurzinfoDeps.limitMs = 60
const echt = globalThis.fetch
mock.method(globalThis, 'fetch', async (url, ...rest) => {
if (String(url).includes('/shopOrders/')) await new Promise((r) => setTimeout(r, 600))
return echt(url, ...rest)
})
const start = Date.now()
const b = await holen()
assert.ok(Date.now() - start < 550, `Antwort dauerte ${Date.now() - start} ms`)
assert.equal(info(b, 'p1', 'shop'), null)
assert.equal(info(b, 'p1', 'news'), '1 Beitrag sichtbar')
await new Promise((r) => setTimeout(r, 700)) // die späte Antwort richtet nichts mehr an
})
test('ohne neue Ansicht: keine Kurzinfo in der Antwort, keine einzige Abfrage dafür; sonst ist die neue Antwort die alte plus ergänzte Felder', async () => {
alleAktiv('p1')
buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' }); bestellung('p1', 'bezahlt'); zettel('p1'); anfrage('p1', 'neu', '2026-12-01', '2026-12-03')
config.kundenansichtNeu.mode = 'off'
aw.resetListCalls()
const alt = await holen() // auch mit ?kurzinfo=1 ändert die alte Ansicht nichts
assert.doesNotMatch(JSON.stringify(alt), /kurzinfo/)
for (const sammlung of ['termineBookings', 'shopOrders', 'newsPosts', 'ukAnfragen']) assert.equal(abfragen(sammlung).length, 0, sammlung)
assert.deepEqual(await holenOhne(), alt)
config.kundenansichtNeu.mode = 'pilot'
config.kundenansichtNeu.pilotEmails = ['k2@example.invalid'] // Kunde nicht in der Liste
assert.deepEqual(await holen(), alt)
config.kundenansichtNeu.pilotEmails = ['k@example.invalid']
const neu = await holen()
for (const key of ['modell', 'kundenart', 'abo', 'katalog']) delete neu[key]
for (const p of neu.projects) for (const f of Object.values(p.features)) { delete f.kurzinfo; delete f.anfrageStatus }
assert.deepEqual(neu, alt)
})
/* ------------------------------------------------------------------------------------ nur auf Anfrage --- */
test('Kurzinfo nur auf Anfrage: ohne ?kurzinfo=1 fehlt das Feld überall und es gibt keine einzige Abfrage dafür; der Rest der Antwort ist gleich', async () => {
alleAktiv('p1'); projekt('p2'); funktion('p2', 'termine'); funktion('p2', 'news', 'setup')
buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z' }); bestellung('p1', 'bezahlt'); zettel('p1'); anfrage('p1', 'neu', '2026-12-01', '2026-12-03')
zeitenDok('p1', zeitenDaten({ 6: ['09:00', '18:00'] }))
const sammlungen = ['termineBookings', 'shopOrders', 'newsPosts', 'ukAnfragen']
for (const pfad of ['/api/funktionen', '/api/funktionen?kurzinfo=0', '/api/funktionen?kurzinfo=', '/api/funktionen?kurzinfo=true', '/api/funktionen?kurzinfo=1&kurzinfo=0', '/api/funktionen?kurzinfo[]=1', '/api/funktionen?x=1']) {
aw.resetListCalls()
const ohne = await holenOhne(pfad)
assert.doesNotMatch(JSON.stringify(ohne), /kurzinfo/, pfad)
assert.equal(ohne.modell, 'abo', pfad) // die neue Ansicht bekommt sonst alles wie bisher
for (const sammlung of sammlungen) assert.equal(abfragen(sammlung).length, 0, `${pfad}: ${sammlung}`)
}
aw.resetListCalls()
const mit = await holen()
for (const sammlung of sammlungen) assert.ok(abfragen(sammlung).length > 0, sammlung)
assert.equal(info(mit, 'p1', 'termine'), '1 Termin heute · nächster 10:30')
assert.equal(info(mit, 'p2', 'news'), null) // mit Anfrage haben auch Funktionen ohne Text das Feld (null)
const ohne = await holenOhne()
for (const p of mit.projects) for (const f of Object.values(p.features)) delete f.kurzinfo
assert.deepEqual(ohne, mit)
})
test('die Kurzinfo ist ein kurzer Text ohne Preis, Mail-Adresse oder Namen von Gästen', async () => {
alleAktiv('p1')
buchung('p1', { startsAt: '2026-10-17T08:30:00.000Z', gastName: 'Erika Beispiel', gastEmail: 'erika@example.invalid', preisCent: 4500 })
bestellung('p1', 'bezahlt', { kaeuferEmail: 'kauf@example.invalid', summeCent: 9900 })
zettel('p1', { title: 'Geheimer Titel' })
anfrage('p1', 'neu', '2026-12-01', '2026-12-03', { gast: JSON.stringify({ name: 'Max Gast' }) })
const b = await holen()
const texte = NAMEN.map((k) => info(b, 'p1', k))
for (const t of texte) {
assert.equal(typeof t, 'string')
assert.ok(t.length > 0 && t.length <= 60, t)
assert.doesNotMatch(t, /€|Euro|@|Erika|Max|Geheimer|\d+,\d\d/)
}
})
+178 -3
View File
@@ -558,7 +558,8 @@ test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren,
const terms = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion) const terms = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion)
assert.ok(terms) assert.ok(terms)
assert.ok(standTag >= `${terms[1]}-${terms[2]}-${terms[3]}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`) assert.ok(standTag >= `${terms[1]}-${terms[2]}-${terms[3]}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`)
assert.ok(html.includes('<p class="meta">Stand 07.10.2026</p>'), 'Stand = Tag der Änderung (Abschnitt 6f Buchungskalender)') // Stand = Tag der letzten Änderung der Erklärung; seit dem 17.10.2026 (Abschnitt 4a Reichweitenmessung auf webklar.com) nicht davor (der genaue Tag ist in legal_datenschutz_anfragen gepinnt)
assert.ok(html.includes(`<p class="meta">${stand[0]}</p>`) && standTag >= '2026-10-17', `Stand ${stand[0]} liegt vor der Reichweitenmessung (17.10.2026)`)
}) })
// ---------------------------------------------------------------- Abschnitt 6c und Funktionsbestellung (TP2 F12a) // ---------------------------------------------------------------- Abschnitt 6c und Funktionsbestellung (TP2 F12a)
@@ -603,8 +604,8 @@ test('legal_datenschutz_termine: Abschnitt 6c steht zwischen 6b und 7 und nennt
const titel = [...html.matchAll(/<h2>(\d+)\. ([^<]*)<\/h2>/g)].map((m) => `${m[1]}. ${m[2]}`) const titel = [...html.matchAll(/<h2>(\d+)\. ([^<]*)<\/h2>/g)].map((m) => `${m[1]}. ${m[2]}`)
assert.deepEqual(titel, [ assert.deepEqual(titel, [
'1. Verantwortlicher', '2. Geltungsbereich', '3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit', '4. Cookies und Speicher im Browser', '5. Kontaktaufnahme', '6. Kundenportal', '1. Verantwortlicher', '2. Geltungsbereich', '3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit', '4. Cookies und Speicher im Browser', '5. Kontaktaufnahme', '6. Kundenportal',
'7. Abos und Zahlung über Stripe', '8. E-Mail', '9. Buchhaltung und Rechnungen', '10. Domains unserer Kunden', '11. Interessenten', '12. Empfänger im Überblick', '13. Ihre Rechte', '7. Abos und Zahlung über Stripe', '8. E-Mail', '9. Buchhaltung und Rechnungen', '10. Domains unserer Kunden', '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick', '13. Ihre Rechte',
'14. Pflicht zur Bereitstellung, keine automatisierten Entscheidungen', '15. Änderungen', '14. Pflicht zur Bereitstellung, keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO', '15. Änderungen',
]) ])
}) })
@@ -708,3 +709,177 @@ test('datenschutz_pdf: das PDF der Datenschutzerklärung ist eingecheckt (> 20 K
const skript = read('scripts/build-legal-pdfs.sh') const skript = read('scripts/build-legal-pdfs.sh')
assert.match(skript, /\ndatenschutz\)[\s\S]*\n\s+pdf public\/datenschutz\.html docs\/WEBklar-Datenschutzerklaerung\.pdf\n[\s\S]*?;;/) assert.match(skript, /\ndatenschutz\)[\s\S]*\n\s+pdf public\/datenschutz\.html docs\/WEBklar-Datenschutzerklaerung\.pdf\n[\s\S]*?;;/)
}) })
// ---------------------------------------------------------------- Reichweitenmessung auf webklar.com (eigene Website-Analyse)
test('legal_datenschutz_4a: eigene Website-Analyse auf webklar.com mit Rolle, Rechtsgrundlagen, Fristen und Zusatzseite', () => {
const html = read(DS)
const ds = strip(html)
// Der frühere Satz "keine Analyse-, Tracking- oder Marketing-Werkzeuge" gilt nur noch mit Ausnahme der eigenen Messung
assert.ok(ds.includes('Auf webklar.com messen wir mit unserer eigenen Website-Analyse, wie die Seite genutzt wird (Abschnitt 4a). Darüber hinaus setzen wir auf unseren eigenen Seiten'))
assert.ok(!ds.includes('Derzeit stützen wir keine Verarbeitung auf eine Einwilligung.'))
assert.ok(ds.includes('Auf eine Einwilligung stützen wir derzeit nur die erweiterte Reichweitenmessung auf webklar.com (Abschnitt 4a).'))
assert.ok(html.includes('<tr><td>wk-consent (lokaler Speicher, webklar.com)</td>'))
// 4a steht zwischen 4 und 5
const i4 = html.indexOf('<h2>4. Cookies und Speicher im Browser</h2>')
const i4a = html.indexOf('<h2 id="reichweitenmessung">4a. Reichweitenmessung auf webklar.com</h2>')
const i5 = html.indexOf('<h2>5. Kontaktaufnahme</h2>')
assert.ok(i4 > 0 && i4 < i4a && i4a < i5, 'Reihenfolge 4, 4a, 5')
const block = strip(html.slice(i4a, i5))
for (const s of ['für webklar.com sind wir selbst Verantwortlicher', 'Hetzner Online GmbH in Deutschland', 'ohne Cookies',
'Art. 6 Abs. 1 lit. f DSGVO', '§ 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO', 'Global Privacy Control', 'Keks-Symbol',
'nach 30 Tagen', 'nach 25 Monaten', 'täglich wechselt', 'webklar.com/datenschutz-analyse.html']) assert.ok(block.includes(s), s)
assert.ok(html.includes('<a href="https://webklar.com/datenschutz-analyse.html">'))
// Kontaktformular: seit 17.10.2026 auch per Mail an das eigene Postfach
assert.ok(ds.includes('und schicken sie zugleich per E-Mail an unser Postfach (Abschnitt 8)'))
})
// ---------------------------------------------------------------- Abschnitt 11 Interessenten, Weg B (nur der heute laufende Weg: öffentliche Anfragen)
test('legal_datenschutz_anfragen: Abschnitt 11 beschreibt nur den Anfrage-Weg (KI bewertet, Mitarbeiter sendet von Hand), mit Anker, Quelle, Daten, Rechtsgrundlage, Fristen und Widerspruch', () => {
const html = read(DS)
// Stand-Pin: der Tag der Fassung (das endgültige Datum setzt der Push, dann auch hier)
assert.ok(html.includes('<p class="meta">Stand 22.10.2026</p>'), 'Stand 22.10.2026')
assert.match(html, /<a id="interessenten"><\/a>\s*<h2>11\. Interessenten: Antworten auf öffentliche Anfragen<\/h2>/)
assert.equal(html.split('id="interessenten"').length - 1, 1, 'der Anker steht genau einmal da')
const s = between(strip(html), '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
for (const t of ['Art. 14 DSGVO', 'KI-Assistent', 'Claude von Anthropic', 'von Hand', 'Automatische E-Mails verschicken wir nicht', 'Für E-Mails gilt Abschnitt 8',
// Quellen: Alarm-E-Mails und Websuche; Plattformen mit Bot-Verbot ausgenommen
'Benachrichtigungs-E-Mails', 'Kleinanzeigen, freelancermap und freelance.de', 'Websuche', 'Frage-Foren', 'Die Suche und den Abruf der Seiten führt Anthropic aus',
// feste Ausschlussliste (so wie BOT_VERBOT_DOMAINS im Dienst) statt eines Kriteriums
'Von der Websuche und vom Abruf ausgenommen sind die Plattformen Kleinanzeigen, freelancermap, freelance.de, Facebook, Instagram, LinkedIn, Xing, Reddit, Upwork, Fiverr und MyHammer sowie Seiten, deren Betreiber automatische Abrufe in der Datei robots.txt untersagen',
'nicht nach bestimmten Personen',
// Daten
'Titel des Beitrags', 'Beitragstext (höchstens 4000 Zeichen)', 'den Link', 'Ort und Postleitzahl', 'die Plattform', 'Punktzahl mit kurzer Begründung', 'E-Mail-Adresse aus dem Beitrag',
'Wir lesen Ihren Namen nicht gesondert aus; steht er im Titel oder im Text, speichern wir ihn damit', 'Telefonnummern entfernen wir beim Speichern automatisch', 'Faustregel',
// Rechtsgrundlage, Empfänger
'Art. 6 Abs. 1 lit. f DSGVO', 'Hetzner Online GmbH', 'Anthropic Ireland, Limited', 'als Auftragsverarbeiter', 'USA', 'Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO); eine Kopie erhalten Sie bei uns (support@webklar.com)',
// keine automatisierte Einzelentscheidung: die KI sortiert aus, ein Mitarbeiter entscheidet über jede Antwort
'Die KI sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht', 'Ob und was wir einer passenden Anfrage antworten, entscheidet ein Mitarbeiter (Abschnitt 14)',
// Hinweis am Ende unserer Antwort (der Dienst setzt ihn in Claras Entwurf, src/anfragen/clara.js; die Sätze hier stehen dort wörtlich, test/clara.test.js).
// Ein Mitarbeiter kann den Entwurf bearbeiten: zugesagt ist deshalb nur die Arbeitsanweisung, den Hinweis stehen zu lassen.
'Am Ende unserer Antwort steht ein Hinweis', 'dass eine KI-Assistentin von WEBklar antwortet', 'dass wir die Angaben aus Ihrer Anfrage spätestens 90 Tage nach dem Tag, an dem wir Ihre Anfrage gefunden haben, in unserer Datenbank kürzen, das heißt, Titel, Text, Link und E-Mail-Adresse löschen',
'Wenn Sie keine weitere Nachricht von uns möchten oder der Speicherung widersprechen, antworten Sie uns bitte kurz',
'schreiben wir Ihnen zu dieser Anfrage nicht mehr und kürzen Ihre Daten (bei einem Widerspruch sofort, sonst innerhalb von 30 Tagen)',
'Unsere Mitarbeiter sind angewiesen, diesen Hinweis in jede Antwort aufzunehmen, auch in eine selbst geschriebene, und beim Bearbeiten eines Entwurfs nicht zu entfernen',
// Fristen und Reste: aussortierte Anfragen spätestens am Tag nach dem Fund (Entscheidung E2 vom 22.10.2026, Regel C im Dienst: erster täglicher Kürzlauf nach dem Berliner Kalendertag des Fundes),
// 30 Tage nur noch nach einer Absage, Absage und Widerspruch erst ab dem Lesen unserer Seite
'spätestens 90 Tage nach dem Fund', 'Passt Ihre Anfrage nach der Bewertung der KI nicht zu uns, schreiben wir Ihnen nicht und kürzen die Daten spätestens am Tag nach dem Fund',
'innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben', 'Wir sperren und kürzen sie, sobald wir Ihren Widerspruch lesen', 'pseudonymisierte Prüfwerte (Hash-Werte)', 'Ort, Plattform, Punktzahl, Status',
'jedoch ohne Titel, Text, Link und Kontaktdaten', 'bereinigen die zugehörigen Einträge im Protokoll', 'Abschnitt 5 und, wenn ein Vertrag zustande kommt, die Abschnitte 7 und 9',
// Dauer der Reste (Art. 14 Abs. 2 lit. a: Kriterium)
'so lange auf, wie wir verhindern müssen, dass wir Sie wegen desselben Beitrags oder derselben E-Mail-Adresse (bei einer Firmenadresse: derselben Firma) erneut ansprechen',
// Kopien: Alarm-Postfach = support@webklar.com bei Hetzner (Abschnitt 8), Löschung der Plattform-Mails 90 Tage nach Eingang, Datensicherungen wie im übrigen Text
'gehen in unserem Postfach support@webklar.com bei der Hetzner Online GmbH ein (Abschnitt 8)', 'Unser Dienst liest dort nur den Posteingang',
// ehrlich: er lädt jede neue Mail des gemeinsamen Postfachs kurz (zur Prüfung des Absenders), gespeichert wird nur, was von einer Plattform kommt
'Er lädt dazu jede neue E-Mail dieses Postfachs kurz, um den Absender zu prüfen; übernommen und gespeichert werden nur die Benachrichtigungen der Plattformen, alle anderen E-Mails verwirft er sofort',
'Die Benachrichtigungs-E-Mails der Plattformen löschen wir im Postfach spätestens 90 Tage nach dem Eingang',
'Kürzen wir Daten, bleiben sie in unseren Datensicherungen noch bis zu sieben Tage erhalten und werden dann überschrieben', 'Geschäftsbrief',
// Widerspruch
'Ihr Widerspruchsrecht gegen diese Verarbeitung (Art. 21 DSGVO)', 'Antworten Sie dazu auf unsere Nachricht oder schreiben Sie an support@webklar.com',
'Sobald wir Ihre Nachricht lesen, sperren wir den Beitrag und, soweit wir sie gespeichert haben, Ihre E-Mail-Adresse',
'Wir erkennen Sie dabei nur am Beitrag, an Ihrer E-Mail-Adresse und, bei einer Firmenadresse, an deren Domain', 'Ein neues Gesuch, das Sie später einstellen, ist für uns ein neuer Beitrag, wenn es keine dieser Angaben enthält; sagen Sie uns dann bitte noch einmal Bescheid',
// Prüfwerte auch der Firmen-Domain, Sperre trifft bei einer Firmenadresse die ganze Firma (Register und widerspruch.mjs im Dienst: keys.js, suppress)
'der Domain dieser Adresse, das ist der Teil hinter dem @, zum Beispiel firma.de', 'also nicht bei Anbietern wie gmail.com oder web.de',
'eine andere Adresse derselben Firma noch einmal auftaucht; wir schreiben dann nicht noch einmal und nehmen den Beitrag nicht noch einmal auf',
'Ist es eine Firmenadresse und nicht die eines Anbieters wie gmail.com oder web.de, sperren wir auch deren Domain, also auch andere Adressen derselben Firma',
// Zweck: warum Ort, Plattform, Punktzahl, Status und Zeitpunkte bleiben (der frühere Zweck "Mitarbeiter sehen nach, ob die KI zu Unrecht aussortiert hat" entfällt mit E2)
'Ort, Plattform, Punktzahl, Status und die Zeitpunkte behalten wir für unsere Auswertung, damit unsere Zähler und der zeitliche Verlauf stimmen',
// Foren (Entscheidung E4 vom 22.10.2026): im Forum antworten wir nicht; kein Entwurf; ein Mitarbeiter meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich darum bittet (mit demselben Hinweis).
// Im Dienst: src/anfragen/foren.js, FOREN_ANWEISUNG "Forenbeitrag: nicht im Forum antworten. Nur per E-Mail melden, wenn der Beitrag ausdrücklich um Angebote per E-Mail bittet; sonst Erledigt."
'Eine Ausnahme sind Frage-Foren: Dort antworten wir nicht im Forum (siehe unten)',
'und entwirft eine Antwort (für Beiträge aus Frage-Foren nicht, siehe unten)',
'Beiträge in Frage-Foren (derzeit gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org) beantworten wir nicht im Forum. Die KI entwirft dafür keine Antwort. Ein Mitarbeiter sieht den Beitrag an und meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich um Angebote per E-Mail bittet; unsere Mitarbeiter sind angewiesen, in diese E-Mail am Ende den Hinweis aufzunehmen, der oben beschrieben ist. Bittet der Beitrag nicht darum, melden wir uns nicht',
// die Antwort bei der Plattform: ohne den Satz über öffentlich sichtbare Antworten in Foren (dort antworten wir nicht)
'Die Antwort, die wir auf einer Plattform abschicken, sehen auch deren Betreiber. Dafür gelten die Datenschutzhinweise der Plattform',
// Widerspruch und Absage wirken in der Datenbank; die Plattform-Mail im Postfach bleibt bis zur Löschung nach 90 Tagen (und das steht im Kasten)
'auch wenn wir die Anfrage in der Datenbank schon früher gekürzt haben',
'Die Benachrichtigung der Plattform in unserem Postfach erfasst das Kürzen in der Datenbank nicht; wir löschen sie dort spätestens 90 Tage nach dem Eingang (siehe oben)']) assert.ok(s.includes(t), t)
// der Widerspruch steht im Kasten (box widerspruch) im Abschnitt 11
const kasten = html.slice(html.indexOf('<a id="interessenten">'), html.indexOf('<h2>12. Empfänger im Überblick</h2>'))
assert.ok(kasten.includes('<div class="box widerspruch">'))
// Verständlichkeit: "Fund" ist erklärt, bevor es zum zweiten Mal vorkommt; die Fristen stehen als Liste; kein Absatz in Abschnitt 11 ist länger als 130 Wörter; "robots.txt" ist erklärt
const fund1 = s.indexOf('nach dem Fund')
const erklaert = s.indexOf('das ist der Tag, an dem wir Ihre Anfrage gefunden haben')
assert.ok(fund1 > 0 && erklaert > fund1 && erklaert < s.indexOf('spätestens 90 Tage nach dem Fund;'), '"Fund" wird beim ersten Gebrauch erklärt')
assert.ok(/<p>Wie lange wir die Daten speichern:[^<]*<\/p>\s*<ul>(?:\s*<li>[^<]+<\/li>){4}\s*<\/ul>/.test(kasten), 'die vier Fristen stehen als Liste')
// E2: die Liste nennt in dieser Reihenfolge 90 Tage, den Tag nach dem Fund (aussortiert), 30 Tage nach dem Lesen einer Absage (nur dort 30 Tage) und den Widerspruch
const fristen = [...kasten.matchAll(/<li>([^<]+)<\/li>/g)].map((m) => m[1])
assert.deepEqual(fristen, [
'spätestens 90 Tage nach dem Fund;',
'spätestens am Tag nach dem Fund, wenn Ihre Anfrage nicht zu uns passt;',
'innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben, wenn Sie uns sagen, dass Sie kein Interesse haben;',
'sofort, wenn Sie widersprechen: Wir sperren und kürzen sie, sobald wir Ihren Widerspruch lesen.'])
assert.equal(fristen.filter((f) => f.includes('30 Tagen')).length, 1, '30 Tage nur noch für die Absage')
const absaetze = [...kasten.matchAll(/<p>([\s\S]*?)<\/p>/g)].map((m) => strip(m[1]).split(' ').length)
assert.ok(absaetze.length >= 15 && Math.max(...absaetze) <= 130, `längster Absatz in Abschnitt 11: ${Math.max(...absaetze)} Wörter`)
// die Zusagen und Auskünfte, die der Dienst nicht hält oder die unerklärt Fachsprache waren
for (const t of ['verarbeitet nur diese E-Mails', 'andere E-Mails in diesem Postfach übernimmt er nicht in seine Datenbank', 'Ein eigenes Feld für Ihren Namen gibt es nicht', 'deren robots.txt den Abruf sperrt',
'damit wir den Beitrag nicht noch einmal bewerten', 'Wir erkennen Sie dabei nur am Beitrag und an der E-Mail-Adresse', 'beim Bearbeiten des Entwurfs nicht zu entfernen', 'in unserer Datenbank spätestens 90 Tage nach dem Fund kürzen',
// E2: aussortierte Anfragen bleiben nicht mehr 30 Tage, und der Zweck "nachsehen, ob die KI zu Unrecht aussortiert hat" entfällt
'kürzen die Daten innerhalb von 30 Tagen nach dem Fund', 'innerhalb von 30 Tagen nach dem Fund, wenn Ihre Anfrage nicht zu uns passt', 'Bis dahin speichern wir sie', 'nachsehen können', 'zu Unrecht aussortiert',
// E4: kein Satz über öffentlich sichtbare Antworten in Frage-Foren mehr, denn dort antworten wir nicht
'in einem Frage-Forum ist sie in der Regel für alle Besucher der Seite sichtbar', 'für alle Besucher der Seite sichtbar',
// der Hinweis in der Forum-E-Mail ist eine Anweisung an die Mitarbeiter, keine Tatsache, die der Dienst erzwingt (sie ist im Pult-Eintrag nicht enthalten)
'in diese E-Mail nehmen unsere Mitarbeiter am Ende den Hinweis auf'])
assert.ok(!s.includes(t), `nicht mehr im Text: ${t}`)
// E4: die Forenliste in der Regel für Antworten und in der Quelle ist dieselbe (der Dienst erkennt diese vier Foren, platforms.js FINN_ALLOWED_DOMAINS, jeden Fund von Finn und zusätzlich jedes Forum, dessen Host oder erster Pfad-Abschnitt „forum“ oder „foren“ nennt, istForenfund; der Text verspricht nur „derzeit“ die vier)
assert.equal(s.split('gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org').length - 1, 2, 'die vier Foren stehen in der Quelle und in der Regel für Antworten, beide Male gleich')
// die Regel für Foren steht nach dem Hinweis und vor den Quellen; es gibt keinen Hinweis auf eine Antwort im Forum
assert.ok(s.indexOf('Am Ende unserer Antwort steht ein Hinweis') < s.indexOf('beantworten wir nicht im Forum') && s.indexOf('beantworten wir nicht im Forum') < s.indexOf('Die Gesuche finden wir auf zwei Wegen'))
const imForum = [...s.matchAll(/im Forum/g)]
assert.ok(imForum.length >= 2 && imForum.every((m) => s.slice(m.index - 6, m.index) === 'nicht '), 'jedes "im Forum" in Abschnitt 11 steht hinter "nicht": wir antworten nirgends im Forum')
})
test('legal_datenschutz_anfragen: nichts aus dem Kaltmail-Weg (Stufe 2); keine [OFFEN]-Markierung und kein Platzhalter mehr im Text', () => {
const html = read(DS)
const ds = strip(html)
const s = between(ds, '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
for (const t of ['Gratis-Vorschau', 'höchstens zwei E-Mails', 'höchstens drei Betriebe', 'schreibt und versendet', 'Antworten sortiert', 'Place-ID', 'Impressum', 'OpenStreetMap', 'Overpass', 'Resend', 'Cloudflare',
'Abweichend von Abschnitt 8', 'Abmelde-Link', 'bei Heilberufen', 'Geschäftsdaten in geringem Umfang', 'sechs Monate nach der letzten Nachricht', 'zwölf Monaten', 'Branchenverzeichnisse', 'nur für diese Antwort',
'Postfach bei der Hetzner', 'In allen Fällen bleiben nur']) assert.ok(!s.includes(t), `noch da (Stufe 2 oder zu viel versprochen): ${t}`)
// Resend, Cloudflare und Overpass stehen nirgends in der Erklärung
for (const t of ['Resend', 'Cloudflare', 'Overpass']) assert.ok(!ds.includes(t), `${t} gehört nicht in die Weg-B-Fassung`)
// Beide früher offenen Tatsachen sind geklärt (Alarm-Postfach = support@webklar.com bei Hetzner; Datensicherungen bis zu sieben Tage). Jede [OFFEN]-Markierung macht diesen
// Test rot, auch im Quelltext-Kommentar und auch ohne Span. Zusätzlich bricht scripts/datenschutz-hochladen.mjs vor dem Schreiben ab, solange eine Markierung steht.
assert.ok(!/\[OFFEN/i.test(html), `[OFFEN]-Markierung im Text: ${(html.match(/\[OFFEN[^\]]*\]/gi) ?? []).join(', ')}`)
assert.ok(!html.includes('class="offen"'), 'kein gelber Platzhalter-Span mehr')
// Zusagen, die es nicht gibt: keine "Postfach-Regel" (die Löschung der Plattform-Mails ist eine eigene Technik des Dienstes), keine unbestimmten Sicherungen
for (const t of ['Postfach-Regel', 'eigenes E-Mail-Postfach', 'Datensicherungen unseres Servers', 'noch kurze Zeit']) assert.ok(!ds.includes(t), `nicht mehr im Text: ${t}`)
// keine anderen Platzhalter
assert.ok(!/\[Prüfen|TODO|XXX|\{[a-z]+\}/i.test(strip(html.replace(/<!--[\s\S]*?-->/g, ''))), 'keine anderen Platzhalter im sichtbaren Text')
})
test('legal_datenschutz_anfragen: Zusagen nur in der Reichweite des Dienstes (Sperre nur je Beitrag, E-Mail-Adresse und bei Firmenadressen Domain, Fristen ab dem Lesen), keine zu weiten Aussagen', () => {
const ds = strip(read(DS))
const s = between(ds, '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
// der Dienst sperrt nur denselben Beitrag, dieselbe Adresse (bei Firmenadressen auch die Domain) und erreicht keine Antwort auf einer Plattform: keine Zusage "nicht mehr schreiben" ohne Einschränkung
for (const t of ['schreiben Ihnen nicht mehr', 'melden uns nicht wieder', 'sperren Ihre Daten dann sofort', 'derzeit unter anderem Facebook', 'Plattformen, die automatisierte Zugriffe untersagen', 'entscheidet nicht allein', 'kurze Zeit enthalten',
// der Mitarbeiter kann den Entwurf bearbeiten: keine feste Zusage über den Inhalt der Nachricht
'In unserer ersten Nachricht steht'])
assert.ok(!ds.includes(t), `zu weit: ${t}`)
assert.ok(s.includes('schreiben Ihnen dazu nicht mehr'), 'die Sperre gilt "dazu", also für den Beitrag und die Adresse')
})
test('legal_datenschutz_anfragen: Abschnitt 2, 12 und 14 passen zu Abschnitt 11 und es gibt keinen Widerspruch zu 8', () => {
const ds = strip(read(DS))
const s2 = between(ds, '2. Geltungsbereich', '3. Hosting')
assert.ok(s2.includes('Menschen und Betriebe, die im Netz öffentlich nach einer Website suchen') && s2.includes('Privatpersonen') && s2.includes('Frage-Foren') && s2.includes('(Abschnitt 11)'))
const emp = between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte')
assert.ok(emp.includes('Anthropic Ireland, Limited: Bewertung öffentlicher Anfragen, Entwurf von Antworten sowie Websuche und Seitenabruf durch ein Sprachmodell (Auftragsverarbeiter, Daten auch in den USA, Abschnitt 11)'))
assert.ok(emp.includes('Plattformen, auf denen wir Ihre Anfrage beantworten'))
// das Alarm-Postfach liegt bei Hetzner: kein eigener Anbieter in der Liste, Hetzner nennt es
assert.ok(emp.includes('Hetzner Online GmbH: Hosting, E-Mail (auch das Postfach für die Benachrichtigungen der Plattformen, Abschnitt 11) und Datensicherung (Auftragsverarbeiter)'))
assert.ok(!emp.includes('E-Mail-Postfach für die Benachrichtigungen der Plattformen'), 'kein eigener Listenpunkt für das Alarm-Postfach')
assert.ok(!emp.includes('Betrieben') && !emp.includes('Website-Texte'), 'der Anthropic-Zweck nennt keine Website-Texte von Betrieben mehr')
const s14 = between(ds, '14. Pflicht zur Bereitstellung', '15. Änderungen')
assert.ok(s14.includes('keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO'))
// E4: bei Beiträgen aus Frage-Foren entwirft die KI gar keine Antwort, daher "höchstens"
assert.ok(s14.includes('Die Bewertung öffentlicher Anfragen durch einen KI-Assistenten (Abschnitt 11) sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Für alle anderen bereitet sie höchstens eine Antwort vor; ob und was wir antworten, entscheidet ein Mitarbeiter, der die Antwort von Hand abschickt.'))
assert.ok(!s14.includes('bereitet sie nur eine Antwort vor'))
assert.ok(!ds.includes('keine automatisierten Entscheidungen'), 'die zu weite alte Überschrift ist weg')
// Einleitung nennt die Antwort auf öffentliche Anfragen
assert.ok(ds.includes('oder wenn wir auf Ihre öffentliche Anfrage nach einer Website antworten'))
// Abschnitt 8 (E-Mail über Hetzner) bleibt unverändert gültig, Abschnitt 11 verweist darauf, statt davon abzuweichen
assert.ok(between(ds, '8. E-Mail', '9. Buchhaltung').includes('versenden und empfangen wir über den E-Mail-Dienst der Hetzner Online GmbH'))
})