Compare commits
16
Commits
5cd49d6a4a
...
f3f8ac3777
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3f8ac3777 | ||
|
|
ace4c85e72 | ||
|
|
14dc2ad1bf | ||
|
|
6bfcaa892c | ||
|
|
d7724afc02 | ||
|
|
5f4e158660 | ||
|
|
85808fc115 | ||
|
|
1703dabaaf | ||
|
|
d62f8b805e | ||
|
|
ec79defb2a | ||
|
|
670ead3091 | ||
|
|
9aeb9872be | ||
|
|
db62e320ff | ||
|
|
53a191219e | ||
|
|
2064bddba6 | ||
|
|
f231f4b887 |
No files matched your search
@@ -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.
|
||||
(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
|
||||
|
||||
- **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).
|
||||
- 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).
|
||||
- Die Analyse erscheint im Katalog nur, wenn `ANALYSE_ENABLED === 'on'`.
|
||||
|
||||
|
||||
@@ -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() {
|
||||
if (!aktiv) return
|
||||
if (aktiv.timer) clearInterval(aktiv.timer)
|
||||
try { aktiv.composer?.destroy?.() } catch { /* schon abgebaut */ }
|
||||
try { aktiv.eingabe?.abbau() } catch { /* schon abgebaut */ }
|
||||
try { aktiv.fokus?.abbau() } catch { /* schon abgebaut */ }
|
||||
try { aktiv.beobachter?.disconnect() } catch { /* schon abgebaut */ }
|
||||
aktiv = null
|
||||
}
|
||||
|
||||
export async function zeigeChat(wurzel, { route, me, nav } = {}) {
|
||||
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
|
||||
const gilt = () => aktiv === eigener
|
||||
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)
|
||||
eigener.eingabe = verdrahteEingabe({ win: globalThis.window, doc: globalThis.document, wurzel: composerWurzel })
|
||||
eigener.fokus = haltFokus(composerWurzel)
|
||||
if (typeof globalThis.ResizeObserver === 'function') {
|
||||
eigener.beobachter = new globalThis.ResizeObserver(() => { if (verlauf.dataset.stick !== 'false') verlauf.scrollTop = verlauf.scrollHeight })
|
||||
eigener.beobachter.observe(verlauf)
|
||||
|
||||
@@ -40,30 +40,39 @@ function aboEndeText(f) {
|
||||
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) {
|
||||
if (eintrag.key === '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' }
|
||||
}
|
||||
const f = projekt.features[eintrag.key]
|
||||
switch (f.state) {
|
||||
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':
|
||||
return { pille: { text: 'Zahlung offen', ton: 'orange' }, status: 'Wir warten auf deine Zahlung' }
|
||||
case 'active': {
|
||||
const seit = datumDe(f.activatedAt)
|
||||
return { pille: { text: 'Aktiv', ton: 'gruen' }, status: seit ? `Aktiv seit ${seit}` : 'Läuft auf deiner Website' }
|
||||
}
|
||||
case 'active':
|
||||
return { pille: null, status: kurzinfoVon(f) || 'Aktiv' }
|
||||
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: {
|
||||
if (eintrag.ablauf === 'fragebogen') return { pille: { text: 'Entwurf', ton: 'grau' }, status: 'Fragebogen fortsetzen' }
|
||||
const anfrage = anfrageStatusVon(projekt, eintrag.key)
|
||||
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' }
|
||||
}
|
||||
}
|
||||
@@ -71,8 +80,9 @@ function statusVon(eintrag, projekt) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns {Array<{ key: string, name: string, projectId: string, projektName: string, zustand: string, anfrageStatus: string, pille: { text: string, ton: string }, status: string, zeile2: string }>}
|
||||
* verfuegbar !== true fehlen (C9). pille ist null, solange die Funktion läuft; status ist die Detailzeile (Kurzinfo oder Zustandstext),
|
||||
* 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) {
|
||||
const seiten = websites(daten)
|
||||
@@ -82,16 +92,17 @@ export function meineZeilen(daten) {
|
||||
for (const projekt of seiten) {
|
||||
if (!istInMeine(eintrag, projekt)) continue
|
||||
const { pille, status } = statusVon(eintrag, projekt)
|
||||
const projektName = String(projekt.projectName ?? '')
|
||||
zeilen.push({
|
||||
key: eintrag.key,
|
||||
name: String(eintrag.name ?? ''),
|
||||
projectId: projekt.projectId,
|
||||
projektName: String(projekt.projectName ?? ''),
|
||||
projektName,
|
||||
zustand: String(zustandVon(projekt, eintrag.key) ?? ''),
|
||||
anfrageStatus: eintrag.key === 'analyse' ? '' : anfrageStatusVon(projekt, eintrag.key),
|
||||
pille,
|
||||
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$/
|
||||
|
||||
/** 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. */
|
||||
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 {
|
||||
leeren(wurzel).append(h('p', { class: 'kn-leer' }, 'Wird geladen …'))
|
||||
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 {
|
||||
if (!aktuell()) return
|
||||
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)),
|
||||
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 }) {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { api } from './api.js'
|
||||
import { h, leeren } from './dom.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 { starteInstallQuelle, installAngebot, baueInstallHinweis, istAusgeblendet, merkeAusgeblendet } from './install-hinweis.js'
|
||||
import { HANDY_ABFRAGE } from './leiste.js'
|
||||
|
||||
@@ -85,10 +85,7 @@
|
||||
.kn-projekt-links { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
/* 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) */
|
||||
.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-name { min-width: 0; }
|
||||
.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-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); }
|
||||
/* „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-details { margin: 0; }
|
||||
|
||||
@@ -128,13 +151,16 @@
|
||||
--chat-surface: var(--kn-bg);
|
||||
--chat-line: var(--kn-line-strong);
|
||||
--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-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-messages { display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto; padding: 4px 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; }
|
||||
/* 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 {
|
||||
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;
|
||||
@@ -158,6 +184,7 @@
|
||||
.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: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-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; }
|
||||
@@ -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.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 .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-verlauf { scroll-behavior: auto; }
|
||||
.kn .kn-chat .chat-composer { margin-top: 8px; }
|
||||
@@ -357,6 +384,9 @@
|
||||
.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“ */
|
||||
.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 .kn-trenner { display: none; }
|
||||
.kn-rechnung-offen a { flex: none; font-weight: 600; }
|
||||
@@ -377,7 +407,6 @@
|
||||
.kn-sperrzeile { margin-bottom: 8px; }
|
||||
.kn-bestellen .kn-knopf { width: 100%; min-height: 44px; }
|
||||
.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 .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%; }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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-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-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 --- */
|
||||
.kn-fn-eintrag { display: flex; flex-direction: column; gap: 16px; padding: 24px 0; border-top: 1px solid var(--kn-line, #ebeef1); }
|
||||
|
||||
@@ -173,6 +173,25 @@
|
||||
}
|
||||
.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 ---------- */
|
||||
|
||||
@media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
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']
|
||||
const ANZAHL_AENDERUNGEN = 5
|
||||
const ANZAHL_EINTRAEGE = 4
|
||||
const ANZAHL_AKTIVITAET = 3
|
||||
const TON = { new: 'grau', active: 'blau', waiting: 'orange', review: 'violett', done: 'gruen', paused: 'grau', stopped: 'rot' }
|
||||
|
||||
/** Farbton der Status-Pille zum Ton des Servers. */
|
||||
@@ -136,24 +135,6 @@ export function baueDateien(files) {
|
||||
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. */
|
||||
export async function ladeProjektDetails(projectId, apiFn = api) {
|
||||
const id = encodeURIComponent(projectId)
|
||||
|
||||
@@ -31,6 +31,7 @@ import { anfrageStatusJeProjekt, katalogFuerKunde } from '../services/aboKatalog
|
||||
import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js'
|
||||
import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.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 { 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"
|
||||
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").
|
||||
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([
|
||||
kundenDokument(customerId),
|
||||
aboProdukt(customerId, { now: jetzt }),
|
||||
katalogFuerKunde({ email: req.session.email }),
|
||||
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) {
|
||||
p.features.shop.anfrageStatus = anfrageStatus.get(p.projectId)?.shop ?? ''
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const WURZEL = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
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)
|
||||
const WORTLAUT = []
|
||||
const BESTELLSEITEN = ['ansicht-bestellen', 'ansicht-wechsel', 'ansicht-speicher', 'ansicht-gutschrift', 'bestell-bausteine']
|
||||
|
||||
@@ -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 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 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 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
|
||||
@@ -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', () => {
|
||||
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 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', () => {
|
||||
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]))
|
||||
|
||||
@@ -32,6 +32,22 @@ test('Website: Aktivität fällt aus, die Projekte bleiben', mit({ ...HAUPT, 'GE
|
||||
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"]'))
|
||||
}))
|
||||
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: 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/)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
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'
|
||||
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/)
|
||||
}))
|
||||
|
||||
test('Aktivität: neueste zuerst, drei sichtbar, „Alle anzeigen (n)“ klappt auf; fremde Inhalte bleiben Text', () => mitDom((fx) => {
|
||||
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.')
|
||||
}))
|
||||
// Die Aktivität der Website-Seite (Zeitleiste) prüfen knAuftragsZeitleiste.test.js und knAuftragsZeitleisteDom.test.js.
|
||||
|
||||
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 }] },
|
||||
|
||||
@@ -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), [])
|
||||
})
|
||||
@@ -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'))
|
||||
}))
|
||||
@@ -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)
|
||||
})
|
||||
@@ -15,7 +15,7 @@ function umgebung(fx, { d = daten(), abo, api, post = { feature: { state: 'setup
|
||||
const basis = async (pfad, o = {}) => {
|
||||
u.aufrufe.push([o.method ?? 'GET', pfad])
|
||||
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/abo') { if (abo instanceof Error) throw abo; return abo ?? { subscription: null } }
|
||||
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)
|
||||
|
||||
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) => {
|
||||
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') })] }) })
|
||||
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', { kurzinfo: '3 Termine heute · nächster 10:30' }), shop: feature('off', { anfrageStatus: 'angenommen' }) }), projekt('p2', 'Café', { termine: feature('active') })] }) })
|
||||
await u.zeige(MEINE)
|
||||
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.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[1], 'button'))
|
||||
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) => {
|
||||
const einzel = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') })] }) })
|
||||
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', { kurzinfo: '4 Beiträge sichtbar' }), zeiten: feature('active', { kurzinfo: null }) })] }) })
|
||||
await einzel.zeige(MEINE)
|
||||
assert.match(fx.text(fx.finde(einzel.wurzel, 'li[data-fn]')), /News-Pinnwand.*Läuft auf deiner Website.*Aktiv/)
|
||||
const zwei = umgebung(fx, { d: daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') }), projekt('p2', 'Café')] }) })
|
||||
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', { kurzinfo: '4 Beiträge sichtbar' }) }), projekt('p2', 'Café')] }) })
|
||||
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 --- */
|
||||
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) => {
|
||||
const u = umgebung(fx)
|
||||
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"]'))
|
||||
assert.match(fx.text(u.wurzel), /News-Pinnwand einschalten/)
|
||||
assert.equal(fx.alle(u.wurzel, 'section[data-fn]').length, 0)
|
||||
|
||||
@@ -3,41 +3,68 @@ import assert from 'node:assert/strict'
|
||||
import { meineZeilen, entdeckenEintraege, zeilenWeg, datumDe } from '../public/neu/ansicht-funktionen.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', {
|
||||
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' }),
|
||||
shop: feature('off', { anfrageStatus: 'offen' }),
|
||||
unterkunft: feature('off', { anfrageStatus: 'zurueckgezogen' }),
|
||||
zeiten: feature('disabled', { disabledReason: 'abo_ende', loeschungAm: '2026-11-20' }),
|
||||
})] })
|
||||
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.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.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), ['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.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 faelle = [
|
||||
['news', feature('setup', { ticketWoid: '100201' }), 'In Einrichtung', 'blau', 'Einrichtung läuft · Ticket #100201'],
|
||||
['news', feature('setup'), 'In Einrichtung', 'blau', 'Einrichtung läuft'],
|
||||
['news', feature('setup', { ticketWoid: '100201' }), 'In Einrichtung', 'blau', 'Wir richten sie gerade ein'],
|
||||
['news', feature('setup'), 'In Einrichtung', 'blau', 'Wir richten sie gerade ein'],
|
||||
['news', feature('ordered'), 'Zahlung offen', 'orange', 'Wir warten auf deine Zahlung'],
|
||||
['news', feature('active'), 'Aktiv', 'gruen', 'Läuft auf deiner Website'],
|
||||
['news', feature('disabled'), 'Abgeschaltet', 'grau', 'Schreib uns im Chat, wenn du die Funktion wieder möchtest.'],
|
||||
['news', feature('active'), null, null, 'Aktiv'],
|
||||
['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.'],
|
||||
['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) {
|
||||
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', () => {
|
||||
const d = daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active') }), projekt('p2', 'Café', { termine: feature('setup'), news: feature('active') })] })
|
||||
assert.deepEqual(meineZeilen(d).map((r) => [r.key, r.projectId, r.zeile2]), [['news', 'p2', 'Café'], ['termine', 'p1', 'Bäckerei'], ['termine', 'p2', 'Café']])
|
||||
test('Kurzinfo: nur bei aktiver Funktion, nur als kurzer nicht leerer Text; sonst "Aktiv"', () => {
|
||||
const status = (f) => meineZeilen(daten({ projekte: [projekt('p1', 'B', { news: f })] })).find((r) => r.key === 'news').status
|
||||
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', () => {
|
||||
@@ -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', () => {
|
||||
const zeilen = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).filter((r) => r.key === 'analyse').map((r) => r.pille.text)
|
||||
assert.deepEqual([zeilen({ state: 'active' }), zeilen({ state: 'disabled' }), zeilen({ state: 'off' }), zeilen({ state: 'unknown' }), zeilen({ comingSoon: true })], [['Aktiv'], ['Gesperrt'], [], [], []])
|
||||
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 })], [['ohne Pille'], ['Gesperrt'], [], [], []])
|
||||
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])
|
||||
})
|
||||
@@ -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', () => {
|
||||
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.deepEqual(zeilenWeg(zeile({ state: 'active' })), { art: 'etage', etage: 'analyse' })
|
||||
const kaputt = { modell: 'abo', katalog: [], projects: [{ projectId: 'p1' }] }
|
||||
|
||||
@@ -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/)
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user