Terminplaner: Portal-Ansicht – Nachbesserungen (2/2)

- Annehmen mit Rueckfrage in der Seite (schliesst den Vertrag, schickt G02)
- Offene Anfragen: alle bis Ende des Buchungszeitraums (Fenster zu je 62 Tagen,
  hoechstens 6 Aufrufe), passend zur Zahl auf Kachel und Banner
- Meldung ins Bild geholt, Fehler mit Fokus; einzige Live-Region
- Netzwerkfehler auf Deutsch; Antwort einer frueheren Website erscheint nie
  unter der neuen (Zaehler je Aufruf); Datumssprung nur mit echtem Tag
- Ende um Mitternacht als 24:00, Statusfilter nach Eintragen auf Alle,
  abgeschaltet ohne Einstellung mit Export-Link, dunkles Datumsfeld
- Playwright: Zeitzone New York/Berlin, ferne Anfragen, Projektwechsel,
  Meldung im Bild, Datumssprung, abgeschaltet ohne Einstellung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent 9c07f6a8c5
commit 650919b022
10 files changed
+405 -68

No files matched your search

+2
View File
@@ -140,6 +140,8 @@ function analyseTile(projects) {
(termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. */ (termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. */
const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.' const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.'
const termineOf = (project) => project.features?.termine || { state: 'off' } const termineOf = (project) => project.features?.termine || { state: 'off' }
// Doppelt zu anfragenText in termine-view.js: funktionen.js darf dort nichts importieren (die Kachel testet test/funktionenTiles.test.js
// mit einem umgebogenen Import). Aenderungen an beiden Stellen machen.
const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`) const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`)
function pickTermineProject(projects) { function pickTermineProject(projects) {
+15 -6
View File
@@ -24,14 +24,9 @@ export function schliesseFrage(t, fokus = false) {
export function starteAktion(t, li, b, art, knopf) { export function starteAktion(t, li, b, art, knopf) {
t.ctx.meldung('') t.ctx.meldung('')
if (art === 'annehmen') {
schliesseFrage(t)
fuehreAus(t, b, { aktion: 'annehmen' }, b.gastEmail ? `Anfrage angenommen. ${b.gastName} bekommt eine Bestätigung per E-Mail.` : 'Anfrage angenommen.', null, knopf)
return
}
schliesseFrage(t) schliesseFrage(t)
const zu = () => schliesseFrage(t, true) const zu = () => schliesseFrage(t, true)
const fragen = { ablehnen: frageAblehnen, absagen: frageAbsagen, verschieben: frageVerschieben, nichterschienen: frageNichtErschienen, anonymisieren: frageAnonymisieren, entsperren: frageEntsperren } const fragen = { annehmen: frageAnnehmen, ablehnen: frageAblehnen, absagen: frageAbsagen, verschieben: frageVerschieben, nichterschienen: frageNichtErschienen, anonymisieren: frageAnonymisieren, entsperren: frageEntsperren }
const box = fragen[art](t, b, zu) const box = fragen[art](t, b, zu)
li.querySelector('[data-frage]').replaceChildren(box) li.querySelector('[data-frage]').replaceChildren(box)
li.classList.add('tm-offen') li.classList.add('tm-offen')
@@ -80,6 +75,20 @@ function knopfZeile(bestaetigen, zu) {
return { ok, zeile: h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: zu }, 'Abbrechen')) } return { ok, zeile: h('div', { class: 'tm-frage-knoepfe' }, ok, h('button', { type: 'button', class: 'kb-btn-outline', onclick: zu }, 'Abbrechen')) }
} }
// Annehmen schliesst den Vertrag und verschickt die Bestaetigung (G02): wie jede Aktion erst nach einer Rueckfrage in der Seite
function frageAnnehmen(t, b, zu) {
const { ok, zeile } = knopfZeile('Annehmen', zu)
const form = frage('Anfrage annehmen?',
h('p', null, b.gastEmail
? `${b.gastName} bekommt eine Bestätigung per E-Mail. Mit dem Annehmen kommt der Termin zustande.`
: 'Der Gast bekommt keine E-Mail, weil keine Adresse hinterlegt ist. Mit dem Annehmen kommt der Termin zustande.'),
allgemeinerFehler(),
zeile)
const erfolg = b.gastEmail ? `Anfrage angenommen. ${b.gastName} bekommt eine Bestätigung per E-Mail.` : 'Anfrage angenommen.'
form.addEventListener('submit', () => fuehreAus(t, b, { aktion: 'annehmen' }, erfolg, form, ok))
return form
}
function frageAblehnen(t, b, zu) { function frageAblehnen(t, b, zu) {
const nachricht = h('textarea', { rows: '3', maxlength: String(MAX.nachricht) }) const nachricht = h('textarea', { rows: '3', maxlength: String(MAX.nachricht) })
const { ok, zeile } = knopfZeile('Anfrage ablehnen', zu) const { ok, zeile } = knopfZeile('Anfrage ablehnen', zu)
+7 -1
View File
@@ -16,12 +16,18 @@ export function h(tag, attrs, ...kinder) {
} }
export async function request(path, { method = 'GET', body } = {}) { export async function request(path, { method = 'GET', body } = {}) {
const response = await fetch(path, { let response
try {
response = await fetch(path, {
method, method,
credentials: 'same-origin', credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : {}, headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}) })
} catch {
// Netzwerkfehler (offline, Server nicht erreichbar): deutscher Text statt „Failed to fetch“; status 0 = nie eine Antwort bekommen
throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0, fehler: {} })
}
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
if (!response.ok) { if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`)) const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
+2 -2
View File
@@ -2,7 +2,7 @@
Die Formulare kennen den Reiter nicht: sie bekommen ctx (siehe termine.js) und hooks = { startTag, erfolg(tag, text), abbrechen() }. Die Formulare kennen den Reiter nicht: sie bekommen ctx (siehe termine.js) und hooks = { startTag, erfolg(tag, text), abbrechen() }.
erfolg wird nach dem Speichern aufgerufen (der Reiter zeigt dann den Tag und laedt die Liste neu), abbrechen schliesst das Formular. */ erfolg wird nach dem Speichern aufgerufen (der Reiter zeigt dann den Tag und laedt die Liste neu), abbrechen schliesst das Formular. */
import { MAX, heuteBerlin, ressourcenWort, zeitNormal } from './termine-view.js' import { MAX, heuteBerlin, istTag, ressourcenWort, zeitNormal } from './termine-view.js'
import { allgemeinerFehler, feld, h, setzeFehler, zeigeFehler } from './termine-dom.js' import { allgemeinerFehler, feld, h, setzeFehler, zeigeFehler } from './termine-dom.js'
/** Freie Uhrzeiten als Auswahl-Knoepfe: laden() holt sie fuer query() (leistung/ressource/tag oder ohne/tag), wert() ist die gewaehlte. */ /** Freie Uhrzeiten als Auswahl-Knoepfe: laden() holt sie fuer query() (leistung/ressource/tag oder ohne/tag), wert() ist die gewaehlte. */
@@ -19,7 +19,7 @@ export function zeitWahl(ctx, query, { onWahl } = {}) {
knoepfe.replaceChildren() knoepfe.replaceChildren()
fehler.textContent = '' fehler.textContent = ''
const q = query() const q = query()
if (!q || !q.tag) { status.textContent = 'Wähle zuerst einen Tag.'; return } if (!q || !istTag(q.tag)) { status.textContent = 'Wähle zuerst einen Tag.'; return }
status.textContent = 'Freie Zeiten werden geladen …' status.textContent = 'Freie Zeiten werden geladen …'
try { try {
const { zeiten } = await ctx.api.get('/zeiten', q) const { zeiten } = await ctx.api.get('/zeiten', q)
+15 -9
View File
@@ -4,7 +4,7 @@
Rueckfrage stehen in termine-aktionen.js, die Formulare in termine-formulare.js. */ Rueckfrage stehen in termine-aktionen.js, die Formulare in termine-formulare.js. */
import { import {
FILTER, anfragenText, bereichFuer, datumKurz, endeZeit, filterStatus, gruppiereNachTag, heuteBerlin, istSperre, FILTER, anfragenText, bereichFuer, datumKurz, endeZeit, fensterAufteilen, filterStatus, gruppiereNachTag, heuteBerlin, istSperre, istTag,
moeglicheAktionen, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, zeitraumTitel, moeglicheAktionen, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, zeitraumTitel,
} from './termine-view.js' } from './termine-view.js'
import { fehlerKarte, h } from './termine-dom.js' import { fehlerKarte, h } from './termine-dom.js'
@@ -27,8 +27,9 @@ export function render(panel, ctx) {
h('button', { type: 'button', class: 'kb-btn-outline tm-heute', onclick: () => setze(t, { tag: heuteBerlin() }) }, 'Heute'), h('button', { type: 'button', class: 'kb-btn-outline tm-heute', onclick: () => setze(t, { tag: heuteBerlin() }) }, 'Heute'),
h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Weiter', onclick: () => setze(t, { tag: verschiebeTag(t.auswahl.ansicht, s.tag, 1) }) }, h('span', { 'aria-hidden': 'true' }, '›'))) h('button', { type: 'button', class: 'tm-pfeil', 'aria-label': 'Weiter', onclick: () => setze(t, { tag: verschiebeTag(t.auswahl.ansicht, s.tag, 1) }) }, h('span', { 'aria-hidden': 'true' }, '›')))
t.datumFeld = h('input', { type: 'date', id: 'tm-sprung', 'aria-label': 'Zu einem Datum springen' }) t.datumFeld = h('input', { type: 'date', id: 'tm-sprung', 'aria-label': 'Zu einem Datum springen' })
t.datumFeld.addEventListener('change', () => { if (t.datumFeld.value) setze(t, { tag: t.datumFeld.value }) }) // nur ein echter Tag springt an (ein Datumsfeld mit fuenfstelligem Jahr liefert anderes und wuerde die Berechnung der Ansicht festfahren)
t.titel = h('h3', { class: 'tm-zeitraum', 'aria-live': 'polite' }) t.datumFeld.addEventListener('change', () => { if (istTag(t.datumFeld.value)) setze(t, { tag: t.datumFeld.value }) })
t.titel = h('h3', { class: 'tm-zeitraum' })
const filterKnoepfe = FILTER.map((f) => h('button', { type: 'button', class: 'tm-chip', 'data-filter': f.key, onclick: () => setze(t, { filter: f.key }) }, f.label)) const filterKnoepfe = FILTER.map((f) => h('button', { type: 'button', class: 'tm-chip', 'data-filter': f.key, onclick: () => setze(t, { filter: f.key }) }, f.label))
t.filterBox = h('div', { class: 'tm-filter', role: 'group', 'aria-label': 'Nach Status filtern' }, filterKnoepfe) t.filterBox = h('div', { class: 'tm-filter', role: 'group', 'aria-label': 'Nach Status filtern' }, filterKnoepfe)
t.ansichtBox = h('div', { class: 'tm-segment', role: 'group', 'aria-label': 'Ansicht' }, ansichtKnoepfe) t.ansichtBox = h('div', { class: 'tm-segment', role: 'group', 'aria-label': 'Ansicht' }, ansichtKnoepfe)
@@ -36,9 +37,9 @@ export function render(panel, ctx) {
h('button', { type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'manuell', onclick: () => oeffneFormular(t, 'manuell') }, '+ Termin eintragen'), h('button', { type: 'button', class: 'kb-btn-primary', 'data-werkzeug': 'manuell', onclick: () => oeffneFormular(t, 'manuell') }, '+ Termin eintragen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'sperre', onclick: () => oeffneFormular(t, 'sperre') }, 'Zeit sperren')) h('button', { type: 'button', class: 'kb-btn-outline', 'data-werkzeug': 'sperre', onclick: () => oeffneFormular(t, 'sperre') }, 'Zeit sperren'))
t.neuBtn = h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ladeListe(t, { auchZahl: true }) }, 'Aktualisieren') t.neuBtn = h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => ladeListe(t, { auchZahl: true }) }, 'Aktualisieren')
t.anfragenBox = h('div', { class: 'tm-box tm-box-info', role: 'status', hidden: true }) t.anfragenBox = h('div', { class: 'tm-box tm-box-info', hidden: true })
t.formularBox = h('div', { class: 'tm-formular-box' }) t.formularBox = h('div', { class: 'tm-formular-box' })
t.liste = h('div', { class: 'tm-liste', 'aria-live': 'polite' }) t.liste = h('div', { class: 'tm-liste' })
panel.replaceChildren( panel.replaceChildren(
h('div', { class: 'tm-leiste' }, t.ansichtBox, t.nav, h('label', { class: 'tm-sprung', for: 'tm-sprung' }, h('span', null, 'Datum'), t.datumFeld), t.zurueck), h('div', { class: 'tm-leiste' }, t.ansichtBox, t.nav, h('label', { class: 'tm-sprung', for: 'tm-sprung' }, h('span', null, 'Datum'), t.datumFeld), t.zurueck),
@@ -91,16 +92,19 @@ async function ladeListe(t, { auchZahl = false } = {}) {
const { s } = ctx const { s } = ctx
const nr = ++t.nr const nr = ++t.nr
const anfragen = t.auswahl.ansicht === 'anfragen' const anfragen = t.auswahl.ansicht === 'anfragen'
const { von, bis } = bereichFuer(t.auswahl.ansicht, s.tag, heuteBerlin()) // Offene Anfragen: alle bis zum Ende des Buchungszeitraums; die Schnittstelle liefert hoechstens 62 Tage je Aufruf, darum mehrere Fenster
const { von, bis } = bereichFuer(t.auswahl.ansicht, s.tag, heuteBerlin(), ctx.daten.einstellung?.regeln?.horizontTage)
const fenster = anfragen ? fensterAufteilen(von, bis) : [{ von, bis }]
const status = anfragen ? 'angefragt' : filterStatus(t.auswahl.filter) const status = anfragen ? 'angefragt' : filterStatus(t.auswahl.filter)
t.liste.setAttribute('aria-busy', 'true') t.liste.setAttribute('aria-busy', 'true')
try { try {
const [antwort] = await Promise.all([ const [antworten] = await Promise.all([
ctx.api.get('/buchungen', { von, bis, ...(status ? { status } : {}) }), Promise.all(fenster.map((f) => ctx.api.get('/buchungen', { von: f.von, bis: f.bis, ...(status ? { status } : {}) }))),
auchZahl ? ctx.neuLaden().then(() => aktualisiereKopf(t), () => {}) : null, // die Zahl der Anfragen ist Beiwerk: ein Fehler dabei kostet nicht die Liste auchZahl ? ctx.neuLaden().then(() => aktualisiereKopf(t), () => {}) : null, // die Zahl der Anfragen ist Beiwerk: ein Fehler dabei kostet nicht die Liste
]) ])
if (nr !== t.nr || !ctx.aktuell() || !t.liste.isConnected) return if (nr !== t.nr || !ctx.aktuell() || !t.liste.isConnected) return
t.buchungen = antwort.buchungen || [] // ein Termin ueber die Fenstergrenze (Mitternacht) steht in beiden Antworten: nach ID zusammenfuehren
t.buchungen = [...new Map(antworten.flatMap((a) => a.buchungen || []).map((b) => [b.id, b])).values()]
zeichneListe(t) zeichneListe(t)
} catch (err) { } catch (err) {
if (nr !== t.nr || !ctx.aktuell()) return if (nr !== t.nr || !ctx.aktuell()) return
@@ -220,8 +224,10 @@ function schliesseFormular(t) {
async function nachErfolg(t, tag, text) { async function nachErfolg(t, tag, text) {
schliesseFormular(t) schliesseFormular(t)
if (!t.ctx.aktuell()) return
t.ctx.s.tag = tag t.ctx.s.tag = tag
if (t.auswahl.ansicht === 'anfragen') t.auswahl.ansicht = 'tag' if (t.auswahl.ansicht === 'anfragen') t.auswahl.ansicht = 'tag'
t.auswahl.filter = 'alle' // sonst bliebe der neue Eintrag unter einem Statusfilter (z. B. „Angefragt“) unsichtbar
aktualisiereKopf(t) aktualisiereKopf(t)
await ladeListe(t, { auchZahl: true }) await ladeListe(t, { auchZahl: true })
t.ctx.meldung(text) t.ctx.meldung(text)
+38 -7
View File
@@ -40,16 +40,45 @@ export function monatVon(tag) {
} }
/** /**
* Zeitraum fuer die Abfrage der Buchungen. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute, so weit die * Zeitraum der Liste. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute bis zum Ende des Buchungszeitraums horizontTage
* Schnittstelle reicht). * der Einstellung, Vorgabe 60, hoechstens 365). Fuer 'anfragen' ist der Zeitraum laenger als ein Aufruf der Schnittstelle:
* fensterAufteilen macht daraus die einzelnen Abfragen.
*/ */
export function bereichFuer(ansicht, tag, heute) { export function bereichFuer(ansicht, tag, heute, horizontTage) {
if (ansicht === 'woche') return wocheVon(tag) if (ansicht === 'woche') return wocheVon(tag)
if (ansicht === 'monat') return monatVon(tag) if (ansicht === 'monat') return monatVon(tag)
if (ansicht === 'anfragen') return { von: heute, bis: tagPlus(heute, MAX_TAGE) } if (ansicht === 'anfragen') {
const horizont = Number.isInteger(horizontTage) && horizontTage >= 1 ? Math.min(horizontTage, 365) : 60
return { von: heute, bis: tagPlus(heute, horizont) }
}
return { von: tag, bis: tag } return { von: tag, bis: tag }
} }
/**
* Zerlegt von..bis (beide Tage eingeschlossen) in lueckenlose Fenster, die die Schnittstelle einzeln annimmt:
* bis hoechstens 62 Tage nach von. 365 Tage ab heute sind sechs Aufrufe.
*/
export function fensterAufteilen(von, bis) {
const fenster = []
for (let anfang = von; anfang <= bis; anfang = tagPlus(fenster.at(-1).bis, 1)) {
const ende = tagPlus(anfang, MAX_TAGE)
fenster.push({ von: anfang, bis: ende < bis ? ende : bis })
}
return fenster
}
/** Ein echter Kalendertag 'JJJJ-MM-TT' aus den Jahren 2000 bis 2100 (ein Datumsfeld mit fuenfstelligem Jahr liefert anderes). */
export function istTag(text) {
const m = typeof text === 'string' ? TAG_RE.exec(text) : null
if (!m) return false
const jahr = Number(m[1])
const monat = Number(m[2])
const tag = Number(m[3])
if (jahr < 2000 || jahr > 2100) return false
const datum = new Date(Date.UTC(jahr, monat - 1, tag))
return datum.getUTCFullYear() === jahr && datum.getUTCMonth() === monat - 1 && datum.getUTCDate() === tag
}
/** Naechster (richtung 1) oder vorheriger (-1) Zeitraum: Tag +-1, Woche +-7, Monat auf den 1. des Nachbarmonats. */ /** Naechster (richtung 1) oder vorheriger (-1) Zeitraum: Tag +-1, Woche +-7, Monat auf den 1. des Nachbarmonats. */
export function verschiebeTag(ansicht, tag, richtung) { export function verschiebeTag(ansicht, tag, richtung) {
if (ansicht === 'woche') return tagPlus(tag, 7 * richtung) if (ansicht === 'woche') return tagPlus(tag, 7 * richtung)
@@ -100,10 +129,12 @@ export function zeitraumTitel(ansicht, tag) {
const ENDE_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) const ENDE_FORMAT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
/** Ende des Termins als Berliner Wanduhr 'HH:MM' (aus endsAt); '' wenn kein Zeitpunkt. */ /** Ende des Termins als Berliner Wanduhr 'HH:MM' (aus endsAt), Mitternacht als '24:00' (22:00–24:00); '' wenn kein Zeitpunkt. */
export function endeZeit(buchung) { export function endeZeit(buchung) {
const ende = new Date(buchung?.endsAt) const ende = new Date(buchung?.endsAt)
return Number.isNaN(ende.getTime()) ? '' : ENDE_FORMAT.format(ende) if (Number.isNaN(ende.getTime())) return ''
const zeit = ENDE_FORMAT.format(ende)
return zeit === '00:00' ? '24:00' : zeit
} }
/* ------------------------------------------------------------- Uhrzeit --- */ /* ------------------------------------------------------------- Uhrzeit --- */
@@ -182,7 +213,7 @@ export function filterStatus(key) {
return FILTER.find((f) => f.key === key)?.status?.join(',') ?? '' return FILTER.find((f) => f.key === key)?.status?.join(',') ?? ''
} }
/** „3 Anfragen warten“ / „1 Anfrage wartet“; bei 0 oder ohne Zahl ''. */ /** „3 Anfragen warten“ / „1 Anfrage wartet“; bei 0 oder ohne Zahl ''. Gleicher Text steht als anfragenText in funktionen.js (Kachel) – beide gemeinsam aendern. */
export function anfragenText(n) { export function anfragenText(n) {
if (!Number.isInteger(n) || n < 1) return '' if (!Number.isInteger(n) || n < 1) return ''
return n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten` return n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`
+6 -2
View File
@@ -12,8 +12,10 @@
.tm-box[hidden] { display: none; } .tm-box[hidden] { display: none; }
.tm-box-warn { background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); } .tm-box-warn { background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
.tm-box-info { background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line); } .tm-box-info { background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line); }
.tm-meldung { margin: 0 0 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; } .tm-meldung { margin: 0 0 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; scroll-margin-top: calc(var(--kb-appbar-h, 76px) + 12px); }
.tm-meldung[hidden] { display: none; } /* leer bleibt die Live-Region im Dokument (sonst liest ein Screenreader die naechste Meldung nicht vor), nimmt aber keinen Platz */
.tm-meldung:empty { margin: 0; padding: 0; border: 0; }
.tm-meldung:focus { outline: none; }
.tm-meldung-ok { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); } .tm-meldung-ok { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.tm-meldung-fehler { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.5); color: #fecaca; } .tm-meldung-fehler { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.5); color: #fecaca; }
.tm-info p { margin: 0 0 12px; line-height: 1.55; color: var(--kb-muted); } .tm-info p { margin: 0 0 12px; line-height: 1.55; color: var(--kb-muted); }
@@ -43,6 +45,8 @@
} }
.tm-pfeil:hover { background: rgba(255, 255, 255, 0.07); } .tm-pfeil:hover { background: rgba(255, 255, 255, 0.07); }
.tm-sprung { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--kb-muted); } .tm-sprung { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--kb-muted); }
#view-termine input, #view-termine select, #view-termine textarea { color-scheme: dark; } /* Kalender und Auswahllisten der Datumsfelder dunkel */
.tm-sprung input { padding: 8px 10px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; font-size: 14px; }
.tm-zeitraum { margin: 18px 0 10px; font-size: 19px; } .tm-zeitraum { margin: 18px 0 10px; font-size: 19px; }
.tm-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; } .tm-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.tm-chip { .tm-chip {
+54 -30
View File
@@ -22,7 +22,7 @@ export async function initTermine(root, { projectId } = {}) {
delete root.dataset.wunschProjekt delete root.dataset.wunschProjekt
if (wunsch) auswahl.projectId = wunsch if (wunsch) auswahl.projectId = wunsch
const { projects = [], termine } = await request('/api/funktionen') const { projects = [], termine } = await request('/api/funktionen')
const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, buehne: null, meldungEl: null } const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null }
S = s S = s
if (!termine?.verfuegbar) { if (!termine?.verfuegbar) {
root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.')) root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.'))
@@ -56,32 +56,48 @@ function projektwahl(s) {
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select) return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select)
} }
const ladeDaten = (s) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(s.projekt.projectId)}`) const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`)
/** Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. */ /** Hinweis fuer abgeschaltete Funktionen: nur noch ansehen und exportieren. */
const hinweisAbgeschaltet = (projectId) => h('div', { class: 'tm-box tm-box-warn', role: 'note' },
'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ',
h('a', { href: exportUrl(projectId), download: 'termine.csv' }, 'exportieren'),
'.')
/**
* Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort
* einer frueheren Website (langsam, dann zur anderen gewechselt) wird verworfen und erscheint nie unter der neuen.
*/
async function zeigeProjekt(s) { async function zeigeProjekt(s) {
const zustand = s.projekt.features?.termine?.state || 'off' const projekt = s.projekt
const lauf = ++s.lauf
const aktuell = () => S === s && s.lauf === lauf
const zustand = projekt.features?.termine?.state || 'off'
s.nurLesen = zustand !== 'active' s.nurLesen = zustand !== 'active'
if (zustand !== 'active' && zustand !== 'disabled') { if (zustand !== 'active' && zustand !== 'disabled') {
s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true })) s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true }))
return return
} }
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let daten
try { try {
s.daten = await ladeDaten(s) daten = await ladeDaten(projekt.projectId)
} catch (err) { } catch (err) {
if (S === s) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s))) if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s)))
return return
} }
if (S !== s) return if (!aktuell()) return
if (s.daten.einstellung) zeigeReiter(s) s.daten = daten
else if (s.nurLesen) s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website abgeschaltet', 'Es wurden keine Termine eingerichtet.', { chat: true })) if (daten.einstellung) zeigeReiter(s, { projekt, aktuell })
else zeigeVorlagen(s) else if (s.nurLesen) {
s.buehne.replaceChildren(
hinweisAbgeschaltet(projekt.projectId),
infoKarte('Keine Termine eingerichtet', 'Für diese Website wurden keine Einstellungen und Termine angelegt.', { chat: true }))
} else zeigeVorlagen(s, { projekt, aktuell })
} }
/* ---------------------------------------------------- Vorlage wählen --- */ /* ---------------------------------------------------- Vorlage wählen --- */
function zeigeVorlagen(s) { function zeigeVorlagen(s, { projekt, aktuell }) {
const projektId = s.projekt.projectId
const fehler = h('p', { class: 'tm-fehler', role: 'alert' }) const fehler = h('p', { class: 'tm-fehler', role: 'alert' })
const start = h('button', { type: 'button', class: 'kb-btn-primary', disabled: true }, 'Mit dieser Vorlage starten') const start = h('button', { type: 'button', class: 'kb-btn-primary', disabled: true }, 'Mit dieser Vorlage starten')
let gewaehlt = null let gewaehlt = null
@@ -95,10 +111,11 @@ function zeigeVorlagen(s) {
start.disabled = true start.disabled = true
fehler.textContent = '' fehler.textContent = ''
try { try {
await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projektId, vorlage: gewaehlt.key } }) await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projekt.projectId, vorlage: gewaehlt.key } })
s.daten = await ladeDaten(s) const daten = await ladeDaten(projekt.projectId)
if (S !== s) return if (!aktuell()) return
zeigeReiter(s) s.daten = daten
zeigeReiter(s, { projekt, aktuell })
zeigeMeldung(s, `Die Vorlage „${gewaehlt.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`) zeigeMeldung(s, `Die Vorlage „${gewaehlt.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`)
} catch (err) { } catch (err) {
fehler.textContent = err.message fehler.textContent = err.message
@@ -126,27 +143,34 @@ const REITER = [
{ key: 'einstellungen', label: 'Einstellungen', render: platzhalter('Einstellungen', 'Hier ändern wir bald Betriebsangaben, Regeln und Texte für deine Gäste.') }, { key: 'einstellungen', label: 'Einstellungen', render: platzhalter('Einstellungen', 'Hier ändern wir bald Betriebsangaben, Regeln und Texte für deine Gäste.') },
] ]
/** Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. */ /**
* Meldung ueber der Reiterleiste: art 'ok' (Standard) oder 'fehler'; leerer Text blendet sie aus. Die Meldung ist die einzige Live-Region
* der Ansicht. Sie erscheint dort, wo man hinschaut: liegt sie ausserhalb des Bildes, wird sie hingescrollt (block: 'nearest'), eine
* Fehlermeldung bekommt zusaetzlich den Fokus.
*/
function zeigeMeldung(s, text, art = 'ok') { function zeigeMeldung(s, text, art = 'ok') {
const el = s.meldungEl const el = s.meldungEl
if (!el) return if (!el) return
el.textContent = text || '' el.textContent = text || ''
el.hidden = !text
el.className = `tm-meldung tm-meldung-${art}` el.className = `tm-meldung tm-meldung-${art}`
el.setAttribute('role', art === 'fehler' ? 'alert' : 'status') el.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
if (!text) return
if (art === 'fehler') el.focus({ preventScroll: true })
el.scrollIntoView({ block: 'nearest' })
} }
function zeigeReiter(s) { function zeigeReiter(s, { projekt, aktuell }) {
const projectId = s.projekt.projectId const projectId = projekt.projectId
const nurLesen = s.nurLesen const nurLesen = s.nurLesen
s.meldungEl = h('div', { class: 'tm-meldung', hidden: true }) s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
const ctx = { const ctx = {
s, s,
projectId, projectId,
daten: s.daten, daten: s.daten,
nurLesen, nurLesen,
auswahl, auswahl,
aktuell: () => S === s, aktuell,
api: { api: {
get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`), get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
send: (method, pfad, body = {}) => (method === 'DELETE' send: (method, pfad, body = {}) => (method === 'DELETE'
@@ -156,18 +180,18 @@ function zeigeReiter(s) {
meldung: (text, art) => zeigeMeldung(s, text, art), meldung: (text, art) => zeigeMeldung(s, text, art),
zumReiter: (key) => waehleReiter(key), zumReiter: (key) => waehleReiter(key),
neuLaden: async () => { neuLaden: async () => {
s.daten = await ladeDaten(s) const daten = await ladeDaten(projectId)
ctx.daten = s.daten if (aktuell()) {
return s.daten s.daten = daten
ctx.daten = daten
}
return daten
}, },
} }
const hinweise = [] const hinweise = []
if (nurLesen) { if (nurLesen) {
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' }, hinweise.push(hinweisAbgeschaltet(projectId))
'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ',
h('a', { href: exportUrl(projectId), download: 'termine.csv' }, 'exportieren'),
'.'))
} else if ((s.daten.fehlt || []).length) { } else if ((s.daten.fehlt || []).length) {
hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' }, hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' },
h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`), h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`),
@@ -202,7 +226,7 @@ function zeigeReiter(s) {
panel.replaceChildren() panel.replaceChildren()
Promise.resolve() Promise.resolve()
.then(() => reiter.render(panel, ctx)) .then(() => reiter.render(panel, ctx))
.catch((err) => { if (S === s) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) }) .catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
} }
s.buehne.replaceChildren(...hinweise, s.meldungEl, leiste, panel) s.buehne.replaceChildren(...hinweise, s.meldungEl, leiste, panel)
+51 -3
View File
@@ -3,8 +3,9 @@ import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs' import { readFileSync, readdirSync } from 'node:fs'
import { import {
FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag, FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag,
fehltText, heuteBerlin, istSperre, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel, fehltText, fensterAufteilen, heuteBerlin, istSperre, istTag, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel,
} from '../public/termine-view.js' } from '../public/termine-view.js'
import { exportUrl, request } from '../public/termine-dom.js'
// Reine Helfer der Portal-Ansicht Terminplaner (public/termine-view.js): Tage sind Berliner Kalendertage 'JJJJ-MM-TT', gerechnet wird // Reine Helfer der Portal-Ansicht Terminplaner (public/termine-view.js): Tage sind Berliner Kalendertage 'JJJJ-MM-TT', gerechnet wird
// nur mit dem Kalender (kein Browser-Datum), die Uhrzeit des Endes kommt aus endsAt in Berliner Zeit. // nur mit dem Kalender (kein Browser-Datum), die Uhrzeit des Endes kommt aus endsAt in Berliner Zeit.
@@ -80,7 +81,11 @@ test('bereichFuer und verschiebeTag je Ansicht', () => {
assert.deepEqual(bereichFuer('tag', '2026-10-07', '2026-10-05'), { von: '2026-10-07', bis: '2026-10-07' }) assert.deepEqual(bereichFuer('tag', '2026-10-07', '2026-10-05'), { von: '2026-10-07', bis: '2026-10-07' })
assert.deepEqual(bereichFuer('woche', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' }) assert.deepEqual(bereichFuer('woche', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-10-11' })
assert.deepEqual(bereichFuer('monat', '2026-10-07', '2026-10-05'), { von: '2026-10-01', bis: '2026-10-31' }) assert.deepEqual(bereichFuer('monat', '2026-10-07', '2026-10-05'), { von: '2026-10-01', bis: '2026-10-31' })
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-12-06' }, 'ab heute, 62 Tage (Grenze der Schnittstelle)') assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05'), { von: '2026-10-05', bis: '2026-12-04' }, 'ab heute bis zum Ende des Buchungszeitraums (Vorgabe 60 Tage)')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 180), { von: '2026-10-05', bis: '2027-04-03' }, 'Buchungszeitraum der Einstellung')
assert.deepEqual(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 365), { von: '2026-10-05', bis: '2027-10-05' })
assert.equal(bereichFuer('anfragen', '2026-10-07', '2026-10-05', 9999).bis, '2027-10-05', 'höchstens 365 Tage')
for (const kaputt of [0, -5, NaN, 'x', null, undefined]) assert.equal(bereichFuer('anfragen', '2026-10-07', '2026-10-05', kaputt).bis, '2026-12-04', `ungültiger Zeitraum ${String(kaputt)} ⇒ Vorgabe`)
assert.equal(verschiebeTag('tag', '2026-10-31', 1), '2026-11-01') assert.equal(verschiebeTag('tag', '2026-10-31', 1), '2026-11-01')
assert.equal(verschiebeTag('tag', '2026-10-01', -1), '2026-09-30') assert.equal(verschiebeTag('tag', '2026-10-01', -1), '2026-09-30')
assert.equal(verschiebeTag('woche', '2026-10-05', 1), '2026-10-12') assert.equal(verschiebeTag('woche', '2026-10-05', 1), '2026-10-12')
@@ -90,6 +95,29 @@ test('bereichFuer und verschiebeTag je Ansicht', () => {
assert.equal(verschiebeTag('monat', '2026-12-31', 1), '2027-01-01') assert.equal(verschiebeTag('monat', '2026-12-31', 1), '2027-01-01')
}) })
test('fensterAufteilen: lückenlose Abfragefenster von höchstens 62 Tagen nach dem Beginn (Grenze der Schnittstelle)', () => {
assert.deepEqual(fensterAufteilen('2026-10-05', '2026-10-05'), [{ von: '2026-10-05', bis: '2026-10-05' }])
assert.deepEqual(fensterAufteilen('2026-10-05', '2026-12-04'), [{ von: '2026-10-05', bis: '2026-12-04' }], '60 Tage: ein Aufruf')
assert.deepEqual(fensterAufteilen('2026-10-05', '2026-12-06'), [{ von: '2026-10-05', bis: '2026-12-06' }], 'genau 62 Tage nach dem Beginn: noch ein Aufruf')
assert.deepEqual(fensterAufteilen('2026-10-05', '2026-12-07'), [{ von: '2026-10-05', bis: '2026-12-06' }, { von: '2026-12-07', bis: '2026-12-07' }])
const jahr = fensterAufteilen('2026-10-05', '2027-10-05')
assert.equal(jahr.length, 6, '365 Tage: höchstens 6 Aufrufe')
assert.equal(jahr[0].von, '2026-10-05')
assert.equal(jahr.at(-1).bis, '2027-10-05')
for (let i = 0; i < jahr.length; i += 1) {
assert.ok(jahr[i].bis <= tagPlus(jahr[i].von, 62), `Fenster ${i} nicht länger als 62 Tage`)
if (i > 0) assert.equal(jahr[i].von, tagPlus(jahr[i - 1].bis, 1), `Fenster ${i} schließt ohne Lücke und Überlappung an`)
}
assert.deepEqual(fensterAufteilen('2026-10-05', '2026-10-01'), [], 'Ende vor Beginn')
})
test('istTag: nur echte Kalendertage aus einem vernünftigen Jahr (Datumsfeld mit 5-stelligem Jahr darf nichts auslösen)', () => {
for (const gut of ['2026-10-05', '2028-02-29', '2000-01-01', '2100-12-31']) assert.equal(istTag(gut), true, gut)
for (const schlecht of ['12345-01-01', '+12345-01-01', '2026-02-30', '2026-13-01', '2026-00-10', '2027-02-29', '0001-01-01', '1999-12-31', '2101-01-01', '2026-1-5', '', 'abc', null, undefined, 20261005]) {
assert.equal(istTag(schlecht), false, String(schlecht))
}
})
test('Daten: datumLang, datumKurz, tagUeberschrift, zeitraumTitel', () => { test('Daten: datumLang, datumKurz, tagUeberschrift, zeitraumTitel', () => {
assert.equal(datumLang('2026-10-05'), 'Montag, 5. Oktober 2026') assert.equal(datumLang('2026-10-05'), 'Montag, 5. Oktober 2026')
assert.equal(datumLang('2026-03-01'), 'Sonntag, 1. März 2026') assert.equal(datumLang('2026-03-01'), 'Sonntag, 1. März 2026')
@@ -117,7 +145,8 @@ test('heuteBerlin: der Berliner Kalendertag, nicht der des Browsers', () => {
test('endeZeit: Ende des Termins in Berliner Wanduhr aus endsAt', () => { test('endeZeit: Ende des Termins in Berliner Wanduhr aus endsAt', () => {
assert.equal(endeZeit({ endsAt: '2026-10-05T08:45:00.000Z' }), '10:45') assert.equal(endeZeit({ endsAt: '2026-10-05T08:45:00.000Z' }), '10:45')
assert.equal(endeZeit({ endsAt: '2026-12-01T08:45:00.000Z' }), '09:45', 'Winterzeit') assert.equal(endeZeit({ endsAt: '2026-12-01T08:45:00.000Z' }), '09:45', 'Winterzeit')
assert.equal(endeZeit({ endsAt: '2026-10-05T22:00:00.000Z' }), '00:00') assert.equal(endeZeit({ endsAt: '2026-10-05T22:00:00.000Z' }), '24:00', 'Ende um Mitternacht ist 24:00, nicht 00:00 (22:00–24:00)')
assert.equal(endeZeit({ endsAt: '2026-10-05T22:05:00.000Z' }), '00:05', 'nach Mitternacht bleibt es 00:05')
assert.equal(endeZeit({ endsAt: 'kaputt' }), '') assert.equal(endeZeit({ endsAt: 'kaputt' }), '')
assert.equal(endeZeit({}), '') assert.equal(endeZeit({}), '')
}) })
@@ -258,3 +287,22 @@ test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kenn
assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`) assert.doesNotMatch(lies(`public/${datei}`), /from '\.\/termine\.js'/, `${datei} importiert die Huelle nicht`)
} }
}) })
test('request: Netzwerkfehler auf Deutsch, Fehler der Schnittstelle mit Feldern und Code', async () => {
const vorher = globalThis.fetch
try {
globalThis.fetch = async () => { throw new TypeError('Failed to fetch') }
await assert.rejects(request('/api/termine/buchungen'), (err) => err.message === 'Keine Verbindung. Bitte versuch es gleich noch einmal.' && err.status === 0 && !/fetch/i.test(err.message))
globalThis.fetch = async () => ({ ok: false, status: 400, json: async () => ({ error: 'Bitte einen Namen angeben.', fehler: { name: 'Bitte einen Namen angeben.' }, code: 'x' }) })
await assert.rejects(request('/api/termine/buchungen'), (err) => err.status === 400 && err.fehler.name === 'Bitte einen Namen angeben.' && err.code === 'x')
globalThis.fetch = async () => ({ ok: false, status: 502, json: async () => { throw new Error('kein JSON') } })
await assert.rejects(request('/x'), /Server nicht erreichbar/)
} finally {
globalThis.fetch = vorher
}
})
test('exportUrl: Projekt-ID wird codiert', () => {
assert.equal(exportUrl('p1'), '/api/termine/export.csv?projectId=p1')
assert.equal(exportUrl('a&b=c d'), '/api/termine/export.csv?projectId=a%26b%3Dc%20d')
})
+215 -8
View File
@@ -49,14 +49,14 @@ async function shot(page, name) {
} }
const knopf = (loc, name) => loc.getByRole('button', { name }) // Teiltreffer: Knoepfe in Zeilen tragen den Kontext im aria-label const knopf = (loc, name) => loc.getByRole('button', { name }) // Teiltreffer: Knoepfe in Zeilen tragen den Kontext im aria-label
const reihe = (page, text) => page.locator('li.tm-eintrag', { hasText: text }) const reihe = (page, text) => page.locator('li.tm-eintrag', { hasText: text })
const meldung = (page) => page.locator('.tm-meldung:not([hidden])') const meldung = (page) => page.locator('.tm-meldung:not(:empty)')
const pillen = (page) => page.locator('.tm-pille').allInnerTexts() const pillen = (page) => page.locator('.tm-pille').allInnerTexts()
const textVon = (loc) => loc.innerText() const textVon = (loc) => loc.innerText()
/** Kein Streifen rechts: die Seite passt in die Breite. */ /** Kein Streifen rechts: die Seite passt in die Breite. */
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
// Berliner Wanduhr im Oktober 2026 (MESZ, bis 25.10.) als ISO-Zeit // Berliner Wanduhr als ISO-Zeit: Sommerzeit (+02:00) bis 25.10.2026 und ab 28.03.2027, dazwischen +01:00
const utc = (tag, zeit) => new Date(`${tag}T${zeit}:00+02:00`).toISOString() const utc = (tag, zeit) => new Date(`${tag}T${zeit}:00${tag >= '2026-10-25' && tag < '2027-03-28' ? '+01:00' : '+02:00'}`).toISOString()
/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/funktionen.ui.mjs). */ /** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/funktionen.ui.mjs). */
function portalHilfe(p, json) { function portalHilfe(p, json) {
@@ -128,7 +128,13 @@ async function fixtureAntwort(fx, route, req, url, json) {
const q = url.searchParams const q = url.searchParams
const m = req.method() const m = req.method()
if (p === '/api/funktionen') return json({ projects: fx.projekte, termine: { verfuegbar: fx.verfuegbar } }) if (p === '/api/funktionen') return json({ projects: fx.projekte, termine: { verfuegbar: fx.verfuegbar } })
if (p === '/api/termine/einstellungen' && m === 'GET') return json(fx.daten()) if (p === '/api/termine/einstellungen' && m === 'GET') {
const projekt = q.get('projectId')
if (fx.verzoegerung?.[projekt]) await new Promise((fertig) => { setTimeout(fertig, fx.verzoegerung[projekt]) })
const daten = fx.daten()
if (fx.ohneEinstellungFuer?.includes(projekt)) Object.assign(daten, { einstellung: null, version: 0 })
return json(daten)
}
if (p === '/api/termine/einstellungen/vorlage' && m === 'POST') { if (p === '/api/termine/einstellungen/vorlage' && m === 'POST') {
fx.aufrufe.push(['vorlage', req.postDataJSON()]) fx.aufrufe.push(['vorlage', req.postDataJSON()])
fx.einstellung = srv.einstellung() fx.einstellung = srv.einstellung()
@@ -146,6 +152,7 @@ async function fixtureAntwort(fx, route, req, url, json) {
const body = req.postDataJSON() const body = req.postDataJSON()
fx.aufrufe.push(['aktion', aktion[1], body]) fx.aufrufe.push(['aktion', aktion[1], body])
const b = fx.buchungen.find((x) => x.id === aktion[1]) const b = fx.buchungen.find((x) => x.id === aktion[1])
if (b.gastName === 'Konflikt Test') return json({ error: 'Dieser Termin ist nicht mehr offen.', code: 'nicht_moeglich', fehler: { _: 'Dieser Termin ist nicht mehr offen.' } }, 409)
if (body.aktion === 'annehmen') { b.status = 'bestaetigt'; b.angenommenAt = JETZT.toISOString() } if (body.aktion === 'annehmen') { b.status = 'bestaetigt'; b.angenommenAt = JETZT.toISOString() }
if (body.aktion === 'absagen') Object.assign(b, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: body.grund }) if (body.aktion === 'absagen') Object.assign(b, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: body.grund })
return json({ status: b.status }) return json({ status: b.status })
@@ -163,11 +170,11 @@ const hosts = new Set()
* Eine Pruefung in eigenem Browser-Kontext. optionen: viewport/context (Playwright), fixture (Objekt aus neueFixture() ⇒ alle * Eine Pruefung in eigenem Browser-Kontext. optionen: viewport/context (Playwright), fixture (Objekt aus neueFixture() ⇒ alle
* Schnittstellen aus der Fixture; sonst die echten Router mit Portal-Cookie), reset (Optionen fuer srv.zuruecksetzen). * Schnittstellen aus der Fixture; sonst die echten Router mit Portal-Cookie), reset (Optionen fuer srv.zuruecksetzen).
*/ */
async function check(name, fn, { context = {}, fixture = null, reset = {} } = {}) { async function check(name, fn, { context = {}, fixture = null, reset = {}, jetzt = JETZT } = {}) {
if (process.env.NUR && !name.includes(process.env.NUR)) return if (process.env.NUR && !name.includes(process.env.NUR)) return
await srv.zuruecksetzen(reset) await srv.zuruecksetzen(reset)
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context }) const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
await ctx.clock.setFixedTime(JETZT) await ctx.clock.setFixedTime(jetzt)
if (!fixture) await ctx.addCookies([await srv.portalCookie()]) if (!fixture) await ctx.addCookies([await srv.portalCookie()])
const page = await ctx.newPage() const page = await ctx.newPage()
page.fx = fixture page.fx = fixture
@@ -236,6 +243,7 @@ async function buche(o = {}) {
} }
const gleichZeit = (a, b) => new Date(a).getTime() === new Date(b).getTime() // der Nachbau speichert Datumswerte als +00:00 const gleichZeit = (a, b) => new Date(a).getTime() === new Date(b).getTime() // der Nachbau speichert Datumswerte als +00:00
const tagNach = (tag) => new Date(Date.parse(`${tag}T00:00:00Z`) + 86400000).toISOString().slice(0, 10)
const mailsAn = (adresse) => srv.mails().filter((m) => m.to.some((t) => t.address === adresse)) const mailsAn = (adresse) => srv.mails().filter((m) => m.to.some((t) => t.address === adresse))
const buchungMit = (id) => srv.buchungen().find((b) => b.$id === id) const buchungMit = (id) => srv.buchungen().find((b) => b.$id === id)
@@ -488,7 +496,7 @@ async function pruefungen() {
await knopf(banner, 'Anfragen ansehen').click() await knopf(banner, 'Anfragen ansehen').click()
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 1) // die Liste der Tagesansicht steht noch bis die neue da ist await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 1) // die Liste der Tagesansicht steht noch bis die neue da ist
eq(await textVon(page.locator('.tm-zeitraum')), 'Offene Anfragen', 'Titel Anfragen') eq(await textVon(page.locator('.tm-zeitraum')), 'Offene Anfragen', 'Titel Anfragen')
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-12-06', status: 'angefragt' }, 'Abfrage: ab heute, 62 Tage, nur Anfragen') eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-12-04', status: 'angefragt' }, 'Abfrage: ab heute bis Ende des Buchungszeitraums (60 Tage), nur Anfragen')
eq(await page.locator('li.tm-eintrag').count(), 1, 'nur die Anfrage') eq(await page.locator('li.tm-eintrag').count(), 1, 'nur die Anfrage')
const zeile = reihe(page, 'Lea Braun') const zeile = reihe(page, 'Lea Braun')
// Details zeigen Kontakt und Nachricht als Text // Details zeigen Kontakt und Nachricht als Text
@@ -496,7 +504,20 @@ async function pruefungen() {
const details = await textVon(zeile.locator('dl')) const details = await textVon(zeile.locator('dl'))
assert(details.includes('max@example.com') && details.includes('Hochzeit am 24.10., bitte mit Probetermin.') && details.includes('90 Minuten'), `Details: ${details}`) assert(details.includes('max@example.com') && details.includes('Hochzeit am 24.10., bitte mit Probetermin.') && details.includes('90 Minuten'), `Details: ${details}`)
await shot(page, 'anfrage-offen') await shot(page, 'anfrage-offen')
await knopf(zeile, 'Annehmen').click() // Annehmen schliesst den Vertrag und schickt die Bestaetigung: erst eine Rueckfrage in der Seite
await knopf(zeile, 'Annehmen: Brautfrisur').click()
const frage = zeile.locator('.tm-frage-box')
await frage.waitFor()
const ft = await textVon(frage)
assert(ft.includes('Anfrage annehmen?') && ft.includes('Lea Braun bekommt eine Bestätigung per E-Mail.'), `Rückfrage: ${ft}`)
eq(await page.evaluate(() => document.activeElement === document.querySelector('.tm-frage-box')), true, 'Fokus auf der Rückfrage, Enter bestätigt nichts')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 0, 'vor der Rückfrage nichts gesendet')
await shot(page, 'anfrage-annehmen-frage')
await knopf(frage, 'Abbrechen').click()
eq(await zeile.locator('.tm-frage-box').count(), 0, 'Abbrechen schließt die Rückfrage')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 0, 'nach Abbrechen nichts gesendet')
await knopf(zeile, 'Annehmen: Brautfrisur').click()
await zeile.locator('.tm-frage-box').getByRole('button', { name: 'Annehmen', exact: true }).click()
await meldung(page).waitFor() await meldung(page).waitFor()
assert((await textVon(meldung(page))).startsWith('Anfrage angenommen.'), 'Meldung nach dem Annehmen') assert((await textVon(meldung(page))).startsWith('Anfrage angenommen.'), 'Meldung nach dem Annehmen')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion'), [['aktion', 'banfrage', { projectId: 'p1', aktion: 'annehmen' }]], 'POST annehmen') eq(fx.aufrufe.filter((a) => a[0] === 'aktion'), [['aktion', 'banfrage', { projectId: 'p1', aktion: 'annehmen' }]], 'POST annehmen')
@@ -594,6 +615,151 @@ async function pruefungen() {
eq(await page.evaluate(() => document.body.dataset.kbView), 'funktionen', 'zurueck in Funktionen') eq(await page.evaluate(() => document.body.dataset.kbView), 'funktionen', 'zurueck in Funktionen')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }), context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } }) }, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }), context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ------------------------------------------------------------ Offene Anfragen: alle, auch weit in der Zukunft
await check('fx_anfragen_ueber_mehrere_fenster', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
const banner = page.locator('.tm-box-info')
await banner.getByText('3 Anfragen warten').waitFor()
await knopf(banner, 'Anfragen ansehen').click()
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 3)
// Zahl und Liste stimmen ueberein; die ferne Anfrage (107 Tage) steht dabei
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['banfrage', 'bfern1', 'bfern2'], 'alle drei Anfragen in Zeitfolge')
assert((await textVon(reihe(page, 'Fern Eins'))).includes('Angefragt'), 'ferne Anfrage mit Status')
// Abfragen: lueckenlose Fenster ab heute bis heute + Buchungszeitraum (180 Tage), jedes hoechstens 62 Tage nach seinem Beginn, nur Anfragen
const tageZwischen = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86400000)
const fenster = fx.aufrufe.filter((a) => a[0] === 'liste' && a[1].status === 'angefragt').map((a) => a[1]).sort((a, b) => (a.von < b.von ? -1 : 1))
eq(fenster.length, 3, 'drei Aufrufe')
eq(fenster[0].von, '2026-10-05', 'beginnt heute')
eq(fenster.at(-1).bis, '2027-04-03', 'endet nach 180 Tagen')
fenster.forEach((f, i) => {
assert(tageZwischen(f.von, f.bis) <= 62, `Fenster ${i} zu lang: ${f.von}..${f.bis}`)
if (i > 0) eq(tageZwischen(fenster[i - 1].bis, f.von), 1, `Fenster ${i} schließt lückenlos an`)
})
await shot(page, 'anfragen-fern')
}, {
fixture: (() => {
const fx = neueFixture()
fx.einstellung.regeln.horizontTage = 180
fx.buchungen = [
buchung('banfrage', '2026-10-05', '15:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Lea Braun' }),
buchung('bfern1', '2027-01-20', '09:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Fern Eins' }),
buchung('bfern2', '2027-03-10', '14:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Fern Zwei' }),
]
return fx
})(),
})
// ------------------------------------------------------------ Website wechseln, Antwort der alten kommt spaet
await check('fx_projektwechsel_antwort_kommt_spaet', async (page) => {
await oeffne(page, '#termine')
await page.locator('#termine-project').waitFor()
// p1 (ohne Einstellung ⇒ Vorlagenauswahl) antwortet erst nach 1,5 s; in der Zwischenzeit zu p2 wechseln, das sofort antwortet
await page.locator('#termine-project').selectOption('p2')
await page.locator('.tm-reiterleiste').waitFor()
await page.waitForTimeout(2000)
eq(await page.locator('.tm-vorlagen').count(), 0, 'die späte Antwort von p1 erscheint nicht')
eq(await page.locator('.tm-reiterleiste').count(), 1, 'p2 bleibt stehen')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl p2')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Termine', 'Reiter von p2')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Langsame Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Schnelle Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
]
fx.ohneEinstellungFuer = ['p1']
fx.verzoegerung = { p1: 1500 }
return fx
})(),
})
// ------------------------------------------------------------ Abgeschaltet und nie eingerichtet
await check('fx_abgeschaltet_ohne_einstellung', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const hinweis = page.locator('.tm-box-warn')
await hinweis.waitFor()
assert((await textVon(hinweis)).includes('abgeschaltet'), 'Hinweis abgeschaltet')
eq(await hinweis.locator('a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link auch ohne Einstellung')
eq(await hinweis.locator('a').innerText(), 'exportieren', 'Linktext')
eq(await page.locator('.tm-vorlagen').count(), 0, 'keine Vorlagenauswahl')
eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knöpfe')
assert((await textVon(page.locator('.tm-info'))).includes('Keine Termine eingerichtet'), 'Info-Karte')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte[0].features.termine = { state: 'disabled' }
fx.einstellung = null
return fx
})(),
})
// ------------------------------------------------------------ Datumssprung mit Unsinn
await check('fx_datumssprung_unsinn', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('li.tm-eintrag').first().waitFor()
// fuenfstelliges Jahr: gueltiger Wert des Datumsfelds, aber kein Tag der Ansicht
const wert = await page.locator('#tm-sprung').evaluate((el) => { el.value = '12345-01-01'; el.dispatchEvent(new Event('change', { bubbles: true })); return el.value })
eq(wert, '12345-01-01', 'das Feld nimmt das Datum an')
await page.waitForTimeout(200)
eq(await textVon(page.locator('.tm-zeitraum')), 'Montag, 5. Oktober 2026', 'Ansicht bleibt stehen')
await page.getByRole('button', { name: 'Weiter', exact: true }).click()
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Ansicht reagiert weiter')
// Datumsfeld im dunklen Farbschema und im Stil der anderen Felder
eq(await page.locator('#tm-sprung').evaluate((el) => getComputedStyle(el).colorScheme), 'dark', 'color-scheme dark')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
// ------------------------------------------------------------ Rueckmeldung ist im Bild (390 px)
await check('fx_meldung_im_bild', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.getByRole('button', { name: 'Woche', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="bkonflikt"]').waitFor()
const imBild = () => page.evaluate(() => {
const m = document.querySelector('.tm-meldung:not(:empty)')
if (!m) return 'keine Meldung'
const r = m.getBoundingClientRect()
const kopf = document.querySelector('.kb-header').getBoundingClientRect().bottom
return r.top >= kopf - 1 && r.bottom <= window.innerHeight ? 'ja' : `nein (oben ${Math.round(r.top)}, unten ${Math.round(r.bottom)}, Kopf ${Math.round(kopf)}, Fenster ${window.innerHeight})`
})
// Erfolg: weit unten abgesagt ⇒ die Meldung (ueber den Reitern) wird ins Bild geholt
const unten = page.locator('li.tm-eintrag[data-id="bvermerk"]')
await unten.scrollIntoViewIfNeeded()
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await knopf(unten, 'Absagen: Damenschnitt, 06.10.2026 um 16:00 Uhr').click()
await unten.getByLabel('Grund').selectOption('Krankheit')
await knopf(unten.locator('.tm-frage-box'), 'Termin absagen').click()
await meldung(page).waitFor()
eq(await imBild(), 'ja', 'Erfolgsmeldung im Bild')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 1, 'eine Absage gesendet')
// Fehler: Meldung im Bild und mit Fokus (Screenreader, Tastatur)
const konflikt = page.locator('li.tm-eintrag[data-id="bkonflikt"]')
await konflikt.scrollIntoViewIfNeeded()
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await knopf(konflikt, 'Absagen: Damenschnitt, 06.10.2026 um 17:00 Uhr').click()
await konflikt.getByLabel('Grund').selectOption('Krankheit')
await knopf(konflikt.locator('.tm-frage-box'), 'Termin absagen').click()
await page.locator('.tm-meldung-fehler').waitFor()
eq(await imBild(), 'ja', 'Fehlermeldung im Bild')
eq(await page.evaluate(() => document.activeElement?.classList.contains('tm-meldung-fehler')), true, 'Fehlermeldung hat den Fokus')
eq(await page.locator('.tm-meldung-fehler').getAttribute('role'), 'alert', 'role alert')
// genau eine Live-Region: die Meldung; Liste und Titel melden nichts selbst
eq(await page.locator('#termine-body [aria-live]:not([data-feld] *)').evaluateAll((l) => l.map((e) => e.className)), ['tm-meldung tm-meldung-fehler'], 'aria-live nur an der Meldung')
await shot(page, '390-5-fehlermeldung')
}, {
fixture: (() => {
const fx = neueFixture()
fx.buchungen = [...alleStatus(), buchung('bkonflikt', '2026-10-06', '17:00', 45, { gastName: 'Konflikt Test' })]
return fx
})(),
context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
})
// ============================================================ Echter Server // ============================================================ Echter Server
await check('echt_kachel_vorlage_und_liste', async (page) => { await check('echt_kachel_vorlage_und_liste', async (page) => {
// aktiv, aber noch ohne Einstellung // aktiv, aber noch ohne Einstellung
@@ -644,6 +810,8 @@ async function pruefungen() {
// annehmen: G02 an die Anfragende // annehmen: G02 an die Anfragende
const mailsVorher = mailsAn('lea@example.com').length const mailsVorher = mailsAn('lea@example.com').length
await knopf(reihe(page, 'Lea Braun'), 'Annehmen: Brautfrisur, 13.10.2026 um 14:00 Uhr').click() await knopf(reihe(page, 'Lea Braun'), 'Annehmen: Brautfrisur, 13.10.2026 um 14:00 Uhr').click()
eq(buchungMit(anfrage1.$id).status, 'angefragt', 'Rückfrage: noch nichts geändert')
await reihe(page, 'Lea Braun').locator('.tm-frage-box').getByRole('button', { name: 'Annehmen', exact: true }).click()
await meldung(page).waitFor() await meldung(page).waitFor()
assert((await textVon(meldung(page))).includes('Lea Braun bekommt eine Bestätigung per E-Mail.'), 'Meldung annehmen') assert((await textVon(meldung(page))).includes('Lea Braun bekommt eine Bestätigung per E-Mail.'), 'Meldung annehmen')
await srv.warteAufVersand() await srv.warteAufVersand()
@@ -739,6 +907,8 @@ async function pruefungen() {
eq([buchungMit('b_begonnen').gastName, buchungMit('b_begonnen').gastEmail], ['Gelöscht', ''], 'Server: Gastdaten geloescht') eq([buchungMit('b_begonnen').gastName, buchungMit('b_begonnen').gastEmail], ['Gelöscht', ''], 'Server: Gastdaten geloescht')
// + Termin eintragen: Pflichtfelder, Feldfehler am Feld, dann Erfolg // + Termin eintragen: Pflichtfelder, Feldfehler am Feld, dann Erfolg
await page.getByRole('button', { name: 'Angefragt', exact: true }).click() // Statusfilter gesetzt: der neue Eintrag (bestaetigt) darf dadurch nicht verschwinden
eq(await page.getByRole('button', { name: 'Angefragt', exact: true }).getAttribute('aria-pressed'), 'true', 'Filter Angefragt gesetzt')
await knopf(page, '+ Termin eintragen').click() await knopf(page, '+ Termin eintragen').click()
const form = page.getByRole('form', { name: 'Termin eintragen' }) const form = page.getByRole('form', { name: 'Termin eintragen' })
await form.waitFor() await form.waitFor()
@@ -764,6 +934,7 @@ async function pruefungen() {
// Ansicht springt auf den Tag des neuen Termins // Ansicht springt auf den Tag des neuen Termins
await page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`).waitFor() await page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`).waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Tag des Eintrags') eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Tag des Eintrags')
eq(await page.getByRole('button', { name: 'Alle', exact: true }).getAttribute('aria-pressed'), 'true', 'Filter nach dem Eintragen wieder auf Alle')
eq(await page.locator('.tm-formular').count(), 0, 'Formular zu') eq(await page.locator('.tm-formular').count(), 0, 'Formular zu')
assert((await textVon(page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`))).includes('von dir eingetragen'), 'Handeintrag gekennzeichnet') assert((await textVon(page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`))).includes('von dir eingetragen'), 'Handeintrag gekennzeichnet')
@@ -864,6 +1035,42 @@ async function pruefungen() {
eq(await passtInBreite(page), true, 'echt: Sperre ohne Streifen') eq(await passtInBreite(page), true, 'echt: Sperre ohne Streifen')
await shot(page, '390-7-echt-zeit-sperren') await shot(page, '390-7-echt-zeit-sperren')
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } }) }, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ------------------------------------------------------------ Zeitzone des Browsers ist egal: Berliner Tage und Uhrzeiten
// Browser-Uhr 2026-10-05T23:30Z: in Berlin schon Dienstag 01:30, in New York noch Montag 19:30. Drei Termine rund um Mitternacht.
for (const [name, zone] of [['echt_zeitzone_new_york', 'America/New_York'], ['echt_zeitzone_berlin', 'Europe/Berlin']]) {
await check(name, async (page) => {
const doc = (id, tag, von, bis, gast) => ({
$id: id, featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_HERREN, leistungName: 'Herrenschnitt', ressourceId: R_BEN, ressourceName: 'Ben',
startsAt: utc(tag, von), endsAt: bis === '24:00' ? utc(tagNach(tag), '00:00') : utc(tag, bis), blockEndsAt: bis === '24:00' ? utc(tagNach(tag), '00:00') : utc(tag, bis),
status: 'bestaetigt', quelle: 'online', gastName: gast, gastEmail: 'gast@example.com', gastTelefon: '', gastNachricht: '', gastZusatz: '', erinnerung: false,
createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(), preisCent: 2800, preisArt: 'fest', buchungsart: 'sofort', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
})
srv.aw.seed('termineBookings', doc('b_spaet', '2026-10-05', '23:30', '24:00', 'Spät Abend'))
srv.aw.seed('termineBookings', doc('b_nacht', '2026-10-06', '00:30', '01:00', 'Nacht Eins'))
srv.aw.seed('termineBookings', doc('b_frueh', '2026-10-06', '09:00', '09:30', 'Früh Neun'))
await oeffne(page, '#termine')
await warteAnsicht(page)
eq(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone), zone, 'Zeitzone des Browsers')
// heute ist in Berlin Dienstag, auch wenn es in New York noch Montag ist
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Titel: Berliner Tag')
await page.locator('li.tm-eintrag[data-id="b_frueh"]').waitFor()
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['b_nacht', 'b_frueh'], 'Dienstag: die beiden Termine')
const zeiten = await page.locator('li.tm-eintrag .tm-zeit').allInnerTexts()
eq(zeiten, ['00:30–01:00', '09:00–09:30'], 'Uhrzeiten wie in Berlin')
// ein Tag zurueck: der Termin bis Mitternacht endet um 24:00
await page.getByRole('button', { name: 'Zurück', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="b_spaet"]').waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Montag, 5. Oktober 2026', 'ein Tag zurück')
eq(await page.locator('li.tm-eintrag .tm-zeit').allInnerTexts(), ['23:30–24:00'], 'Ende um Mitternacht als 24:00')
// Woche: Ueberschriften „Heute“ und „Gestern“ nach Berliner Datum
await page.getByRole('button', { name: 'Heute', exact: true }).click()
await page.getByRole('button', { name: 'Woche', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="b_spaet"]').waitFor()
eq(await page.locator('.tm-tag-kopf').allInnerTexts().then((l) => l.slice(0, 2)), ['Gestern · Montag, 5. Oktober', 'Heute · Dienstag, 6. Oktober'], 'Gestern und Heute nach Berliner Datum')
await shot(page, name === 'echt_zeitzone_new_york' ? 'echt-new-york' : 'echt-berlin')
}, { context: { timezoneId: zone }, jetzt: new Date('2026-10-05T23:30:00Z') })
}
} }
// ---------------------------------------------------------------- Lauf // ---------------------------------------------------------------- Lauf