From 650919b022f5d544495c388a73dc767d5908c824 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:07:05 +0000 Subject: [PATCH] =?UTF-8?q?Terminplaner:=20Portal-Ansicht=20=E2=80=93=20Na?= =?UTF-8?q?chbesserungen=20(2/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- public/funktionen.js | 2 + public/termine-aktionen.js | 21 +++- public/termine-dom.js | 18 ++- public/termine-formulare.js | 4 +- public/termine-tab-termine.js | 24 ++-- public/termine-view.js | 45 +++++-- public/termine.css | 8 +- public/termine.js | 84 ++++++++----- test/termineView.test.js | 54 +++++++- test/ui/termine-portal.ui.mjs | 223 ++++++++++++++++++++++++++++++++-- 10 files changed, 410 insertions(+), 73 deletions(-) diff --git a/public/funktionen.js b/public/funktionen.js index f8f1230..8ef3dbb 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -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. */ const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.' 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`) function pickTermineProject(projects) { diff --git a/public/termine-aktionen.js b/public/termine-aktionen.js index af12964..ee5c9a4 100644 --- a/public/termine-aktionen.js +++ b/public/termine-aktionen.js @@ -24,14 +24,9 @@ export function schliesseFrage(t, fokus = false) { export function starteAktion(t, li, b, art, knopf) { 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) 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) li.querySelector('[data-frage]').replaceChildren(box) 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')) } } +// 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) { const nachricht = h('textarea', { rows: '3', maxlength: String(MAX.nachricht) }) const { ok, zeile } = knopfZeile('Anfrage ablehnen', zu) diff --git a/public/termine-dom.js b/public/termine-dom.js index dee20b3..9d7c966 100644 --- a/public/termine-dom.js +++ b/public/termine-dom.js @@ -16,12 +16,18 @@ export function h(tag, attrs, ...kinder) { } export async function request(path, { method = 'GET', body } = {}) { - const response = await fetch(path, { - method, - credentials: 'same-origin', - headers: body ? { 'Content-Type': 'application/json' } : {}, - body: body ? JSON.stringify(body) : undefined, - }) + let response + try { + response = await fetch(path, { + method, + credentials: 'same-origin', + headers: body ? { 'Content-Type': 'application/json' } : {}, + 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(() => ({})) 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}`)) diff --git a/public/termine-formulare.js b/public/termine-formulare.js index 5ab76e2..5a1b301 100644 --- a/public/termine-formulare.js +++ b/public/termine-formulare.js @@ -2,7 +2,7 @@ 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. */ -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' /** 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() fehler.textContent = '' 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 …' try { const { zeiten } = await ctx.api.get('/zeiten', q) diff --git a/public/termine-tab-termine.js b/public/termine-tab-termine.js index c37b9dc..86cffca 100644 --- a/public/termine-tab-termine.js +++ b/public/termine-tab-termine.js @@ -4,7 +4,7 @@ Rueckfrage stehen in termine-aktionen.js, die Formulare in termine-formulare.js. */ 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, } from './termine-view.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: '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.addEventListener('change', () => { if (t.datumFeld.value) setze(t, { tag: t.datumFeld.value }) }) - t.titel = h('h3', { class: 'tm-zeitraum', 'aria-live': 'polite' }) + // nur ein echter Tag springt an (ein Datumsfeld mit fuenfstelligem Jahr liefert anderes und wuerde die Berechnung der Ansicht festfahren) + 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)) 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) @@ -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-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.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.liste = h('div', { class: 'tm-liste', 'aria-live': 'polite' }) + t.liste = h('div', { class: 'tm-liste' }) 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), @@ -91,16 +92,19 @@ async function ladeListe(t, { auchZahl = false } = {}) { const { s } = ctx const nr = ++t.nr 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) t.liste.setAttribute('aria-busy', 'true') try { - const [antwort] = await Promise.all([ - ctx.api.get('/buchungen', { von, bis, ...(status ? { status } : {}) }), + const [antworten] = await Promise.all([ + 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 ]) 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) } catch (err) { if (nr !== t.nr || !ctx.aktuell()) return @@ -220,8 +224,10 @@ function schliesseFormular(t) { async function nachErfolg(t, tag, text) { schliesseFormular(t) + if (!t.ctx.aktuell()) return t.ctx.s.tag = 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) await ladeListe(t, { auchZahl: true }) t.ctx.meldung(text) diff --git a/public/termine-view.js b/public/termine-view.js index a2bbf8a..b58c320 100644 --- a/public/termine-view.js +++ b/public/termine-view.js @@ -40,16 +40,45 @@ export function monatVon(tag) { } /** - * Zeitraum fuer die Abfrage der Buchungen. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute, so weit die - * Schnittstelle reicht). + * Zeitraum der Liste. ansicht: 'tag' | 'woche' | 'monat' | 'anfragen' (ab heute bis zum Ende des Buchungszeitraums horizontTage + * 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 === '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 } } +/** + * 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. */ export function verschiebeTag(ansicht, tag, 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' }) -/** 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) { 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 --- */ @@ -182,7 +213,7 @@ export function filterStatus(key) { 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) { if (!Number.isInteger(n) || n < 1) return '' return n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten` diff --git a/public/termine.css b/public/termine.css index d16457d..a3543e0 100644 --- a/public/termine.css +++ b/public/termine.css @@ -12,8 +12,10 @@ .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-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[hidden] { display: none; } +.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); } +/* 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-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); } @@ -43,6 +45,8 @@ } .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); } +#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-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; } .tm-chip { diff --git a/public/termine.js b/public/termine.js index e7c09ca..4c5d7b6 100644 --- a/public/termine.js +++ b/public/termine.js @@ -22,7 +22,7 @@ export async function initTermine(root, { projectId } = {}) { delete root.dataset.wunschProjekt if (wunsch) auswahl.projectId = wunsch 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 if (!termine?.verfuegbar) { 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) } -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) { - 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' 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 })) return } s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) + let daten try { - s.daten = await ladeDaten(s) + daten = await ladeDaten(projekt.projectId) } catch (err) { - if (S === s) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s))) + if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s))) return } - if (S !== s) return - if (s.daten.einstellung) zeigeReiter(s) - else if (s.nurLesen) s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website abgeschaltet', 'Es wurden keine Termine eingerichtet.', { chat: true })) - else zeigeVorlagen(s) + if (!aktuell()) return + s.daten = daten + if (daten.einstellung) zeigeReiter(s, { projekt, aktuell }) + 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 --- */ -function zeigeVorlagen(s) { - const projektId = s.projekt.projectId +function zeigeVorlagen(s, { projekt, aktuell }) { 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') let gewaehlt = null @@ -95,10 +111,11 @@ function zeigeVorlagen(s) { start.disabled = true fehler.textContent = '' try { - await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projektId, vorlage: gewaehlt.key } }) - s.daten = await ladeDaten(s) - if (S !== s) return - zeigeReiter(s) + await request('/api/termine/einstellungen/vorlage', { method: 'POST', body: { projectId: projekt.projectId, vorlage: gewaehlt.key } }) + const daten = await ladeDaten(projekt.projectId) + if (!aktuell()) return + s.daten = daten + zeigeReiter(s, { projekt, aktuell }) zeigeMeldung(s, `Die Vorlage „${gewaehlt.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`) } catch (err) { 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.') }, ] -/** 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') { const el = s.meldungEl if (!el) return el.textContent = text || '' - el.hidden = !text el.className = `tm-meldung tm-meldung-${art}` 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) { - const projectId = s.projekt.projectId +function zeigeReiter(s, { projekt, aktuell }) { + const projectId = projekt.projectId 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 = { s, projectId, daten: s.daten, nurLesen, auswahl, - aktuell: () => S === s, + aktuell, api: { get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`), send: (method, pfad, body = {}) => (method === 'DELETE' @@ -156,18 +180,18 @@ function zeigeReiter(s) { meldung: (text, art) => zeigeMeldung(s, text, art), zumReiter: (key) => waehleReiter(key), neuLaden: async () => { - s.daten = await ladeDaten(s) - ctx.daten = s.daten - return s.daten + const daten = await ladeDaten(projectId) + if (aktuell()) { + s.daten = daten + ctx.daten = daten + } + return daten }, } const hinweise = [] if (nurLesen) { - hinweise.push(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'), - '.')) + hinweise.push(hinweisAbgeschaltet(projectId)) } else if ((s.daten.fehlt || []).length) { 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.`), @@ -202,7 +226,7 @@ function zeigeReiter(s) { panel.replaceChildren() Promise.resolve() .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) diff --git a/test/termineView.test.js b/test/termineView.test.js index 8f5a427..1eaac7a 100644 --- a/test/termineView.test.js +++ b/test/termineView.test.js @@ -3,8 +3,9 @@ import assert from 'node:assert/strict' import { readFileSync, readdirSync } from 'node:fs' import { 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' +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 // 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('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('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-01', -1), '2026-09-30') 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') }) +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', () => { assert.equal(datumLang('2026-10-05'), 'Montag, 5. Oktober 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', () => { 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-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({}), '') }) @@ -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`) } }) + +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') +}) diff --git a/test/ui/termine-portal.ui.mjs b/test/ui/termine-portal.ui.mjs index 96cbbdb..5b6d4cc 100644 --- a/test/ui/termine-portal.ui.mjs +++ b/test/ui/termine-portal.ui.mjs @@ -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 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 textVon = (loc) => loc.innerText() /** Kein Streifen rechts: die Seite passt in die Breite. */ const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) -// Berliner Wanduhr im Oktober 2026 (MESZ, bis 25.10.) als ISO-Zeit -const utc = (tag, zeit) => new Date(`${tag}T${zeit}:00+02:00`).toISOString() +// 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${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). */ function portalHilfe(p, json) { @@ -128,7 +128,13 @@ async function fixtureAntwort(fx, route, req, url, json) { const q = url.searchParams const m = req.method() 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') { fx.aufrufe.push(['vorlage', req.postDataJSON()]) fx.einstellung = srv.einstellung() @@ -146,6 +152,7 @@ async function fixtureAntwort(fx, route, req, url, json) { const body = req.postDataJSON() fx.aufrufe.push(['aktion', aktion[1], body]) 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 === 'absagen') Object.assign(b, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: body.grund }) 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 * 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 await srv.zuruecksetzen(reset) 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()]) const page = await ctx.newPage() 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 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 buchungMit = (id) => srv.buchungen().find((b) => b.$id === id) @@ -488,7 +496,7 @@ async function pruefungen() { 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 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') const zeile = reihe(page, 'Lea Braun') // Details zeigen Kontakt und Nachricht als Text @@ -496,7 +504,20 @@ async function pruefungen() { 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}`) 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() 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') @@ -594,6 +615,151 @@ async function pruefungen() { 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 } }) + // ------------------------------------------------------------ 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 await check('echt_kachel_vorlage_und_liste', async (page) => { // aktiv, aber noch ohne Einstellung @@ -644,6 +810,8 @@ async function pruefungen() { // annehmen: G02 an die Anfragende const mailsVorher = mailsAn('lea@example.com').length 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() assert((await textVon(meldung(page))).includes('Lea Braun bekommt eine Bestätigung per E-Mail.'), 'Meldung annehmen') await srv.warteAufVersand() @@ -739,6 +907,8 @@ async function pruefungen() { eq([buchungMit('b_begonnen').gastName, buchungMit('b_begonnen').gastEmail], ['Gelöscht', ''], 'Server: Gastdaten geloescht') // + 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() const form = page.getByRole('form', { name: 'Termin eintragen' }) await form.waitFor() @@ -764,6 +934,7 @@ async function pruefungen() { // Ansicht springt auf den Tag des neuen Termins 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 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') 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') await shot(page, '390-7-echt-zeit-sperren') }, { 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