Merge kn/meine-pilot (Pilot-Feedback 17.10.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-17 15:43:07 +00:00
commit d7724afc02
8 files changed
+630 -41

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 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).
- Je Projekt und Funktion zusätzlich `kurzinfo: string | null` (Live-Detail der Zeile in „Meine“, nur aktive Funktionen, Fehler → `null`; 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'`.
+25 -14
View File
@@ -40,30 +40,39 @@ function aboEndeText(f) {
return tag ? `Dein Abo ist beendet. Deine Daten bleiben bis ${tag} gespeichert.` : 'Dein Abo ist beendet.'
}
/** 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,
})
}
}
@@ -260,7 +271,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 -1
View File
@@ -50,7 +50,10 @@
.kn-fn-zeile-knopf:hover .kn-fn-name { color: var(--kn-accent, #111); text-decoration: underline; text-decoration-color: var(--kn-line-strong, #d8dde3); text-underline-offset: 3px; }
.kn-fn-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); }
+4 -1
View File
@@ -31,6 +31,7 @@ import { anfrageStatusJeProjekt, katalogFuerKunde } from '../services/aboKatalog
import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js'
import { 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,7 +254,8 @@ 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 je Projekt und Funktion die Kurzinfo (Live-Detail der Zeile in "Meine", Text oder null; jede Abfrage begrenzt und fehlertolerant).
// 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 [kunde, abo, katalog, anfrageStatus] = await Promise.all([
@@ -261,6 +263,7 @@ router.get('/', requireSession, async (req, res) => {
aboProdukt(customerId, { now: jetzt }),
katalogFuerKunde({ email: req.session.email }),
anfrageStatusJeProjekt({ docs, projects }),
ergaenzeKurzinfos({ projects, docs, jetzt }).catch((err) => console.error(`[funktionen] Kurzinfo nicht lesbar: ${err?.name ?? 'Fehler'}`)),
])
for (const p of projects) {
p.features.shop.anfrageStatus = anfrageStatus.get(p.projectId)?.shop ?? ''
+158
View File
@@ -0,0 +1,158 @@
import { config } from '../../config.js'
import { Query, countDocuments, listDocuments } from '../appwriteAdmin.js'
import { isHanging } from '../newsPosts.js'
import { heute, plusTage, uhrzeit, zuUtc } from '../termine/zeit.js'
/**
* Kurzinfo der aktiven Funktionen fuer GET /api/funktionen in der neuen Ansicht: 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)
}
+30 -8
View File
@@ -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,13 +202,24 @@ 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 --- */
+44 -17
View File
@@ -3,41 +3,68 @@ import assert from 'node:assert/strict'
import { meineZeilen, entdeckenEintraege, zeilenWeg, datumDe } from '../public/neu/ansicht-funktionen.js'
import { 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' }] }
+359
View File
@@ -0,0 +1,359 @@
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', { cookie }))
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()
assert.doesNotMatch(JSON.stringify(alt), /kurzinfo/)
for (const sammlung of ['termineBookings', 'shopOrders', 'newsPosts', 'ukAnfragen']) assert.equal(abfragen(sammlung).length, 0, sammlung)
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)
})
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/)
}
})