Öffnungszeiten: Editor für Pläne, Feiertage und Ausnahmen

Speichern über PUT /api/zeiten (Version, 409, Feldfehler per ID-Pfad), Zeitpläne mit
Wochentagen, Hauptplan wechseln und Löschen (R10, R33), Feiertage je Bundesland,
Betriebsurlaub und Sondertage mit Hinweis-Leisten-Frage. Die Hülle behält den Editor
bei einem neuen Stand und zeichnet nur den Kopf neu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 06:02:31 +00:00
1 parent cee44d6852
commit 04a3525f79
8 files changed
+1974 -21

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereiche 2 „Feiertage“ und 3 „Ausnahmen“ (Spec 3.4). Jeder Bereich ist eine Funktion
(t) => Element; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js (sp = Speicher des Editors, zeichne() zeichnet alle Abschnitte
neu). Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js). Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt,
ohne Knoepfe zum Aendern. Stil: termine-team-ausnahmen.js (Abwesenheiten, Feiertage, Sonderoeffnungen).
Feiertage: Bundesland (ein eigenes Speichern; die Liste kommt vom Server und gilt danach fuer das neue Land) und die Feiertage der naechsten
12 Monate (ctx.stand.feiertage). Gespeichert werden nur Abweichungen von der Vorgabe (daten.feiertage): ein gesetzlicher Feiertag ist
„geschlossen“, ein „bitte prüfen“-Tag (Heiligabend, Silvester, nur ortsuebliche Feiertage) folgt dem Wochenplan.
Ausnahmen: Betriebsurlaub (von–bis, Text) und Sondertag (Datum, geschlossen oder Zeiten), Geltung „alle Pläne“ oder ein Plan. Nach dem Speichern
eines Urlaubs ab 2 Tagen fragt der Editor „Hinweis-Leiste dazu anlegen?“ (ctx.hinweisFrage); „Ja“ legt den Vorschlag (hinweisVorschlag) in
daten.hinweise an und speichert. */
import { BUNDESLAENDER, datumKurz, datumLang, heuteBerlin, istTag } from './termine-view.js'
import { abschnitt, allgemeinerFehler, editorZeile, feld, frage, gesperrt, h, neuerEintrag, zeigeFehler } from './termine-dom.js'
import { MODI, ZEITEN_LIMITS, ZEITEN_MAX, hinweisVorschlag, neueZeitenId, tagFehler } from './zeiten-view.js'
import { entfernenKnopf, nachSpeichern, stelleVon } from './zeiten-speichern.js'
import { MODUS_WAHL, tagFelder, tagText } from './zeiten-plaene.js'
const nachBeginn = (x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.bis < y.bis ? -1 : x.bis > y.bis ? 1 : 0)
const zeitraum = (a) => (a.von === a.bis ? datumKurz(a.von) : `${datumKurz(a.von)} – ${datumKurz(a.bis)}`)
/* -------------------------------------------------------------- Feiertage --- */
const FEIERTAG_WAHL = Object.freeze([['', 'geschlossen (Vorgabe)'], ...MODUS_WAHL.filter(([wert]) => wert !== 'zu')])
const PRUEFEN_WAHL = Object.freeze([['', 'wie im Wochenplan (Vorgabe)'], ...MODUS_WAHL])
export function feiertage(t) {
const { ctx, sp } = t
const daten = ctx.stand.daten
const nurLesen = ctx.nurLesen
const gespeichert = daten.feiertage !== null && typeof daten.feiertage === 'object' ? daten.feiertage : {}
// Bundesland: eigenes Formular, denn die Liste darunter kommt nach dem Speichern vom Server neu
const land = h('select', null, BUNDESLAENDER.map(([key, name]) => h('option', { value: key, selected: (daten.land || 'RP') === key }, name)))
const landForm = h('form', { novalidate: true, 'aria-label': 'Bundesland' },
gesperrt(nurLesen, feld('land', 'Bundesland', land, 'Danach richten sich die Feiertage unten.')),
allgemeinerFehler(),
nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, h('button', { type: 'submit', class: 'kb-btn-outline', 'data-aktion': 'land-speichern' }, 'Bundesland speichern')))
landForm.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => { entwurf.land = land.value }, { container: landForm })
if (!ok || !sp.nochHier()) return
const name = BUNDESLAENDER.find(([key]) => key === land.value)?.[1] ?? land.value
nachSpeichern(t, `Bundesland gespeichert: ${name}. Die Feiertage unten gelten jetzt dafür.`, 'form[aria-label="Bundesland"] button[type="submit"]')
})
const zeilen = ctx.stand.feiertage.map((f) => {
const pruefen = f.art === 'pruefen'
const abweichung = Object.hasOwn(gespeichert, f.tag) ? gespeichert[f.tag] : null
// ein gespeichertes „geschlossen“ an einem gesetzlichen Feiertag ist die Vorgabe
const modus = abweichung && MODI.includes(abweichung.modus) && !(!pruefen && abweichung.modus === 'zu') ? abweichung.modus : ''
const tag = tagFelder({ pfad: `feiertage.${f.tag}`, label: `${f.name}, ${datumKurz(f.tag)}`, wahl: pruefen ? PRUEFEN_WAHL : FEIERTAG_WAHL, modus, fenster: abweichung?.fenster })
const el = h('li', { class: 'tm-eintrag zt-feiertag', 'data-feiertag': f.tag },
h('div', { class: 'tm-kopf' },
h('div', { class: 'tm-haupt' }, h('strong', { class: 'tm-name' }, datumLang(f.tag)), h('span', { class: 'tm-meta' }, f.name)),
pruefen ? h('span', { class: 'tm-pille tm-pille-wait' }, 'bitte prüfen') : null),
tag.el)
return { f, tag, el }
})
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'feiertage-speichern' }, 'Feiertage speichern')
const form = h('form', { novalidate: true, 'aria-label': 'Feiertage' },
zeilen.length
? gesperrt(nurLesen, h('ul', { class: 'tm-eintraege', 'aria-label': 'Feiertage der nächsten 12 Monate' }, zeilen.map(({ el }) => el)))
: h('p', { class: 'tm-leer' }, 'Für dieses Bundesland gibt es in den nächsten 12 Monaten keine Feiertage.'),
allgemeinerFehler(),
nurLesen || !zeilen.length ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
// nur Abweichungen von der Vorgabe: alles andere (auch Tage ausserhalb der Liste) bleibt, wie es war
const neu = { ...(entwurf.feiertage || {}) }
const fehler = {}
for (const { f, tag } of zeilen) {
const r = tag.lies()
if (r.fehler) {
Object.assign(fehler, r.fehler)
continue
}
if (r.modus === '' || (f.art !== 'pruefen' && r.modus === 'zu')) {
delete neu[f.tag]
continue
}
const eintrag = { modus: r.modus, fenster: r.fenster }
const meldung = tagFehler(eintrag)
if (meldung) fehler[`feiertage.${f.tag}`] = meldung
neu[f.tag] = eintrag
}
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
entwurf.feiertage = neu
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, 'Feiertage gespeichert.', 'form[aria-label="Feiertage"] button[type="submit"]')
})
return abschnitt('Feiertage',
'An gesetzlichen Feiertagen ist deine Website als „geschlossen“ eingetragen. Willst du an einem Feiertag öffnen, wähle eigene Zeiten. „Bitte prüfen“ sind Tage, die nicht überall oder nicht für jeden frei sind (zum Beispiel Heiligabend, Silvester oder Fronleichnam): Hier gilt als Vorgabe dein Wochenplan.',
landForm,
h('p', { class: 'tm-hinweis' }, 'Gemeinde-Feiertage sind nicht enthalten. Trage sie bei Bedarf unten als Sondertag ein.'),
form)
}
/* -------------------------------------------------------------- Ausnahmen --- */
const gibtHinweis = (hinweise, v) => (Array.isArray(hinweise) ? hinweise : []).some((x) => x?.text === v.text && x.von === v.von && x.bis === v.bis)
const planName = (daten, id) => (daten.plaene || []).find((p) => p?.id === id)?.name
export function ausnahmen(t) {
const { ctx } = t
const daten = ctx.stand.daten
const alle = Array.isArray(daten.ausnahmen) ? daten.ausnahmen : []
const heute = heuteBerlin()
const kommend = alle.filter((a) => a.bis >= heute).sort(nachBeginn)
const vorbei = alle.filter((a) => a.bis < heute).sort(nachBeginn).reverse()
const voll = alle.length >= ZEITEN_LIMITS.ausnahmen
const grenzText = `Mehr als ${ZEITEN_LIMITS.ausnahmen} Ausnahmen sind nicht möglich.`
const urlaubNeu = neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Betriebsurlaub eintragen', werkzeug: 'urlaub-neu', voll, grenzText,
baue: (schliessen) => formular(t, { a: null, art: 'urlaub', schliessen }),
})
const sonderNeu = neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Sondertag eintragen', werkzeug: 'sondertag-neu', voll, grenzText: '',
baue: (schliessen) => formular(t, { a: null, art: 'sondertag', schliessen }),
})
const reihe = (a) => {
const art = a.art === 'sondertag' ? 'Sondertag' : 'Betriebsurlaub'
const plan = a.plan ? `nur ${planName(daten, a.plan) || 'ein gelöschter Plan'}` : ''
return editorZeile({
attrs: { 'data-id': a.id },
nurLesen: ctx.nurLesen,
label: `${art} ${datumKurz(a.von)}`,
haupt: [
h('strong', { class: 'tm-name' }, zeitraum(a)),
h('span', { class: 'tm-meta' }, [art, a.art === 'sondertag' ? tagText(a) : '', a.text, plan].filter(Boolean).join(' · ')),
],
baue: (schliessen) => formular(t, { a, art: a.art === 'sondertag' ? 'sondertag' : 'urlaub', schliessen }),
})
}
return abschnitt('Ausnahmen',
'Betriebsurlaub und einzelne Sondertage gehen dem Wochenplan und den Feiertagen vor. Jede Ausnahme gilt für alle Pläne oder nur für einen.',
hinweisFrage(t),
h('div', { class: 'zt-werkzeuge' }, urlaubNeu.leiste, sonderNeu.leiste),
urlaubNeu.box,
sonderNeu.box,
kommend.length
? h('ul', { class: 'tm-eintraege', 'aria-label': 'Kommende Ausnahmen' }, kommend.map(reihe))
: h('p', { class: 'tm-leer' }, 'Keine kommenden Ausnahmen eingetragen.'),
vorbei.length
? h('details', { class: 'tm-details' }, h('summary', null, `Vergangene Ausnahmen (${vorbei.length})`), h('ul', { class: 'tm-eintraege' }, vorbei.map(reihe)))
: null)
}
/**
* „Hinweis-Leiste dazu anlegen?“ nach einem Betriebsurlaub ab 2 Tagen (ctx.hinweisFrage = { vorschlag }): Text und Zeitraum sind vorbefuellt
* (hinweisVorschlag: sichtbar ab 7 Tage vor Beginn bis zum Urlaubsende). „Ja“ legt den Hinweis in daten.hinweise an und speichert; „Nein“ laesst es.
*/
function hinweisFrage(t) {
const { ctx, sp } = t
const f = ctx.hinweisFrage
if (!f || ctx.nurLesen) return null
const v = f.vorschlag
const form = frage('Hinweis-Leiste dazu anlegen?',
h('p', null, `„${v.text}“ – sichtbar vom ${datumKurz(v.von)} bis ${datumKurz(v.bis)}, oben auf deiner Website.`),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'hinweis-ja' }, 'Ja, anlegen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hinweis-nein', onclick: () => {
ctx.hinweisFrage = null
t.zeichne()
t.wurzel.querySelector('[data-werkzeug="urlaub-neu"]')?.focus()
} }, 'Nein, danke')))
form.dataset.frage = 'hinweis'
form.addEventListener('submit', async () => {
const ok = await sp.speichere((entwurf) => {
entwurf.hinweise = [...(Array.isArray(entwurf.hinweise) ? entwurf.hinweise : []), { id: neueZeitenId('h'), art: v.art, text: v.text, knopf: null, von: v.von, bis: v.bis }]
}, { container: form })
if (!ok || !sp.nochHier()) return
ctx.hinweisFrage = null
nachSpeichern(t, `Die Hinweis-Leiste „${v.text}“ ist angelegt.`, '[data-werkzeug="urlaub-neu"]')
})
return form
}
/**
* Formular einer neuen (a null) oder bestehenden Ausnahme der Art 'urlaub' (von, bis) oder 'sondertag' (ein Datum, Betriebsart, Zeiten).
* Die Felder heissen nach dem Pfad der Fehler des Servers (ausnahmen.<ID>.von …); Absenden sucht die Ausnahme per ID.
*/
function formular(t, { a, art, schliessen }) {
const { ctx, sp } = t
const daten = ctx.stand.daten
const neu = a === null
const alt = a || { id: neueZeitenId('x'), art, von: heuteBerlin(), bis: heuteBerlin(), text: '', modus: 'zu', fenster: [], plan: null }
const p = `ausnahmen.${alt.id}`
const urlaub = art === 'urlaub'
const wort = urlaub ? 'Betriebsurlaub' : 'Sondertag'
const von = h('input', { type: 'date', value: alt.von })
const bis = h('input', { type: 'date', value: alt.bis })
const text = h('input', { type: 'text', maxlength: ZEITEN_MAX.ausnahmeText, autocomplete: 'off', placeholder: urlaub ? 'z. B. Sommerpause' : 'z. B. Tag der offenen Tür', value: alt.text })
const tag = urlaub ? null : tagFelder({ pfad: `${p}.modus`, fensterPfad: `${p}.fenster`, label: 'Sondertag', wahl: MODUS_WAHL, sichtbar: true, modus: alt.modus, fenster: alt.fenster })
const plaene = Array.isArray(daten.plaene) ? daten.plaene : []
const geltung = plaene.length > 1 || alt.plan
? h('select', null,
h('option', { value: '', selected: !alt.plan }, 'Alle Pläne'),
plaene.map((x) => h('option', { value: x.id, selected: alt.plan === x.id }, x.name)))
: null
const entfernen = neu || ctx.nurLesen ? null : entfernenKnopf(sp, {
titel: `${wort} entfernen?`,
text: urlaub ? 'In dieser Zeit gilt danach wieder der Wochenplan.' : 'An diesem Tag gilt danach wieder der Wochenplan (und der Feiertag, falls einer ist).',
aendere: (entwurf) => { entwurf.ausnahmen = (entwurf.ausnahmen || []).filter((x) => x?.id !== alt.id) },
nachher: () => nachSpeichern(t, `${wort} entfernt.`, '[data-werkzeug="urlaub-neu"]'),
})
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'speichern' }, neu ? `${wort} eintragen` : 'Speichern')
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': neu ? `${wort} eintragen` : `${wort} bearbeiten` },
h('h3', null, neu ? (urlaub ? 'Neuer Betriebsurlaub' : 'Neuer Sondertag') : wort),
gesperrt(ctx.nurLesen, [
urlaub
? h('div', { class: 'tm-raster' }, feld(`${p}.von`, 'Von (erster Tag)', von), feld(`${p}.bis`, 'Bis (letzter Tag)', bis))
: feld(`${p}.von`, 'Datum', von),
tag?.el,
feld(`${p}.text`, 'Text (optional)', text, 'Höchstens 60 Zeichen.'),
geltung ? feld(`${p}.plan`, 'Gilt für', geltung) : null,
]),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
ctx.nurLesen ? null : speichern,
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, ctx.nurLesen ? 'Schließen' : 'Abbrechen'),
entfernen?.knopf))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
let eintrag = null
const ok = await sp.speichere((entwurf) => {
const fehler = {}
if (!istTag(von.value)) fehler[`${p}.von`] = 'Bitte gib ein gültiges Datum an.'
let ende = von.value
if (urlaub) {
ende = bis.value
if (!istTag(bis.value)) fehler[`${p}.bis`] = 'Bitte gib ein gültiges Datum an.'
else if (istTag(von.value) && bis.value < von.value) fehler[`${p}.bis`] = 'Das Ende darf nicht vor dem Beginn liegen.'
}
let betrieb = { modus: 'zu', fenster: [] }
if (tag) {
const r = tag.lies()
if (r.fehler) Object.assign(fehler, r.fehler)
else {
const meldung = tagFehler(r)
if (meldung) fehler[`${p}.fenster`] = meldung
betrieb = r
}
}
if (Object.keys(fehler).length) {
zeigeFehler(form, { fehler })
return false
}
eintrag = { ...alt, art, von: von.value, bis: ende, text: text.value.trim(), modus: betrieb.modus, fenster: betrieb.fenster, plan: geltung ? (geltung.value || null) : alt.plan ?? null }
if (neu) {
entwurf.ausnahmen = [...(Array.isArray(entwurf.ausnahmen) ? entwurf.ausnahmen : []), eintrag]
return true
}
const stelle = stelleVon(entwurf.ausnahmen, alt.id, form, `Dieser ${wort}`)
if (stelle < 0) return false
entwurf.ausnahmen[stelle] = eintrag
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
// Urlaub ab 2 Tagen: die Hinweis-Leiste dazu anbieten (nicht noch einmal, wenn es sie schon gibt oder sich der Zeitraum nicht geaendert hat)
if (urlaub) {
const vorschlag = hinweisVorschlag(eintrag)
const geaendert = neu || alt.von !== eintrag.von || alt.bis !== eintrag.bis
ctx.hinweisFrage = vorschlag && geaendert && !gibtHinweis(ctx.stand.daten.hinweise, vorschlag) ? { vorschlag } : null
}
nachSpeichern(t, neu ? `${wort} eingetragen.` : `${wort} gespeichert.`, `li[data-id="${CSS.escape(alt.id)}"] [data-aktion="bearbeiten"]`)
})
return h('div', { class: 'tm-editor' }, form, entfernen?.box)
}
+134
View File
@@ -0,0 +1,134 @@
/* Oeffnungszeiten live im Kundenbereich: der Editor (ES-Modul, zeiten.js haengt ihn in .zt-editor ein; Spec 3.4). Server:
server/routes/zeiten.js. renderEditor(root, ctx) laedt GET /api/zeiten/<projectId> und zeichnet die Abschnitte; die Eingaben des Kunden
bleiben im Editor, solange der Platz (root) steht – zeiten.js baut ihn bei einem neuen Stand der Funktion nicht neu auf.
root.dataset.state = Zustand der Funktion (setup, active, disabled): disabled heisst nur lesen (alles gesperrt, PUT antwortet mit 403).
ctx von zeiten.js: { projectId, kundenansicht, neuZeichnen() } – neuZeichnen holt die Liste der Funktionen neu und zeichnet den Kopf der
Ansicht (Statuskarte); der Editor ruft es einmal nach dem ersten Speichern (zeitenLeer wechselt), nie sonst.
Der Editor-ctx t.ctx der Abschnitte (aus Aufgabe 29; Aufgabe 30 haengt ihre Abschnitte an ABSCHNITTE und die Live-Vorschau in den Kopf):
{ projectId, kundenansicht, nurLesen, stand, entwurfsplan, offen, hinweisFrage, aktuell(), neuLaden(), meldung(text, art), uebernimm(antwort, { erstes }),
leereDaten(), leer() }
stand = Antwort des Servers { state, daten (null vor dem ersten Speichern), version, kundenansicht, vorschau, feiertage, googleText }; sie wird bei jedem
Speichern und bei „Neu laden“ ersetzt. Ein Abschnitt ist eine Funktion (t) => Element mit t = { ctx, sp, zeichne, wurzel }.
Vor dem ersten Speichern (stand.daten null) gibt es nur den leeren Hauptplan; die anderen Abschnitte (mitDaten) erscheinen danach, denn der
Server verlangt mindestens einen Plan. Die Kontaktdaten (daten.kontakt) gehen unveraendert zurueck: der Editor setzt nie ein neues
kontakt.whatsapp.bestaetigtAt (R12, das tut Aufgabe 30 mit dem Haken des Kunden). */
import { fehlerKarte, h, request } from './termine-dom.js'
import { istTag } from './termine-view.js'
import { leererPlan } from './zeiten-view.js'
import { neuerZeitenSpeicher } from './zeiten-speichern.js'
import { plaene } from './zeiten-plaene.js'
import { ausnahmen, feiertage } from './zeiten-ausnahmen.js'
/** Abschnitte in der Reihenfolge der Seite (Spec 3.4). mitDaten: erst nach dem ersten Speichern. */
const ABSCHNITTE = [
{ key: 'plaene', bauen: plaene, mitDaten: false },
{ key: 'feiertage', bauen: feiertage, mitDaten: true },
{ key: 'ausnahmen', bauen: ausnahmen, mitDaten: true },
]
const NICHT_GELADEN = 'Deine Öffnungszeiten konnten gerade nicht geladen werden. Bitte versuch es noch einmal.'
const NUR_LESEN = 'Diese Funktion ist abgeschaltet. Du kannst deine Eingaben nur noch ansehen.'
const KUNDENANSICHT = 'Kundenansicht: Was du hier speicherst, ändert die Öffnungszeiten des Kunden. Jedes Speichern steht im Protokoll.'
const LEER = 'Trag deine Öffnungszeiten ein. Sobald du speicherst, kennt deine Website sie.'
const SPAETER = 'Feiertage, Ausnahmen und alles Weitere kannst du eintragen, sobald deine Öffnungszeiten gespeichert sind.'
// Welcher Aufruf von renderEditor gehoert zu einem Platz? Ein neuer Aufruf macht den alten ungueltig (seine Antworten fassen nichts mehr an).
const LAEUFE = new WeakMap()
/** Antwort des Servers pruefen und auf die Felder reduzieren, die der Editor braucht; wirft bei einer unbrauchbaren Antwort. */
function standAus(antwort) {
if (antwort === null || typeof antwort !== 'object' || !Number.isInteger(antwort.version)) throw new Error(NICHT_GELADEN)
const daten = antwort.daten !== null && typeof antwort.daten === 'object' && !Array.isArray(antwort.daten) ? antwort.daten : null
if (daten !== null && !Array.isArray(daten.plaene)) throw new Error(NICHT_GELADEN)
return {
state: typeof antwort.state === 'string' ? antwort.state : '',
// ohne Plaene ist nichts gespeichert (wie vor dem ersten Speichern)
daten: daten !== null && daten.plaene.length > 0 ? daten : null,
version: antwort.version,
kundenansicht: antwort.kundenansicht === true,
vorschau: antwort.vorschau ?? null,
feiertage: (Array.isArray(antwort.feiertage) ? antwort.feiertage : []).filter((f) => f && istTag(f.tag) && typeof f.name === 'string'),
googleText: typeof antwort.googleText === 'string' ? antwort.googleText : '',
}
}
export async function renderEditor(root, ctx = {}) {
const token = {}
LAEUFE.set(root, token)
const aktuell = () => root.isConnected && LAEUFE.get(root) === token
const url = `/api/zeiten/${encodeURIComponent(ctx.projectId)}`
const ladeStand = async () => standAus(await request(url))
root.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let stand
try {
stand = await ladeStand()
} catch (err) {
if (aktuell()) root.replaceChildren(fehlerKarte(err.message, () => renderEditor(root, ctx)))
return
}
if (!aktuell()) return
const nurLesen = ctx.nurLesen === true || root.dataset.state === 'disabled'
const meldungEl = h('div', { class: 'tm-meldung', 'data-meldung': 'editor', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
const entwurfsplan = leererPlan({ name: 'Öffnungszeiten', kurz: 'oeffnungszeiten', haupt: true })
const ec = {
projectId: ctx.projectId,
kundenansicht: ctx.kundenansicht === true,
nurLesen,
stand,
entwurfsplan, // der leere Hauptplan vor dem ersten Speichern
offen: new Set([entwurfsplan.id, ...(stand.daten ? stand.daten.plaene.filter((p) => p?.haupt === true).map((p) => p.id) : [])]), // aufgeklappte Plaene
hinweisFrage: null, // { vorschlag } nach einem Urlaub ab 2 Tagen
aktuell,
leer: () => ec.stand.daten === null,
/** Daten fuer das erste Speichern: der leere Hauptplan; die Kontaktdaten setzt der Server auf seine Vorgabe. */
leereDaten: () => ({ land: 'RP', plaene: [ec.entwurfsplan], feiertage: {}, ausnahmen: [], hinweise: [] }),
/** Eine Meldung, die erst nach dem Wechsel der Website eintrifft, gehoert nicht mehr auf diese Seite. */
meldung(text, art = 'ok') {
if (!aktuell()) return
meldungEl.textContent = text || ''
meldungEl.className = `tm-meldung tm-meldung-${art}`
meldungEl.setAttribute('role', art === 'fehler' ? 'alert' : 'status')
meldungEl.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite')
if (!text) return
if (art === 'fehler') meldungEl.focus({ preventScroll: true })
meldungEl.scrollIntoView({ block: 'nearest' })
},
async neuLaden() {
const neu = await ladeStand()
if (aktuell()) ec.stand = neu
return neu
},
/** Antwort auf ein erfolgreiches Speichern. Das erste Speichern aendert, was die Huelle zeigt (zeitenLeer): sie zeichnet ihren Kopf neu. */
uebernimm(antwort, { erstes = false } = {}) {
ec.stand = standAus(antwort)
if (erstes && typeof ctx.neuZeichnen === 'function') {
try {
Promise.resolve(ctx.neuZeichnen()).catch(() => {}) // scheitert das, zeigt der Kopf den alten Stand bis zum naechsten Besuch
} catch { /* wie oben */ }
}
},
}
const sp = neuerZeitenSpeicher(ec, root, zeichne)
const t = { ctx: ec, sp, zeichne, wurzel: root }
function zeichne() {
const leer = ec.leer()
const teile = [meldungEl, sp.konflikt]
if (ec.kundenansicht || ec.stand.kundenansicht) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'kundenansicht' }, h('span', null, KUNDENANSICHT)))
if (nurLesen) teile.push(h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'nurlesen' }, h('span', null, NUR_LESEN)))
else if (leer) teile.push(h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'leer' }, h('span', null, LEER)))
teile.push(h('div', { class: 'zt-kopfbereich' })) // Platz der Live-Vorschau (Aufgabe 30)
for (const abschnitt of ABSCHNITTE) {
if (abschnitt.mitDaten && leer) continue
const el = abschnitt.bauen(t)
el.dataset.abschnitt = abschnitt.key
teile.push(el)
}
if (leer && !nurLesen) teile.push(h('p', { class: 'tm-hinweis zt-spaeter' }, SPAETER))
root.replaceChildren(...teile)
}
zeichne()
}
+274
View File
@@ -0,0 +1,274 @@
/* Oeffnungszeiten live im Kundenbereich: Editor, Bereich 1 „Zeitplaene“ (Spec 3.4) und die Eingabe eines Tages, die auch Feiertage und
Sondertage nutzen (tagFelder). plaene(t) ist ein Abschnitt des Editors; t = { ctx, sp, zeichne, wurzel } kommt aus zeiten-editor.js
(sp = Speicher des Editors, zeichne() zeichnet alle Abschnitte neu). Jede Aenderung speichert die ganzen Daten (zeiten-speichern.js).
Ein Plan: Name, Kurzname (nur Anzeige: der Server vergibt ihn beim ersten Speichern endgueltig und nennt ihn in der Antwort, R33), je
Wochentag der Haken „geöffnet“ mit zwei Zeitpaaren (<input type="time" step="300">) und der Wahl „zu festen Zeiten“, „rund um die Uhr“,
„nach Vereinbarung“; Knoepfe Speichern, Zum Hauptplan machen, Plan löschen (nicht der einzige, nicht der Hauptplan).
Vor dem ersten Speichern (ctx.stand.daten null) gibt es nur den leeren Hauptplan „Öffnungszeiten“ (ctx.entwurfsplan); er gehoert zu
ctx.leereDaten(), mit denen das erste Speichern geht. Pruefungen im Browser (wochenFehler aus zeiten-view.js) zeigen Fehler nur frueher an,
entscheiden tut der Server. Abgeschaltete Funktion (ctx.nurLesen): dieselben Felder gesperrt, ohne Knoepfe zum Aendern. */
import { WOCHENTAGE_PLAN } from './termine-view.js'
import { abschnitt, allgemeinerFehler, feld, gesperrt, h, neuerEintrag, zeigeFehler } from './termine-dom.js'
import { MODI, ZEITEN_LIMITS, ZEITEN_MAX, hauptplanWechseln, kurzname, leererPlan, planLoeschenGrund, wochenFehler } from './zeiten-view.js'
import { entfernenKnopf, nachSpeichern, stelleVon } from './zeiten-speichern.js'
/** Betriebsart eines Tages, Ausnahme oder Feiertags: Wert und Beschriftung. */
export const MODUS_WAHL = Object.freeze([
['zu', 'geschlossen'], ['zeiten', 'geöffnet zu festen Zeiten'], ['rund', 'rund um die Uhr'], ['vereinbarung', 'nach Vereinbarung'],
])
const ART_WAHL = MODUS_WAHL.filter(([wert]) => wert !== 'zu') // Wochentag: „geöffnet“ ist der Haken, die Art steht daneben
/** Kurzfassung eines Tages: „geschlossen“, „09:00–12:30, 13:30–18:00“, „rund um die Uhr“, „nach Vereinbarung“. */
export function tagText(tag) {
if (tag?.modus === 'rund') return 'rund um die Uhr'
if (tag?.modus === 'vereinbarung') return 'nach Vereinbarung'
if (tag?.modus === 'zeiten' && Array.isArray(tag.fenster) && tag.fenster.length) return tag.fenster.map((f) => `${f[0]}–${f[1]}`).join(', ')
return 'geschlossen'
}
/** <input type="time"> kennt kein 24:00: ein gespeichertes Ende um 24:00 steht als 00:00 da (dasselbe Ende: „bis Mitternacht“). */
const zeitWert = (wert) => (wert === '24:00' ? '00:00' : typeof wert === 'string' ? wert : '')
let idZaehler = 0
/**
* Eingabe eines Tages (Wochentag, Feiertag, Sondertag): Betriebsart und bis zu zwei Zeitpaare.
* haken: Wochentag – Haken „geöffnet“ (ohne Haken: geschlossen) und daneben die Wahl der Art; sonst eine Auswahl der Betriebsart (wahl: Liste
* [Wert, Text]; ein Wert '' steht fuer „Vorgabe“ und liest sich als modus '').
* pfad: Name des Feldes (data-feld) und des Fehlers (data-fehler) – so findet ihn die Antwort des Servers; fensterPfad: Pfad des Fehlers bei den
* Zeitfenstern, wenn der Server fuer Betriebsart und Fenster getrennte Pfade nennt (Sondertag: …modus und …fenster).
* sichtbar: die Beschriftung der Auswahl steht in der Seite (sonst nur fuer Screenreader).
* => { el, lies }: lies() liefert { modus, fenster } oder { fehler: { [pfad]: Text } } (ein Paar nur halb ausgefuellt). Die Regeln der Fenster pruefen
* der Aufrufer (tagFehler/wochenFehler) und der Server.
*/
export function tagFelder({ pfad, fensterPfad = pfad, label, modus = 'zu', fenster = [], haken = false, wahl = MODUS_WAHL, sichtbar = false }) {
const start = typeof modus === 'string' && (MODI.includes(modus) || modus === '') ? modus : 'zu'
const zeitFeld = (i, teil, wert) => h('input', { type: 'time', step: '300', value: zeitWert(wert), 'aria-label': `${label}, Zeitfenster ${i + 1}, ${teil}` })
const paare = [0, 1].map((i) => ({ von: zeitFeld(i, 'von', fenster?.[i]?.[0]), bis: zeitFeld(i, 'bis', fenster?.[i]?.[1]) }))
if (fensterPfad !== pfad) paare[0].von.dataset.feld = fensterPfad
const zeiten = h('div', { class: 'zt-zeiten' }, paare.map((paar, i) => h('div', { class: 'zt-fenster' },
h('span', { class: 'zt-fenster-name' }, i === 0 ? 'Zeitfenster' : '2. Zeitfenster (optional)'),
h('label', { class: 'zt-zeit' }, h('span', null, 'von'), paar.von),
h('label', { class: 'zt-zeit' }, h('span', null, 'bis'), paar.bis))))
let kopf
let felder
let modusWert
let zeige
if (haken) {
const geoeffnet = h('input', { type: 'checkbox', checked: start !== 'zu' && start !== '' })
geoeffnet.dataset.feld = pfad
const art = h('select', { 'aria-label': `${label}: Art der Öffnung` }, ART_WAHL.map(([wert, text]) => h('option', { value: wert, selected: wert === (start === 'zu' || start === '' ? 'zeiten' : start) }, text)))
kopf = h('div', { class: 'tm-feld tm-feld-haken' }, h('label', { class: 'tm-haken' }, geoeffnet, h('span', null, h('strong', null, label), ' geöffnet')))
felder = h('div', { class: 'zt-tag-felder' }, art, zeiten)
modusWert = () => (geoeffnet.checked ? art.value : 'zu')
zeige = () => {
felder.hidden = !geoeffnet.checked
zeiten.hidden = !(geoeffnet.checked && art.value === 'zeiten')
}
geoeffnet.addEventListener('change', zeige)
art.addEventListener('change', zeige)
} else {
const id = `zt-t${++idZaehler}`
const auswahl = h('select', { id }, wahl.map(([wert, text]) => h('option', { value: wert, selected: wert === start }, text)))
auswahl.dataset.feld = pfad
kopf = h('div', { class: 'tm-feld' }, h('label', { for: id, class: sichtbar ? 'tm-label' : 'tm-sr' }, sichtbar ? 'Öffnung' : `${label}: Öffnung`), auswahl)
felder = h('div', { class: 'zt-tag-felder' }, zeiten)
modusWert = () => auswahl.value
zeige = () => { zeiten.hidden = auswahl.value !== 'zeiten' }
auswahl.addEventListener('change', zeige)
}
zeige()
const el = h('div', { class: 'zt-tag' }, kopf, felder,
h('p', { class: 'tm-fehler', 'data-fehler': pfad, role: 'alert' }),
fensterPfad !== pfad ? h('p', { class: 'tm-fehler', 'data-fehler': fensterPfad, role: 'alert' }) : null)
function lies() {
const art = modusWert()
if (art !== 'zeiten') return { modus: art, fenster: [] }
const gelesen = []
for (const paar of paare) {
const von = paar.von.value
const bis = paar.bis.value
if (!von && !bis) continue
if (!von || !bis) return { fehler: { [fensterPfad]: 'Bitte gib Beginn und Ende des Zeitfensters an.' } }
gelesen.push([von, bis])
}
return { modus: art, fenster: gelesen }
}
return { el, lies }
}
/* ----------------------------------------------------------------- Zeitplaene --- */
const LOESCHEN_WARNUNG = 'Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle.'
/** Der Plan mit dieser ID im aktuellen Stand (nach dem Speichern: mit dem Kurznamen des Servers). */
const planMitId = (ctx, id) => (ctx.stand.daten?.plaene || []).find((p) => p?.id === id)
const planSelektor = (id, rest) => `details[data-plan="${CSS.escape(id)}"] ${rest}`
export function plaene(t) {
const { ctx } = t
const daten = ctx.stand.daten
const liste = daten ? daten.plaene : [ctx.entwurfsplan]
const neu = daten ? neuerEintrag({
nurLesen: ctx.nurLesen, text: '+ Plan hinzufügen', werkzeug: 'plan-neu',
voll: liste.length >= ZEITEN_LIMITS.plaene, grenzText: `Mehr als ${ZEITEN_LIMITS.plaene} Zeitpläne sind nicht möglich.`,
baue: (schliessen) => neuFormular(t, { schliessen }),
}) : null
return abschnitt('Zeitpläne',
'Wann hast du geöffnet? Der Hauptplan steuert den Live-Status und die Leisten auf deiner Website. Weitere Pläne (zum Beispiel für Küche oder Lieferdienst) bekommen einen eigenen Kurznamen, unter dem das Team sie auf deiner Website einbauen kann.',
neu?.leiste,
neu?.box,
h('div', { class: 'zt-plaene' }, liste.map((plan) => planKarte(t, plan))))
}
/** Formular „Plan hinzufügen“: Name und die Vorschau des Kurznamens (wie ihn der Server bildet; endgueltig ist der Wert aus der Antwort). */
function neuFormular(t, { schliessen }) {
const { ctx, sp } = t
const name = h('input', { type: 'text', maxlength: ZEITEN_MAX.name, autocomplete: 'off', placeholder: 'z. B. Küche oder Lieferdienst' })
const vorschau = h('code', { 'data-kurz-vorschau': '' }, '')
name.addEventListener('input', () => {
vorschau.textContent = name.value.trim() ? kurzname(name.value, ctx.stand.daten.plaene.map((p) => p.kurz)) : ''
})
const form = h('form', { class: 'kb-panel tm-formular', novalidate: true, 'aria-label': 'Plan hinzufügen' },
h('h3', null, 'Neuer Zeitplan'),
feld('name', 'Name des Zeitplans', name, 'Die Zeiten trägst du danach im neuen Plan ein.'),
h('p', { class: 'tm-hinweis' }, 'Kurzname: ', vorschau, ' – wird beim Speichern festgelegt und bleibt dann immer gleich.'),
allgemeinerFehler(),
h('div', { class: 'tm-frage-knoepfe' },
h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'plan-anlegen' }, 'Plan anlegen'),
h('button', { type: 'button', class: 'kb-btn-outline', onclick: schliessen }, 'Abbrechen')))
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const gewaehlt = name.value.trim()
let neuerPlan = null
const ok = await sp.speichere((entwurf) => {
if (!gewaehlt) {
zeigeFehler(form, { fehler: { name: 'Bitte gib dem Zeitplan einen Namen.' } })
return false
}
neuerPlan = leererPlan({ name: gewaehlt, kurz: kurzname(gewaehlt, entwurf.plaene.map((p) => p.kurz)) })
entwurf.plaene = [...entwurf.plaene, neuerPlan]
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
ctx.offen.add(neuerPlan.id)
const gespeichert = planMitId(ctx, neuerPlan.id) // Name und Kurzname gelten so, wie der Server sie gespeichert hat
nachSpeichern(t, `Der Zeitplan „${gespeichert?.name ?? gewaehlt}“ ist angelegt, sein Kurzname ist „${gespeichert?.kurz ?? neuerPlan.kurz}“. Trage jetzt seine Zeiten ein.`, planSelektor(neuerPlan.id, 'summary'))
})
return form
}
/** Ein Plan als aufklappbare Karte: Kopf (Name, Hauptplan, Kurzname) und das Formular mit Namen und Wochentagen. */
function planKarte(t, plan) {
const { ctx, sp } = t
const daten = ctx.stand.daten
const p = `plaene.${plan.id}`
const nurLesen = ctx.nurLesen
const details = h('details', { class: 'zt-plan', 'data-plan': plan.id, open: ctx.offen.has(plan.id) })
details.addEventListener('toggle', () => {
if (details.hasAttribute('open')) ctx.offen.add(plan.id)
else ctx.offen.delete(plan.id)
})
const name = h('input', { type: 'text', maxlength: ZEITEN_MAX.name, autocomplete: 'off', value: plan.name })
const kurzEl = h('code', { 'data-kurz': '' }, plan.kurz)
// Vor dem ersten Speichern bildet der Server den Kurznamen aus dem Namen von jetzt; danach bleibt er (der Name kann sich aendern, der Kurzname nicht)
if (!daten) name.addEventListener('input', () => { kurzEl.textContent = kurzname(name.value.trim() || plan.name) })
const tage = WOCHENTAGE_PLAN.map(([nr, tagName]) => ({
nr,
tag: tagFelder({ pfad: `${p}.tage.${nr}`, label: tagName, haken: true, modus: plan.tage?.[nr]?.modus, fenster: plan.tage?.[nr]?.fenster }),
}))
for (const { nr, tag } of tage) tag.el.dataset.tag = nr
/** Name und Tage aus dem Formular: { name, tage } oder { fehler } (Pfad => Text); wochenFehler zeigt, was der Server auch abweisen wuerde. */
function lies() {
const fehler = {}
const gewaehlt = name.value.trim()
if (!gewaehlt) fehler[`${p}.name`] = 'Bitte gib dem Zeitplan einen Namen.'
const gelesen = {}
for (const { nr, tag } of tage) {
const r = tag.lies()
if (r.fehler) Object.assign(fehler, r.fehler)
else gelesen[nr] = { modus: r.modus, fenster: r.fenster }
}
for (const [nr, text] of Object.entries(wochenFehler({ tage: gelesen }))) if (!Object.hasOwn(fehler, `${p}.tage.${nr}`)) fehler[`${p}.tage.${nr}`] = text
return Object.keys(fehler).length ? { fehler } : { name: gewaehlt, tage: gelesen }
}
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'speichern' }, 'Speichern')
const grund = daten && !nurLesen ? planLoeschenGrund(daten, plan.id) : null
const mitAusnahmen = daten ? (daten.ausnahmen || []).filter((a) => a?.plan === plan.id).length : 0
const entfernen = daten && !nurLesen && grund === null ? entfernenKnopf(sp, {
knopfText: 'Plan löschen',
ja: 'Ja, löschen',
titel: `Zeitplan „${plan.name}“ löschen?`,
text: [
LOESCHEN_WARNUNG,
mitAusnahmen === 0 ? '' : mitAusnahmen === 1
? 'Die eine Ausnahme, die nur für diesen Plan gilt, wird mit gelöscht.'
: `Die ${mitAusnahmen} Ausnahmen, die nur für diesen Plan gelten, werden mit gelöscht.`,
],
fehlerPfad: p,
// Ausnahmen, die nur fuer diesen Plan galten, haetten danach keinen Plan mehr (R10): sie gehen mit
aendere: (entwurf) => {
entwurf.plaene = entwurf.plaene.filter((x) => x.id !== plan.id)
entwurf.ausnahmen = (entwurf.ausnahmen || []).filter((a) => a?.plan !== plan.id)
},
nachher: () => {
ctx.offen.delete(plan.id)
nachSpeichern(t, `Der Zeitplan „${plan.name}“ ist gelöscht.`, '[data-werkzeug="plan-neu"]')
},
}) : null
const hauptKnopf = daten && !nurLesen && !plan.haupt ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'haupt' }, 'Zum Hauptplan machen') : null
const form = h('form', { class: 'zt-plan-form', novalidate: true, 'aria-label': `Zeitplan ${plan.name}` },
gesperrt(nurLesen, [
feld(`${p}.name`, 'Name des Zeitplans', name, daten
? 'Der Kurzname bleibt gleich, auch wenn du den Namen änderst.'
: 'Der Kurzname wird beim ersten Speichern aus dem Namen gebildet und bleibt dann immer gleich.'),
h('div', { class: 'zt-tage' }, tage.map(({ tag }) => tag.el)),
]),
allgemeinerFehler(),
nurLesen ? null : h('div', { class: 'tm-frage-knoepfe' }, speichern, hauptKnopf, entfernen?.knopf),
grund ? h('p', { class: 'tm-hinweis', 'data-hinweis': 'loeschen' }, grund) : null)
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const stelle = stelleVon(entwurf.plaene, plan.id, form, 'Dieser Zeitplan')
if (stelle < 0) return false
const gelesen = lies()
if (gelesen.fehler) {
zeigeFehler(form, { fehler: gelesen.fehler })
return false
}
const kurz = daten ? entwurf.plaene[stelle].kurz : kurzname(gelesen.name)
entwurf.plaene[stelle] = { ...entwurf.plaene[stelle], kurz, name: gelesen.name, tage: gelesen.tage }
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, `Die Zeiten von „${planMitId(ctx, plan.id)?.name ?? plan.name}“ sind gespeichert.`, planSelektor(plan.id, '[data-aktion="speichern"]'))
})
hauptKnopf?.addEventListener('click', async () => {
const ok = await sp.speichere((entwurf) => {
if (stelleVon(entwurf.plaene, plan.id, form, 'Dieser Zeitplan') < 0) return false
entwurf.plaene = hauptplanWechseln(entwurf, plan.id).plaene
return true
}, { container: form })
if (!ok || !sp.nochHier()) return
nachSpeichern(t, `Der Zeitplan „${planMitId(ctx, plan.id)?.name ?? plan.name}“ ist jetzt der Hauptplan. Er steuert den Live-Status und die Leisten auf deiner Website.`, planSelektor(plan.id, 'summary'))
})
details.append(
h('summary', { class: 'zt-plan-kopf' },
h('strong', { class: 'tm-name' }, plan.name),
plan.haupt ? h('span', { class: 'tm-pille tm-pille-ok' }, 'Hauptplan') : null,
h('span', { class: 'tm-meta' }, 'Kurzname: ', kurzEl)),
h('div', { class: 'tm-editor' }, form, entfernen?.box))
return details
}
+212
View File
@@ -0,0 +1,212 @@
/* Oeffnungszeiten live im Kundenbereich: Speichern des Editors – gemeinsam fuer alle Abschnitte (Zeitplaene, Feiertage, Ausnahmen und die
Abschnitte der Aufgabe 30). Jede Aenderung ist ein Speichern: PUT /api/zeiten/<projectId> schickt ALLE Daten mit der Version, auf der sie
beruhen (server/routes/zeiten.js). Der Abschnitt gibt nur an, was sich aendert: speichere(aendere, { container }) holt eine Kopie des
aktuellen Stands (ctx.stand.daten, vor dem ersten Speichern ctx.leereDaten()), laesst aendere(entwurf) sie aendern und schickt sie ab.
Muster: termine-speichern.js (neuerSpeicher), dort nichts geaendert.
- Erfolg: ctx.uebernimm(antwort, { erstes }) setzt den neuen Stand (Antwort des Servers: daten, version, feiertage, vorschau …); Rueckgabe true.
- 400: Fehler stehen am Feld (data-fehler = Pfad mit der ID des Eintrags, z. B. 'plaene.p_ab12cd34.tage.3', 'ausnahmen.x_ab12cd34.von',
'feiertage.2026-12-25'); Pfade ohne Platz im container erscheinen allgemein, mit dem Namen des Eintrags davor. Rueckgabe false.
- 409 (ZEITEN_VERALTET, jemand hat inzwischen gespeichert): Hinweis mit Knopf „Neu laden“ oben im Editor. Rueckgabe false.
- alles andere (429 zu viele Speichervorgaenge, 403, 500, kein Netz): Meldung ueber dem Editor (ctx.meldung). Rueckgabe false.
- Es laeuft immer nur ein Speichern (PUT und das Uebernehmen): solange es laeuft, ist der Editor aria-busy, alle seine Knoepfe sind gesperrt, und
ein weiterer Aufruf von speichere (z. B. ein Enter im Formular) wird abgewiesen (Rueckgabe false), statt mit einer veralteten Version zu gehen.
Der Abschnitt ruft speichere nur, wenn ctx.nurLesen nicht gesetzt ist. Texte des Servers kommen nur als Text in die Seite (h, textContent).
ctx (baut zeiten-editor.js): { projectId, stand, aktuell(), neuLaden(), meldung(text, art), uebernimm(antwort, { erstes }) }. */
import { allgemeinerFehler, frage, h, knopfZeile, request, setzeFehler, zeigeFehler } from './termine-dom.js'
import { datumKurz } from './termine-view.js'
export const VERALTET_TEXT = 'Inzwischen geändert – bitte neu laden.'
const WOCHENTAGE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
const istTagText = (tag) => typeof tag === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(tag)
const finde = (liste, id) => (Array.isArray(liste) ? liste.find((eintrag) => eintrag?.id === id) : undefined)
/**
* „Zeitplan „Küche“, Dienstag: Text“ aus Pfad und Text eines Fehlers. Eintraege finden sich per ID in den Daten (quellen: bisheriger Stand und
* Entwurf); eine ID ist nie ein Schluessel eines Objekts (IDs wie „__proto__“ sind gewoehnliche Texte).
*/
export function beschrifteFehler(pfad, text, quellen) {
const teile = pfad.split('.')
const suche = (name, id) => {
for (const quelle of quellen) {
const eintrag = /^\d+$/.test(id) ? quelle?.[name]?.[Number(id)] : finde(quelle?.[name], id)
if (eintrag) return eintrag
}
return undefined
}
const mit = (wer) => `${wer}: ${text}`
switch (teile[0]) {
case 'plaene': {
const plan = teile[1] ? suche('plaene', teile[1]) : undefined
if (!plan?.name) return text
const tag = teile[2] === 'tage' && /^[1-7]$/.test(teile[3] || '') ? `, ${WOCHENTAGE[Number(teile[3]) - 1]}` : ''
return mit(`Zeitplan „${plan.name}“${tag}`)
}
case 'ausnahmen': {
const ausnahme = teile[1] ? suche('ausnahmen', teile[1]) : undefined
if (!ausnahme) return text
const wann = istTagText(ausnahme.von) ? ` am ${datumKurz(ausnahme.von)}` : ''
return mit(`${ausnahme.art === 'sondertag' ? 'Sondertag' : 'Betriebsurlaub'}${wann}${ausnahme.text ? ` („${ausnahme.text}“)` : ''}`)
}
case 'hinweise': {
const hinweis = teile[1] ? suche('hinweise', teile[1]) : undefined
return hinweis?.text ? mit(`Hinweis „${hinweis.text}“`) : text
}
case 'feiertage':
return istTagText(teile[1]) ? mit(`Feiertag ${datumKurz(teile[1])}`) : text
case 'kontakt':
return mit('Kontakt-Leiste')
case 'land':
return mit('Bundesland')
default:
return text
}
}
/** Fehler des Servers am Feld; was im container keinen Platz hat, steht allgemein (mit dem Namen des Eintrags davor). */
function zeigeFelder(container, fehler, quellen) {
const amFeld = {}
const rest = []
for (const [pfad, text] of Object.entries(fehler)) {
if (pfad !== '_' && container.querySelector(`[data-fehler="${CSS.escape(pfad)}"]`)) amFeld[pfad] = text
else rest.push(beschrifteFehler(pfad, text, quellen))
}
setzeFehler(container, rest.length ? { ...amFeld, _: rest.join(' ') } : amFeld)
container.querySelector('[aria-invalid="true"]')?.focus()
}
/**
* Speicher des Editors. wurzel = das Element des Editors (verschwindet beim Wechsel der Website: danach fasst nichts mehr die Seite an),
* neuZeichnen() zeichnet den Editor aus ctx.stand neu (nach „Neu laden“).
* => { konflikt, speichere, exklusiv, nochHier }: konflikt ist das Hinweis-Element (der Editor setzt es an den Anfang), nochHier() sagt, ob der
* Editor noch angezeigt wird (vor jedem Anfassen der Seite nach einem await pruefen).
*/
export function neuerZeitenSpeicher(ctx, wurzel, neuZeichnen) {
const nochHier = () => wurzel.isConnected && ctx.aktuell()
const text = h('span', null, VERALTET_TEXT)
const neuLadenKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'neu-laden' }, 'Neu laden')
const konflikt = h('div', { class: 'tm-box tm-box-warn', role: 'alert', hidden: true }, text, neuLadenKnopf)
neuLadenKnopf.addEventListener('click', async () => {
neuLadenKnopf.disabled = true
try {
await ctx.neuLaden()
if (!nochHier()) return
text.textContent = VERALTET_TEXT
konflikt.hidden = true
neuZeichnen()
} catch (err) {
if (nochHier()) text.textContent = err.message
} finally {
neuLadenKnopf.disabled = false
}
})
// Ein Speichern zur Zeit: waehrenddessen aria-busy am Editor und alle Knoepfe darin gesperrt (danach wieder frei, der Fokus kehrt zurueck).
let laeuft = false
function sperre(an, gesperrt) {
if (an) {
wurzel.setAttribute('aria-busy', 'true')
const knoepfe = [...wurzel.querySelectorAll('button:not(:disabled)')]
const fokus = document.activeElement
for (const knopf of knoepfe) knopf.disabled = true
return { knoepfe, fokus }
}
wurzel.removeAttribute('aria-busy')
for (const knopf of gesperrt.knoepfe) knopf.disabled = false
// Der Fokus war auf einem gesperrten Knopf und ist weg: zurueck dorthin; hat der Abschnitt ihn inzwischen anders gesetzt (z. B. auf ein Feld mit Fehler), bleibt das so
const jetzt = document.activeElement
if (gesperrt.knoepfe.includes(gesperrt.fokus) && gesperrt.fokus.isConnected && (!jetzt || jetzt === document.body || jetzt === gesperrt.fokus)) gesperrt.fokus.focus()
return null
}
/** arbeit() exklusiv ausfuehren; laeuft schon etwas, passiert nichts (Rueckgabe undefined). */
async function exklusiv(arbeit) {
if (laeuft) return undefined
laeuft = true
const gesperrt = sperre(true)
try {
return await arbeit()
} finally {
laeuft = false
sperre(false, gesperrt)
}
}
async function speichere(aendere, { container }) {
const ergebnis = await exklusiv(async () => {
setzeFehler(container, {})
konflikt.hidden = true
const bisher = ctx.stand.daten
const entwurf = structuredClone(bisher ?? ctx.leereDaten())
if (aendere(entwurf) === false) return false // der Abschnitt hat selbst Fehler am Feld gezeigt
try {
const antwort = await request(`/api/zeiten/${encodeURIComponent(ctx.projectId)}`, { method: 'PUT', body: { version: ctx.stand.version, daten: entwurf } })
if (!ctx.aktuell()) return false
ctx.uebernimm(antwort, { erstes: bisher === null })
return true
} catch (err) {
if (!nochHier()) return false
if (err.code === 'ZEITEN_VERALTET') {
text.textContent = VERALTET_TEXT
konflikt.hidden = false
konflikt.scrollIntoView({ block: 'nearest' })
} else if (err.status === 400 && Object.keys(err.fehler || {}).length) {
zeigeFelder(container, err.fehler, [bisher, entwurf])
} else {
ctx.meldung(err.message, 'fehler')
}
return false
}
})
return ergebnis === true
}
return { konflikt, speichere, exklusiv, nochHier }
}
/**
* Stelle des Eintrags mit dieser ID in der Liste des Entwurfs. Formulare merken sich keine Stelle, sondern die ID und suchen sie beim
* Absenden. Fehlt der Eintrag, steht am Formular eine Meldung und die Rueckgabe ist -1.
*/
export function stelleVon(liste, id, form, was) {
const stelle = (Array.isArray(liste) ? liste : []).findIndex((eintrag) => eintrag?.id === id)
if (stelle < 0) zeigeFehler(form, { fehler: { _: `${was} gibt es nicht mehr. Bitte lade die Seite neu.` } })
return stelle
}
/**
* Nach einem erfolgreichen Speichern: Abschnitte neu zeichnen, Meldung zeigen, Fokus auf das Element mit selektor (Regel: nach dem Speichern
* auf den Knopf der gespeicherten Zeile, nach dem Entfernen auf den „+“-Knopf der Liste).
*/
export function nachSpeichern(t, meldung, selektor) {
t.zeichne()
t.ctx.meldung(meldung)
t.wurzel.querySelector(selektor)?.focus()
}
/**
* Knopf mit Rueckfrage in der Seite (kein confirm()): Titel, Erklaerung (text, ein Text oder eine Liste von Absaetzen), Platz fuer den Fehler
* des Servers, Knoepfe. aendere(entwurf) entfernt den Eintrag, nachher() laeuft nach dem erfolgreichen Speichern (der Abschnitt zeichnet neu).
* Abbrechen schliesst die Rueckfrage und der Fokus geht zum Knopf zurueck. => { knopf, box }: der Aufrufer setzt beide NEBEN sein Formular, nicht
* hinein (ein submit der Rueckfrage stiege sonst ins aeussere Formular auf).
*/
export function entfernenKnopf(sp, { knopfText = 'Entfernen', ja = 'Ja, entfernen', titel, text, fehlerPfad, aendere, nachher }) {
const box = h('div', { class: 'tm-frage' })
const knopf = h('button', { type: 'button', class: 'kb-btn-outline tm-gefahr', 'data-aktion': 'entfernen' }, knopfText)
knopf.addEventListener('click', () => {
const { zeile } = knopfZeile(ja, () => { box.replaceChildren(); knopf.focus() })
const absaetze = (Array.isArray(text) ? text : [text]).filter(Boolean).map((t) => h('p', null, t))
const rueckfrage = frage(titel,
...absaetze,
fehlerPfad ? h('p', { class: 'tm-fehler', 'data-fehler': fehlerPfad, role: 'alert' }) : null,
allgemeinerFehler(),
zeile)
rueckfrage.addEventListener('submit', async () => {
if (await sp.speichere(aendere, { container: rueckfrage }) && sp.nochHier()) nachher()
})
box.replaceChildren(rueckfrage)
rueckfrage.focus()
})
return { knopf, box }
}
+48 -1
View File
@@ -1,4 +1,4 @@
/* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js; der Editor kommt mit den Aufgaben 29 und 30 dazu). /* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js und der Editor: zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js).
Farben und Abstände aus den Tokens von kundenbereich.css (.kb-page). Die Bausteine der Bestell- und Statusseiten (tm-bestellen, tm-statuskarte, Farben und Abstände aus den Tokens von kundenbereich.css (.kb-page). Die Bausteine der Bestell- und Statusseiten (tm-bestellen, tm-statuskarte,
tm-rechtstext, tm-haken, tm-pille, tm-box, tm-meldung, tm-info …) stehen in termine.css und gelten hier mit; neu sind nur Klassen mit zt- tm-rechtstext, tm-haken, tm-pille, tm-box, tm-meldung, tm-info …) stehen in termine.css und gelten hier mit; neu sind nur Klassen mit zt-
und die Regeln für #view-zeiten. Handy: bis 880px wie die App-Ansicht, Tippflächen mindestens 44px. */ und die Regeln für #view-zeiten. Handy: bis 880px wie die App-Ansicht, Tippflächen mindestens 44px. */
@@ -21,12 +21,59 @@
.zt-status .tm-box { margin-bottom: 0; } .zt-status .tm-box { margin-bottom: 0; }
.zt-status .tm-box .kb-btn-outline { flex: none; } .zt-status .tm-box .kb-btn-outline { flex: none; }
/* Editor: Zeitplaene, Feiertage, Ausnahmen (zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js) */
.zt-editor > * + * { margin-top: 16px; }
.zt-editor > .tm-meldung:empty, .zt-editor > .tm-box[hidden] { margin: 0; }
.zt-kopfbereich:empty { display: none; }
.zt-spaeter { margin: 0 4px; line-height: 1.5; }
.zt-plaene { display: grid; gap: 12px; }
.zt-plan { border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
.zt-plan[open] { border-color: var(--kb-purple-line); }
.zt-plan-kopf { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-height: 44px; padding: 12px 16px; list-style: none; cursor: pointer; }
.zt-plan-kopf::-webkit-details-marker { display: none; }
.zt-plan-kopf::before { content: '▸'; flex: none; color: var(--kb-muted); transition: transform 0.15s; }
.zt-plan[open] > .zt-plan-kopf::before { transform: rotate(90deg); }
.zt-plan-kopf .tm-name { display: inline; }
.zt-plan-kopf .tm-meta { display: inline; margin: 0; }
.zt-plan-kopf code, .tm-formular code { padding: 1px 6px; border-radius: 6px; background: rgba(255, 255, 255, 0.07); font-size: 12.5px; }
.zt-plan > .tm-editor { margin: 0; padding: 0 16px 16px; }
.zt-plan-form .tm-frage-knoepfe { margin-top: 16px; }
.zt-plan-form > .tm-hinweis { margin-top: 10px; }
.zt-werkzeuge { display: flex; flex-wrap: wrap; gap: 0 10px; }
.zt-werkzeuge:empty { display: none; }
.zt-editor .tm-abschnitt > .tm-frage-box { margin: 12px 0 16px; }
.zt-tage { display: grid; gap: 10px; margin-top: 14px; }
.zt-tag { min-width: 0; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.02); }
.zt-tag > .tm-feld { margin-top: 0; }
.zt-tag-felder { display: grid; gap: 10px; margin-top: 8px; }
.zt-tag-felder[hidden], .zt-zeiten[hidden] { display: none; }
.zt-tag select, .zt-feiertag select {
width: 100%; max-width: 360px; box-sizing: border-box; padding: 10px 12px; border-radius: 12px; font: inherit; font-size: 16px;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
}
.zt-tag select option { color: #111; }
.zt-zeiten { display: grid; gap: 8px; }
.zt-fenster { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; }
.zt-fenster-name { min-width: 11rem; font-size: 13px; color: var(--kb-muted); }
.zt-zeit { display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; }
.zt-zeit input {
width: 8.5rem; min-height: 40px; box-sizing: border-box; padding: 8px 10px; border-radius: 12px; font: inherit; font-size: 16px; font-variant-numeric: tabular-nums;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit;
}
.zt-tag [aria-invalid='true'] { border-color: #f87171; }
.zt-tag > .tm-fehler { margin-top: 8px; }
.zt-feiertag .zt-tag { margin-top: 10px; padding: 0; border: 0; background: transparent; }
@media (max-width: 880px), (pointer: coarse) { @media (max-width: 880px), (pointer: coarse) {
#view-zeiten button, #view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 44px; } #view-zeiten button, #view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 44px; }
#view-zeiten .tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; } #view-zeiten .tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
#view-zeiten .tm-haken input { margin-top: 0; } #view-zeiten .tm-haken input { margin-top: 0; }
#view-zeiten .zt-zeit input, #view-zeiten .zt-tag select, #view-zeiten .zt-feiertag select { min-height: 44px; }
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.zt-fenster-name { min-width: 100%; }
.zt-zeit input { width: 7.75rem; }
.zt-plan > .tm-editor { padding: 0 12px 14px; }
.zt-bestellen .tm-bestellen-aktion { justify-items: stretch; } .zt-bestellen .tm-bestellen-aktion { justify-items: stretch; }
.zt-status .tm-box .kb-btn-outline { width: 100%; } .zt-status .tm-box .kb-btn-outline { width: 100%; }
.zt-status .tm-aktionen > * { flex: 1 1 auto; } .zt-status .tm-aktionen > * { flex: 1 1 auto; }
+32 -7
View File
@@ -12,12 +12,16 @@
Rueckkehr von Stripe: #zeiten?bestellt=1&projekt=<id> (Bestellung) und #zeiten?behalten=1&projekt=<id> (Behalten) waehlen das Projekt und zeigen Rueckkehr von Stripe: #zeiten?bestellt=1&projekt=<id> (Bestellung) und #zeiten?behalten=1&projekt=<id> (Behalten) waehlen das Projekt und zeigen
„Danke!“; #zeiten?projekt=<id> (Abbruch) waehlt nur das Projekt. Beides wird einmal gelesen und aus der Adresse genommen. Die Kachel in Funktionen „Danke!“; #zeiten?projekt=<id> (Abbruch) waehlt nur das Projekt. Beides wird einmal gelesen und aus der Adresse genommen. Die Kachel in Funktionen
legt Projekt und Weg in den Behaelter ab (dataset.wunschProjekt, dataset.wunschWeg = 'behalten'). legt Projekt und Weg in den Behaelter ab (dataset.wunschProjekt, dataset.wunschWeg = 'behalten').
Der Editor (Aufgabe 29) haengt in .zt-editor ein (renderEditor(box, ctx) aus zeiten-editor.js). Ruft app.js initZeiten bei jedem Wechsel in die Der Editor haengt in .zt-editor ein (renderEditor(box, ctx) aus zeiten-editor.js). Ruft app.js initZeiten bei jedem Wechsel in die
Ansicht, baut sich nichts neu auf, solange Projekt und Stand gleich sind und der Editorplatz steht (Muster initNews): Eingaben im Editor bleiben stehen. Ansicht, baut sich nichts neu auf, solange Projekt und Stand gleich sind und der Editorplatz steht (Muster initNews): Eingaben im Editor bleiben stehen.
Aendert sich der Stand (zeitenLeer nach dem ersten Speichern, setup → active, die Abo-Lage), wird nur der Kopf (Statuskarte) neu gezeichnet: der Editorplatz
bleibt mit seinem Editor und den ungespeicherten Eingaben stehen, solange Website und Schreibrecht (abgeschaltet = nur lesen) gleich sind (data-projekt,
data-state); eine andere Website oder der Wechsel in „nur lesen“ bekommt einen frischen Editor. Die Rueckkehr von Stripe zeichnet immer ganz neu.
Kundenansicht (window.KbView.id() gesetzt): Einschalten, Bestellen und Behalten sind gesperrt, der Server antwortet dort ohnehin mit 403. */ Kundenansicht (window.KbView.id() gesetzt): Einschalten, Bestellen und Behalten sind gesperrt, der Server antwortet dort ohnehin mit 403. */
import { h, infoKarte, request } from './termine-dom.js' import { fehlerKarte, h, infoKarte, request } from './termine-dom.js'
import { behaltenLage, renderZeitenBestellen, zeitenStatusKarte } from './zeiten-bestellen.js' import { behaltenLage, renderZeitenBestellen, zeitenStatusKarte } from './zeiten-bestellen.js'
import { renderEditor } from './zeiten-editor.js'
// Auswahl bleibt beim Wechsel zwischen Funktionen und Zeiten erhalten // Auswahl bleibt beim Wechsel zwischen Funktionen und Zeiten erhalten
const auswahl = { projectId: '' } const auswahl = { projectId: '' }
@@ -67,8 +71,10 @@ export async function initZeiten(root, { projectId } = {}) {
const projects = Array.isArray(antwort.projects) ? antwort.projects : [] const projects = Array.isArray(antwort.projects) ? antwort.projects : []
const s = { const s = {
root, projects, angebot: antwort.zeiten || null, features: antwort.features || {}, ustHinweis: typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : '', root, projects, angebot: antwort.zeiten || null, features: antwort.features || {}, ustHinweis: typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : '',
projekt: null, lauf: 0, buehne: null, danke: '', projekt: null, lauf: 0, buehne: null, danke: '', editorAlt: null,
} }
// der Editorplatz der bisherigen Ansicht (mit seinem Editor) wird in den neuen Aufbau mitgenommen, ausser nach der Rueckkehr von Stripe
const alterEditor = !rueckkehr.bestellt && !rueckkehr.behalten && S?.root === root ? root.querySelector('.zt-editor') : null
if (!s.angebot?.verfuegbar) { if (!s.angebot?.verfuegbar) {
S = s S = s
root.dataset.zeitenKey = '' root.dataset.zeitenKey = ''
@@ -93,6 +99,7 @@ export async function initZeiten(root, { projectId } = {}) {
return return
} }
S = s S = s
s.editorAlt = alterEditor
s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'bestellt' s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'bestellt'
: rueckkehr.behalten && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'behalten' : '' : rueckkehr.behalten && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'behalten' : ''
s.buehne = h('div', { id: 'zeiten-buehne' }) s.buehne = h('div', { id: 'zeiten-buehne' })
@@ -178,12 +185,30 @@ function zeichneProjekt(s, { weg = '', meldung = '', bestellformular = false } =
if (meldungEl) teile.push(meldungEl) if (meldungEl) teile.push(meldungEl)
teile.push(kopf) teile.push(kopf)
const editorPlatz = (zustand === 'setup' || zustand === 'active' || (zustand === 'disabled' && projekt.zeitenLeer === false)) // Der Editorplatz steht in setup, active und (mit Daten) disabled. Ein Platz derselben Website mit gleichem Schreibrecht bleibt samt Editor stehen
? h('div', { class: 'zt-editor', 'data-state': zustand }) // (ungespeicherte Eingaben gehen nicht verloren, der Editor laedt nicht noch einmal); sonst gibt es einen frischen.
: null let editorPlatz = null
if (editorPlatz) teile.push(editorPlatz) let frisch = false
if (zustand === 'setup' || zustand === 'active' || (zustand === 'disabled' && projekt.zeitenLeer === false)) {
const vorhanden = s.editorAlt || s.buehne.querySelector('.zt-editor')
if (vorhanden && vorhanden.dataset.projekt === projekt.projectId && (vorhanden.dataset.state === 'disabled') === (zustand === 'disabled')) {
editorPlatz = vorhanden
editorPlatz.dataset.state = zustand
editorPlatz.hidden = false // die Behalten-Seite versteckt ihn; sie wird bei Bedarf unten wieder geoeffnet
} else {
editorPlatz = h('div', { class: 'zt-editor', 'data-state': zustand, 'data-projekt': projekt.projectId })
frisch = true
}
teile.push(editorPlatz)
}
s.editorAlt = null
s.buehne.replaceChildren(...teile) s.buehne.replaceChildren(...teile)
if (editorPlatz) s.root.dataset.zeitenKey = stand(projekt, s.angebot) if (editorPlatz) s.root.dataset.zeitenKey = stand(projekt, s.angebot)
if (frisch) {
// neuZeichnen gehoert zur jeweils aktuellen Sitzung (S): der Platz kann einen Neuaufbau der Ansicht ueberleben
const editorCtx = { projectId: projekt.projectId, kundenansicht: ctx.kundenansicht, neuZeichnen: (optionen) => (S ? neuZeichnen(S, optionen) : undefined) }
Promise.resolve(renderEditor(editorPlatz, editorCtx)).catch((err) => editorPlatz.replaceChildren(fehlerKarte(err?.message || 'Der Editor konnte nicht gezeichnet werden.')))
}
// die Meldung des Servers ersetzt die Seite, auf der der Kunde geklickt hat: dorthin schauen, nicht ins Leere // die Meldung des Servers ersetzt die Seite, auf der der Kunde geklickt hat: dorthin schauen, nicht ins Leere
if (meldungEl) { if (meldungEl) {
meldungEl.focus({ preventScroll: true }) meldungEl.focus({ preventScroll: true })
+33 -13
View File
@@ -19,10 +19,21 @@ const STRIPE = 'https://checkout.stripe.test/c/pay/cs_test_1'
const KNOPF_BEZAHLT = REGISTER.consent.knopf const KNOPF_BEZAHLT = REGISTER.consent.knopf
const HAKEN_BEZAHLT = REGISTER.consent.items.filter((item) => item.haken).map((item) => item.id) const HAKEN_BEZAHLT = REGISTER.consent.items.filter((item) => item.haken).map((item) => item.id)
// Der Editor im Editorplatz (public/zeiten-editor.js, eigene Tests in test/zeitenEditor.test.js) lädt GET /api/zeiten/<id>: hier mit fester Antwort
// (noch nichts gespeichert), ohne die Warteschlange der Antworten und fx.fetchLog zu berühren; fx.editorAufrufe zählt diese Abrufe.
const EDITOR_STAND = { state: 'active', daten: null, version: 0, kundenansicht: false, vorschau: null, feiertage: [], googleText: '' }
const mitDom = (optionen, fn) => async () => { const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen) const fx = installFakeDom(optionen)
const echtes = globalThis.fetch
fx.editorAufrufe = []
globalThis.fetch = async (url, anfrage) => {
if (!String(url).startsWith('/api/zeiten/')) return echtes(url, anfrage)
fx.editorAufrufe.push(String(url))
return { ok: true, status: 200, json: async () => structuredClone(EDITOR_STAND) }
}
try { try {
await fn(fx) await fn(fx)
await fx.ruhe() // ein Editor, der noch lädt, soll fertig sein, bevor das Fake-DOM geht
} finally { } finally {
fx.restore() fx.restore()
} }
@@ -703,11 +714,14 @@ test('Ansicht: der Editorplatz steht nur in setup, active und (mit Daten) disabl
assert.equal(await platz(projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: true })), null) assert.equal(await platz(projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: true })), null)
})) }))
test('app.js ruft initZeiten bei jedem Wechsel in die Ansicht: gleiches Projekt und gleicher Stand bauen den Editorplatz nicht neu auf, ein neuer Stand oder die Rückkehr von Stripe schon', mitDom({}, async (fx) => { test('app.js ruft initZeiten bei jedem Wechsel in die Ansicht: gleiches Projekt und gleicher Stand bauen den Editorplatz nicht neu auf, ein neuer Stand zeichnet nur den Kopf neu, die Rückkehr von Stripe alles', mitDom({}, async (fx) => {
const aktiv = () => liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true)) const aktiv = () => liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true))
fx.antworten.push(aktiv()) fx.antworten.push(aktiv())
const root = await oeffne(fx) const root = await oeffne(fx)
let editor = fx.finde(root, '.zt-editor') await fx.ruhe() // der Editor lädt (fester Stand, siehe mitDom)
const editor = fx.finde(root, '.zt-editor')
assert.deepEqual(fx.editorAufrufe, ['/api/zeiten/p1'])
assert.ok(fx.finde(editor, 'details[data-plan]'), 'der Editor steht im Platz')
const eingabe = h('input', { type: 'text' }) const eingabe = h('input', { type: 'text' })
eingabe.value = 'ungespeichert' eingabe.value = 'ungespeichert'
editor.append(eingabe) editor.append(eingabe)
@@ -716,7 +730,7 @@ test('app.js ruft initZeiten bei jedem Wechsel in die Ansicht: gleiches Projekt
fx.antworten.push(aktiv()) fx.antworten.push(aktiv())
await initZeiten(root, { projectId: 'p1' }) await initZeiten(root, { projectId: 'p1' })
assert.equal(fx.finde(root, '.zt-editor'), editor, 'derselbe Editorplatz') assert.equal(fx.finde(root, '.zt-editor'), editor, 'derselbe Editorplatz')
assert.equal(editor.firstChild, eingabe) assert.equal(eingabe.parentNode, editor)
assert.equal(eingabe.value, 'ungespeichert') assert.equal(eingabe.value, 'ungespeichert')
assert.equal(fx.fetchLog.length, 2) assert.equal(fx.fetchLog.length, 2)
assert.equal(fx.fetchLog.every((a) => a.method === 'GET' && a.url === '/api/funktionen'), true, 'keine Zusatzabfrage') assert.equal(fx.fetchLog.every((a) => a.method === 'GET' && a.url === '/api/funktionen'), true, 'keine Zusatzabfrage')
@@ -727,35 +741,41 @@ test('app.js ruft initZeiten bei jedem Wechsel in die Ansicht: gleiches Projekt
await initZeiten(root) await initZeiten(root)
assert.equal(fx.finde(root, '.zt-editor'), editor) assert.equal(fx.finde(root, '.zt-editor'), editor)
// die Abo-Lage hat sich geändert (Behalten gibt es nur ohne Abo): die Karte muss stimmen, also neu aufgebaut // die Abo-Lage hat sich geändert (Behalten gibt es nur ohne Abo): die Karte muss stimmen, also wird der Kopf neu gezeichnet – der Editor bleibt mit seinen Eingaben
const kopf = fx.finde(root, '.zt-kopf')
fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo'))) fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo')))
await initZeiten(root) await initZeiten(root)
assert.notEqual(fx.finde(root, '.zt-editor'), editor) assert.equal(fx.finde(root, '.zt-editor'), editor, 'der Editorplatz bleibt stehen')
editor = fx.finde(root, '.zt-editor') assert.equal(eingabe.value, 'ungespeichert', 'ungespeicherte Eingaben gehen nicht verloren')
assert.notEqual(fx.finde(root, '.zt-kopf'), kopf, 'der Kopf ist neu gezeichnet')
assert.equal(root.dataset.zeitenKey.includes('kein_abo'), true, 'der neue Stand gilt')
// „Für 30 € behalten“ aus der Kachel: Behalten-Seite; der Stand hat sich geändert (aboEndeAt), also neu aufgebaut // „Für 30 € behalten“ aus der Kachel: Behalten-Seite; der Stand hat sich geändert (aboEndeAt), der Editor bleibt auch dann
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo'))) fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root.dataset.wunschWeg = 'behalten' root.dataset.wunschWeg = 'behalten'
await initZeiten(root) await initZeiten(root)
assert.notEqual(fx.finde(root, '.zt-editor'), editor) assert.equal(fx.finde(root, '.zt-editor'), editor)
assert.equal(editor.hidden, true, 'die Behalten-Seite versteckt den Editor')
assert.ok(fx.finde(root, '[data-variante="behalten"]')) assert.ok(fx.finde(root, '[data-variante="behalten"]'))
// gleicher neuer Stand noch einmal mit Weg: der Editor bleibt, die Seite öffnet // gleicher neuer Stand noch einmal mit Weg: der Editor bleibt, die Seite öffnet
const editor2 = fx.finde(root, '.zt-editor')
await fx.klick(knopf(fx, root, 'zurueck')) await fx.klick(knopf(fx, root, 'zurueck'))
assert.equal(editor.hidden, false)
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo'))) fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
root.dataset.wunschWeg = 'behalten' root.dataset.wunschWeg = 'behalten'
await initZeiten(root) await initZeiten(root)
assert.equal(fx.finde(root, '.zt-editor'), editor2, 'der Editor bleibt') assert.equal(fx.finde(root, '.zt-editor'), editor, 'der Editor bleibt')
assert.equal(editor2.hidden, true) assert.equal(editor.hidden, true)
assert.ok(fx.finde(root, '[data-variante="behalten"]')) assert.ok(fx.finde(root, '[data-variante="behalten"]'))
assert.deepEqual(fx.editorAufrufe, ['/api/zeiten/p1'], 'der Editor hat in der ganzen Zeit nur einmal geladen')
// Rückkehr von Stripe: immer neu // Rückkehr von Stripe: immer neu
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo'))) fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
fx.window.location.hash = '#zeiten?behalten=1&projekt=p1' fx.window.location.hash = '#zeiten?behalten=1&projekt=p1'
await initZeiten(root) await initZeiten(root)
assert.notEqual(fx.finde(root, '.zt-editor'), editor2) assert.notEqual(fx.finde(root, '.zt-editor'), editor)
assert.ok(fx.finde(root, '[data-danke="behalten"]')) assert.ok(fx.finde(root, '[data-danke="behalten"]'))
assert.equal(fx.editorAufrufe.length, 2, 'ein frischer Editor lädt')
})) }))
test('Fremdtext (Projektname, Meldung des Servers) kommt nur als Text in die Seite', mitDom({}, async (fx) => { test('Fremdtext (Projektname, Meldung des Servers) kommt nur als Text in die Seite', mitDom({}, async (fx) => {
@@ -819,7 +839,7 @@ test('Portal: Ansicht #zeiten ist in app.js und dashboard.html verdrahtet', () =
test('Quelltext: zeiten.js und zeiten-bestellen.js importieren nur relativ und nur das Nötige; kein HTML aus Daten, kein Speicher, Du-Form', () => { test('Quelltext: zeiten.js und zeiten-bestellen.js importieren nur relativ und nur das Nötige; kein HTML aus Daten, kein Speicher, Du-Form', () => {
const importe = (datei) => [...ohneKommentare(lesen(`public/${datei}`)).matchAll(/\bfrom\s+'([^']+)'/g)].map((m) => m[1]) const importe = (datei) => [...ohneKommentare(lesen(`public/${datei}`)).matchAll(/\bfrom\s+'([^']+)'/g)].map((m) => m[1])
assert.deepEqual(importe('zeiten.js'), ['./termine-dom.js', './zeiten-bestellen.js']) assert.deepEqual(importe('zeiten.js'), ['./termine-dom.js', './zeiten-bestellen.js', './zeiten-editor.js'])
assert.deepEqual(importe('zeiten-bestellen.js'), ['./funktion-bestellen.js', './termine-dom.js']) assert.deepEqual(importe('zeiten-bestellen.js'), ['./funktion-bestellen.js', './termine-dom.js'])
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^zeiten.*\.js$/.test(f)) const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^zeiten.*\.js$/.test(f))
for (const datei of ['zeiten.js', 'zeiten-bestellen.js']) { for (const datei of ['zeiten.js', 'zeiten-bestellen.js']) {
+961
View File
@@ -0,0 +1,961 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { config } from '../server/config.js'
import { oeffentlicheAnsicht } from '../server/services/featureRegistry.js'
import { h } from '../public/termine-dom.js'
import { heuteBerlin, tagPlus } from '../public/termine-view.js'
import { ZEITEN_LIMITS, hinweisVorschlag } from '../public/zeiten-view.js'
import { initZeiten } from '../public/zeiten.js'
// Namensraeume statt benannter Importe: fehlt ein Export, scheitert nur der Test, der ihn braucht
import * as editor from '../public/zeiten-editor.js'
import * as speichern from '../public/zeiten-speichern.js'
// Editor der Öffnungszeiten live, Teil I (Task 29): Speichern, Zeitpläne, Feiertage, Ausnahmen (public/zeiten-editor.js, zeiten-speichern.js,
// zeiten-plaene.js, zeiten-ausnahmen.js; Spec 3.4 Bereiche 1 bis 3 und der Absatz „Speichern“). Fake-DOM und Fake-fetch aus
// test/support/fakeDom.js: jeder Aufruf steht in fx.fetchLog, die Antworten kommen der Reihe nach aus fx.antworten.
const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen)
try {
await fn(fx)
} finally {
fx.restore()
}
}
// ---------------------------------------------------------------------------------------------------------------- Fixtures
const HEUTE = heuteBerlin()
const P1 = 'p_haupt001'
const P2 = 'p_kueche02'
const WOCHE = ['1', '2', '3', '4', '5', '6', '7']
const wochenplan = (extra = {}) => Object.fromEntries(WOCHE.map((nr) => [nr, extra[nr] || { modus: 'zu', fenster: [] }]))
const werktags = () => wochenplan({
1: { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] },
2: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
3: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
4: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
5: { modus: 'zeiten', fenster: [['09:00', '16:00']] },
6: { modus: 'rund', fenster: [] },
})
const plan = (id, extra = {}) => ({ id, kurz: id === P1 ? 'oeffnungszeiten' : 'kueche', name: id === P1 ? 'Öffnungszeiten' : 'Küche', haupt: id === P1, tage: werktags(), ...extra })
const KONTAKT = { an: true, telefon: '496301123456', adresse: 'Hauptstraße 1, 67655 Kaiserslautern', whatsapp: { nummer: '4917112345678', starttext: 'Hallo!', bestaetigtAt: '2026-10-01T08:00:00.000Z' } }
const daten = (extra = {}) => ({ land: 'RP', plaene: [plan(P1)], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: KONTAKT, ...extra })
const FEIERTAGE = [
{ tag: '2026-12-24', name: 'Heiligabend', art: 'pruefen' },
{ tag: '2026-12-25', name: 'Erster Weihnachtstag', art: 'feiertag' },
{ tag: '2026-12-31', name: 'Silvester', art: 'pruefen' },
{ tag: '2027-01-01', name: 'Neujahr', art: 'feiertag' },
]
const stand = (d, extra = {}) => ({ state: 'active', daten: d, version: d ? 3 : 0, kundenansicht: false, vorschau: null, feiertage: FEIERTAGE, googleText: '', ...extra })
const antwort = (d, version = 4, extra = {}) => ({ status: 200, body: stand(d, { version, ...extra }) })
const fehler = (status, error, extra = {}) => ({ status, body: { error, ...extra } })
// ---------------------------------------------------------------------------------------------------------------- Hilfen
/** Den Editor in einen Platz mit data-state hängen und laden; die Antwort des GET steht als erste in der Warteschlange. */
async function oeffne(fx, { start = stand(daten()), state = 'active', kundenansicht, ctx = {} } = {}) {
const root = h('div', { class: 'zt-editor', 'data-state': state })
document.body.append(root)
fx.antworten.unshift({ status: 200, body: start })
const neuGezeichnet = []
await editor.renderEditor(root, { projectId: 'p1', kundenansicht, neuZeichnen: async (optionen) => { neuGezeichnet.push(optionen) }, ...ctx })
return { root, neuGezeichnet }
}
const puts = (fx) => fx.fetchLog.filter((a) => a.method === 'PUT')
const gets = (fx) => fx.fetchLog.filter((a) => a.method === 'GET')
const planKarte = (fx, root, id) => fx.finde(root, `details[data-plan="${id}"]`)
const tagZeile = (fx, root, id, nr) => fx.finde(planKarte(fx, root, id), `[data-tag="${nr}"]`)
const zeitFelder = (fx, zeile) => fx.alle(zeile, 'input[type="time"]')
const haken = (fx, zeile) => fx.finde(zeile, 'input[type="checkbox"]')
const art = (fx, zeile) => fx.finde(zeile, 'select')
const speichernKnopf = (fx, root, id) => fx.finde(planKarte(fx, root, id), '[data-aktion="speichern"]')
const abschnitt = (fx, root, key) => fx.finde(root, `[data-abschnitt="${key}"]`)
const meldung = (fx, root) => fx.text(fx.finde(root, '[data-meldung="editor"]'))
const fehlerAn = (fx, root, pfad) => fx.finde(root, `[data-fehler="${pfad}"]`)
const lesen = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
const optionen = (fx, select) => fx.alle(select, 'option').map((o) => o.value)
// ---------------------------------------------------------------------------------------------------------------- Leerer Start
test('leerer Start: „Trag deine Öffnungszeiten ein“ und ein leerer Hauptplan „Öffnungszeiten“ (Kurzname oeffnungszeiten)', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(null) })
assert.deepEqual(gets(fx).map((a) => a.url), ['/api/zeiten/p1'])
assert.match(fx.text(fx.finde(root, '[data-hinweis="leer"]')), /Trag deine Öffnungszeiten ein/)
const karten = fx.alle(root, 'details[data-plan]')
assert.equal(karten.length, 1, 'genau ein Plan')
const kopf = fx.finde(karten[0], 'summary')
assert.match(fx.text(kopf), /Öffnungszeiten/)
assert.match(fx.text(kopf), /Hauptplan/)
assert.equal(karten[0].hasAttribute('open'), true, 'der Hauptplan steht offen')
assert.equal(fx.text(fx.finde(karten[0], '[data-kurz]')), 'oeffnungszeiten')
const id = karten[0].getAttribute('data-plan')
assert.match(id, /^p_[a-z0-9]{8}$/)
assert.equal(fx.finde(karten[0], `[data-feld="plaene.${id}.name"]`).value, 'Öffnungszeiten')
for (const nr of WOCHE) {
const zeile = tagZeile(fx, root, id, nr)
assert.equal(haken(fx, zeile).checked, false, `Tag ${nr} ist geschlossen`)
}
assert.equal(fx.finde(karten[0], '[data-aktion="entfernen"]'), null, 'der einzige Plan lässt sich nie löschen')
assert.equal(fx.finde(root, '[data-werkzeug="plan-neu"]'), null, 'weitere Pläne erst nach dem ersten Speichern')
assert.equal(abschnitt(fx, root, 'feiertage'), null, 'Feiertage und Ausnahmen erst nach dem ersten Speichern')
assert.equal(abschnitt(fx, root, 'ausnahmen'), null)
assert.match(fx.text(root), /sobald deine Öffnungszeiten gespeichert sind/)
}))
test('erstes Speichern: PUT mit version 0 und dem Plan, danach erscheinen Feiertage und Ausnahmen und die Hülle wird neu gezeichnet', mitDom({}, async (fx) => {
const { root, neuGezeichnet } = await oeffne(fx, { start: stand(null) })
const id = fx.finde(root, 'details[data-plan]').getAttribute('data-plan')
await fx.klick(haken(fx, tagZeile(fx, root, id, '1')))
const [von1, bis1, von2, bis2] = zeitFelder(fx, tagZeile(fx, root, id, '1'))
await fx.setze(von1, '09:00')
await fx.setze(bis1, '12:30')
await fx.setze(von2, '13:30')
await fx.setze(bis2, '18:00')
const gespeichert = daten({ plaene: [plan(id, { tage: wochenplan({ 1: { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] } }) })], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } })
fx.antworten.push(antwort(gespeichert, 1))
await fx.klick(speichernKnopf(fx, root, id))
assert.equal(puts(fx).length, 1)
assert.equal(puts(fx)[0].url, '/api/zeiten/p1')
const body = puts(fx)[0].body
assert.equal(body.version, 0)
assert.deepEqual(Object.keys(body).sort(), ['daten', 'version'])
assert.equal(body.daten.land, 'RP')
assert.equal(body.daten.plaene.length, 1)
assert.deepEqual(body.daten.plaene[0], {
id, kurz: 'oeffnungszeiten', name: 'Öffnungszeiten', haupt: true,
tage: wochenplan({ 1: { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] } }),
})
assert.deepEqual([body.daten.feiertage, body.daten.ausnahmen, body.daten.hinweise], [{}, [], []])
assert.match(meldung(fx, root), /gespeichert/)
assert.equal(fx.finde(root, '[data-hinweis="leer"]'), null, 'nach dem Speichern kein „Trag ein“ mehr')
assert.ok(abschnitt(fx, root, 'feiertage'))
assert.ok(abschnitt(fx, root, 'ausnahmen'))
assert.ok(fx.finde(root, '[data-werkzeug="plan-neu"]'))
assert.equal(neuGezeichnet.length, 1, 'die Hülle holt die Liste neu (zeitenLeer wechselt)')
// der zweite Speichervorgang beruht auf der neuen Version
fx.antworten.push(antwort(gespeichert, 2))
await fx.klick(speichernKnopf(fx, root, id))
assert.equal(puts(fx)[1].body.version, 1)
assert.equal(neuGezeichnet.length, 1, 'nur das erste Speichern wechselt den Stand der Hülle')
}))
test('leerer Start: der Kurzname folgt dem Namen bis zum ersten Speichern (so bildet ihn der Server) und geht dann mit dem Plan hinaus', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(null) })
const id = fx.finde(root, 'details[data-plan]').getAttribute('data-plan')
assert.match(fx.text(planKarte(fx, root, id)), /beim ersten Speichern aus dem Namen gebildet/)
await fx.setze(fx.finde(planKarte(fx, root, id), `[data-feld="plaene.${id}.name"]`), 'Praxis Dr. Müller')
assert.equal(fx.text(fx.finde(planKarte(fx, root, id), '[data-kurz]')), 'praxis-dr-mueller')
fx.antworten.push(antwort(daten({ plaene: [plan(id, { name: 'Praxis Dr. Müller', kurz: 'praxis-dr-mueller' })] }), 1))
await fx.klick(speichernKnopf(fx, root, id))
assert.deepEqual([puts(fx)[0].body.daten.plaene[0].name, puts(fx)[0].body.daten.plaene[0].kurz], ['Praxis Dr. Müller', 'praxis-dr-mueller'])
assert.match(fx.text(planKarte(fx, root, id)), /Der Kurzname bleibt gleich, auch wenn du den Namen änderst/, 'danach ist er unveränderlich')
assert.equal(fx.text(fx.finde(planKarte(fx, root, id), '[data-kurz]')), 'praxis-dr-mueller')
}))
// ---------------------------------------------------------------------------------------------------------------- Wochentage
test('Wochentag: Haken geöffnet, zwei Zeitpaare (type time, step 300), Auswahl rund um die Uhr / nach Vereinbarung', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const montag = tagZeile(fx, root, P1, '1')
assert.match(fx.text(montag), /Montag/)
assert.match(fx.text(montag), /geöffnet/)
assert.equal(haken(fx, montag).checked, true)
const felder = zeitFelder(fx, montag)
assert.equal(felder.length, 4, 'zwei Fenster, je von und bis')
for (const feld of felder) {
assert.equal(feld.getAttribute('type'), 'time')
assert.equal(feld.getAttribute('step'), '300')
}
assert.deepEqual(felder.map((f) => f.value), ['09:00', '12:30', '13:30', '18:00'])
assert.deepEqual(optionen(fx, art(fx, montag)), ['zeiten', 'rund', 'vereinbarung'])
assert.match(fx.text(art(fx, montag)), /rund um die Uhr/)
assert.match(fx.text(art(fx, montag)), /nach Vereinbarung/)
// Dienstag hat nur ein Fenster, das zweite Paar ist leer; Samstag „rund“; Sonntag zu
assert.deepEqual(zeitFelder(fx, tagZeile(fx, root, P1, '2')).map((f) => f.value), ['09:00', '18:00', '', ''])
const samstag = tagZeile(fx, root, P1, '6')
assert.equal(art(fx, samstag).value, 'rund')
assert.equal(fx.finde(samstag, '.zt-zeiten').hidden, true, 'rund: keine Zeitfelder')
const sonntag = tagZeile(fx, root, P1, '7')
assert.equal(haken(fx, sonntag).checked, false)
assert.equal(fx.finde(sonntag, '.zt-tag-felder').hidden, true, 'geschlossen: keine Auswahl')
await fx.klick(haken(fx, sonntag))
assert.equal(fx.finde(sonntag, '.zt-tag-felder').hidden, false)
assert.equal(fx.finde(sonntag, '.zt-zeiten').hidden, false, 'geöffnet zu festen Zeiten zeigt die Zeitfelder')
await fx.setze(art(fx, sonntag), 'vereinbarung')
assert.equal(fx.finde(sonntag, '.zt-zeiten').hidden, true)
}))
test('Wochentag speichern: geschlossen, rund, nach Vereinbarung und feste Zeiten gehen als modus/fenster hinaus', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(haken(fx, tagZeile(fx, root, P1, '2'))) // Dienstag zu
await fx.setze(art(fx, tagZeile(fx, root, P1, '3')), 'vereinbarung')
await fx.klick(haken(fx, tagZeile(fx, root, P1, '7')))
await fx.setze(art(fx, tagZeile(fx, root, P1, '7')), 'rund')
const [von1] = zeitFelder(fx, tagZeile(fx, root, P1, '5'))
await fx.setze(von1, '08:30')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, root, P1))
const tage = puts(fx)[0].body.daten.plaene[0].tage
assert.deepEqual(tage['2'], { modus: 'zu', fenster: [] })
assert.deepEqual(tage['3'], { modus: 'vereinbarung', fenster: [] })
assert.deepEqual(tage['7'], { modus: 'rund', fenster: [] })
assert.deepEqual(tage['5'], { modus: 'zeiten', fenster: [['08:30', '16:00']] })
assert.deepEqual(tage['1'], { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] }, 'Unverändertes bleibt')
assert.deepEqual(Object.keys(tage).sort(), WOCHE)
}))
test('Wochentag: Fehler stehen vor dem Senden am Tag (Überschneidung, halbes Fenster, kein Fenster), nichts geht hinaus', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const fensterMitLuecke = tagZeile(fx, root, P1, '1')
await fx.setze(zeitFelder(fx, fensterMitLuecke)[2], '12:00') // 2. Fenster beginnt vor dem Ende des ersten
const halb = tagZeile(fx, root, P1, '2')
await fx.setze(zeitFelder(fx, halb)[2], '19:00') // zweites Paar nur halb
const leer = tagZeile(fx, root, P1, '3')
await fx.setze(zeitFelder(fx, leer)[0], '')
await fx.setze(zeitFelder(fx, leer)[1], '')
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(puts(fx).length, 0)
assert.match(fx.text(fehlerAn(fx, root, `plaene.${P1}.tage.1`)), /überschneiden/)
assert.match(fx.text(fehlerAn(fx, root, `plaene.${P1}.tage.2`)), /Beginn und Ende/)
assert.match(fx.text(fehlerAn(fx, root, `plaene.${P1}.tage.3`)), /mindestens ein Zeitfenster/)
assert.equal(fx.finde(root, `[data-feld="plaene.${P1}.tage.1"]`).getAttribute('aria-invalid'), 'true')
// ein leerer Name ist ein Fehler am Namen
await fx.setze(fx.finde(planKarte(fx, root, P1), `[data-feld="plaene.${P1}.name"]`), ' ')
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(puts(fx).length, 0)
assert.match(fx.text(fehlerAn(fx, root, `plaene.${P1}.name`)), /Namen/)
}))
test('Zeitfelder zeigen ein gespeichertes „24:00“ als 00:00 (type time kennt kein 24:00)', mitDom({}, async (fx) => {
const d = daten({ plaene: [plan(P1, { tage: wochenplan({ 1: { modus: 'zeiten', fenster: [['18:00', '24:00']] } }) })] })
const { root } = await oeffne(fx, { start: stand(d) })
assert.deepEqual(zeitFelder(fx, tagZeile(fx, root, P1, '1')).map((f) => f.value), ['18:00', '00:00', '', ''])
}))
// ---------------------------------------------------------------------------------------------------------------- Pläne
test('Plan hinzufügen: Name, Kurzname-Vorschau, PUT mit leerem Plan; nach dem Speichern gilt der Kurzname der Antwort (R33)', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const neu = fx.finde(root, '[data-werkzeug="plan-neu"]')
assert.match(fx.text(neu), /Plan hinzufügen/)
await fx.klick(neu)
const form = fx.finde(root, 'form[aria-label="Plan hinzufügen"]')
const name = fx.finde(form, 'input[type="text"]')
await fx.setze(name, 'Küche & Lieferdienst')
assert.match(fx.text(fx.finde(form, '[data-kurz-vorschau]')), /kueche-lieferdienst/)
await fx.setze(name, 'Küche')
assert.equal(fx.text(fx.finde(form, '[data-kurz-vorschau]')), 'kueche')
// der Server vergibt den Kurzname endgültig (reserviert die Namen gelöschter Pläne): hier „kueche-2“
// die Antwort trägt die ID des gesendeten Plans (der Server behält gültige IDs) mit dem Kurznamen des Servers
fx.antworten.push((aufruf) => antwort(daten({ plaene: [plan(P1), plan(aufruf.body.daten.plaene[1].id, { name: 'Küche', kurz: 'kueche-2', haupt: false, tage: wochenplan() })] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const neuerPlan = puts(fx)[0].body.daten.plaene[1]
assert.match(neuerPlan.id, /^p_[a-z0-9]{8}$/)
assert.deepEqual({ ...neuerPlan, id: 'x' }, { id: 'x', kurz: 'kueche', name: 'Küche', haupt: false, tage: wochenplan() })
assert.equal(puts(fx)[0].body.daten.plaene[0].haupt, true)
assert.match(meldung(fx, root), /Küche/)
assert.match(meldung(fx, root), /kueche-2/, 'die Meldung nennt den Kurznamen der Antwort')
const karte = planKarte(fx, root, neuerPlan.id)
assert.equal(fx.text(fx.finde(karte, '[data-kurz]')), 'kueche-2', 'angezeigt wird der Kurzname des Servers')
assert.equal(karte.hasAttribute('open'), true, 'der neue Plan steht zum Ausfüllen offen')
assert.equal(fx.finde(root, 'form[aria-label="Plan hinzufügen"]'), null, 'das Formular ist zu')
}))
test('Plan hinzufügen: ohne Namen kein Senden; bei fünf Plänen gesperrt mit Hinweis', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="plan-neu"]'))
const form = fx.finde(root, 'form[aria-label="Plan hinzufügen"]')
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 0)
assert.match(fx.text(fehlerAn(fx, form, 'name')), /Namen/)
const fuenf = daten({ plaene: ['p_aaaaaaa1', 'p_aaaaaaa2', 'p_aaaaaaa3', 'p_aaaaaaa4', 'p_aaaaaaa5'].map((id, i) => plan(id, { haupt: i === 0, kurz: `plan-${i}`, name: `Plan ${i}` })) })
const voll = await oeffne(fx, { start: stand(fuenf) })
const knopf = fx.finde(voll.root, '[data-werkzeug="plan-neu"]')
assert.equal(knopf.disabled, true)
assert.match(fx.text(abschnitt(fx, voll.root, 'plaene')), new RegExp(`Mehr als ${ZEITEN_LIMITS.plaene} Zeitpläne sind nicht möglich`))
}))
test('Hauptplan wechseln: Knopf nur an den anderen Plänen, PUT mit getauschtem Haupt', mitDom({}, async (fx) => {
const zwei = daten({ plaene: [plan(P1), plan(P2)] })
const { root } = await oeffne(fx, { start: stand(zwei) })
assert.equal(fx.finde(planKarte(fx, root, P1), '[data-aktion="haupt"]'), null, 'der Hauptplan hat den Knopf nicht')
assert.match(fx.text(fx.finde(planKarte(fx, root, P1), 'summary')), /Hauptplan/)
assert.doesNotMatch(fx.text(fx.finde(planKarte(fx, root, P2), 'summary')), /Hauptplan/)
const knopf = fx.finde(planKarte(fx, root, P2), '[data-aktion="haupt"]')
assert.match(fx.text(knopf), /Hauptplan/)
fx.antworten.push(antwort(daten({ plaene: [plan(P1, { haupt: false }), plan(P2, { haupt: true })] }), 4))
await fx.klick(knopf)
assert.deepEqual(puts(fx)[0].body.daten.plaene.map((p) => [p.id, p.haupt]), [[P1, false], [P2, true]])
assert.match(meldung(fx, root), /Küche.*Hauptplan/)
assert.match(fx.text(fx.finde(planKarte(fx, root, P2), 'summary')), /Hauptplan/)
assert.equal(fx.finde(planKarte(fx, root, P2), '[data-aktion="haupt"]'), null)
assert.ok(fx.finde(planKarte(fx, root, P1), '[data-aktion="haupt"]'))
}))
test('Plan löschen warnt („Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle.“) und entfernt auch die Ausnahmen des Plans (R10)', mitDom({}, async (fx) => {
const von = tagPlus(HEUTE, 20)
const ausnahmen = [
{ id: 'x_alle0001', art: 'urlaub', von, bis: von, text: 'Alle', modus: 'zu', fenster: [], plan: null },
{ id: 'x_kueche01', art: 'urlaub', von, bis: von, text: 'Nur Küche', modus: 'zu', fenster: [], plan: P2 },
{ id: 'x_kueche02', art: 'sondertag', von, bis: von, text: 'Küche offen', modus: 'rund', fenster: [], plan: P2 },
]
const d = daten({ plaene: [plan(P1), plan(P2)], ausnahmen })
const { root } = await oeffne(fx, { start: stand(d) })
assert.equal(fx.finde(planKarte(fx, root, P1), '[data-aktion="entfernen"]'), null, 'der Hauptplan lässt sich nicht löschen')
assert.match(fx.text(planKarte(fx, root, P1)), /Erst einen anderen Plan zum Hauptplan machen/)
const knopf = fx.finde(planKarte(fx, root, P2), '[data-aktion="entfernen"]')
assert.match(fx.text(knopf), /löschen/)
await fx.klick(knopf)
const frage = fx.finde(planKarte(fx, root, P2), '.tm-frage-box')
assert.ok(frage, 'die Rückfrage steht in der Seite')
assert.ok(fx.text(frage).includes('Steht dieser Plan auf deiner Website, zeigt sie dort wieder die feste Tabelle.'))
assert.match(fx.text(frage), /Die 2 Ausnahmen, die nur für diesen Plan gelten, werden mit gelöscht/)
assert.equal(puts(fx).length, 0, 'ohne Bestätigung passiert nichts')
fx.antworten.push(antwort(daten({ plaene: [plan(P1)], ausnahmen: [ausnahmen[0]] }), 4))
await fx.klick(fx.finde(frage, 'button[type="submit"]'))
const gesendet = puts(fx)[0].body.daten
assert.deepEqual(gesendet.plaene.map((p) => p.id), [P1])
assert.deepEqual(gesendet.ausnahmen.map((a) => a.id), ['x_alle0001'], 'die Ausnahmen des Plans gehen mit, die übrigen bleiben')
assert.equal(planKarte(fx, root, P2), null)
assert.match(meldung(fx, root), /gelöscht/)
}))
test('Plan löschen: Abbrechen schließt die Rückfrage ohne zu senden; ein einzelner Plan zeigt den Grund „letzter Zeitplan“', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)] })) })
await fx.klick(fx.finde(planKarte(fx, root, P2), '[data-aktion="entfernen"]'))
const frage = fx.finde(planKarte(fx, root, P2), '.tm-frage-box')
assert.doesNotMatch(fx.text(frage), /Ausnahmen, die nur für diesen Plan/, 'ohne Ausnahmen des Plans kein Zusatz')
await fx.klick(fx.finde(frage, 'button[type="button"]'))
assert.equal(fx.finde(planKarte(fx, root, P2), '.tm-frage-box'), null)
assert.equal(puts(fx).length, 0)
const allein = await oeffne(fx)
assert.match(fx.text(planKarte(fx, allein.root, P1)), /Der letzte Zeitplan kann nicht gelöscht werden/)
}))
test('Plan speichern: Name ändern lässt den Kurznamen stehen; die Auswahl „offen“ bleibt nach dem Neuzeichnen', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)] })) })
const karte = planKarte(fx, root, P2)
assert.equal(karte.hasAttribute('open'), false, 'weitere Pläne sind zugeklappt')
karte.setAttribute('open', '')
await fx.sende(karte, 'toggle', { bubbles: false })
await fx.setze(fx.finde(karte, `[data-feld="plaene.${P2}.name"]`), 'Küche neu')
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2, { name: 'Küche neu' })] }), 4))
await fx.klick(speichernKnopf(fx, root, P2))
const gesendet = puts(fx)[0].body.daten.plaene[1]
assert.equal(gesendet.name, 'Küche neu')
assert.equal(gesendet.kurz, 'kueche', 'der Kurzname ändert sich nicht')
assert.equal(planKarte(fx, root, P2).hasAttribute('open'), true, 'der Plan bleibt aufgeklappt')
}))
// ---------------------------------------------------------------------------------------------------------------- Feiertage
test('Feiertage: Bundesland, Liste mit Namen, „bitte prüfen“ markiert, Hinweis auf Gemeinde-Feiertage', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const bereich = abschnitt(fx, root, 'feiertage')
assert.ok(bereich)
const land = fx.finde(bereich, 'select[data-feld="land"]')
assert.equal(land.value, 'RP')
assert.equal(fx.alle(land, 'option').length, 16)
assert.match(fx.text(land), /Rheinland-Pfalz/)
assert.match(fx.text(bereich), /Gemeinde-Feiertage sind nicht enthalten/)
const zeilen = fx.alle(bereich, '[data-feiertag]')
assert.deepEqual(zeilen.map((z) => z.getAttribute('data-feiertag')), FEIERTAGE.map((f) => f.tag))
assert.match(fx.text(zeilen[1]), /Erster Weihnachtstag/)
assert.match(fx.text(zeilen[0]), /Heiligabend/)
assert.match(fx.text(zeilen[0]), /bitte prüfen/i)
assert.doesNotMatch(fx.text(zeilen[1]), /bitte prüfen/i)
// Vorgabe: gesetzlicher Feiertag geschlossen, Prüf-Tag nach dem Wochenplan
assert.equal(fx.finde(zeilen[1], 'select').value, '')
assert.match(fx.text(fx.finde(zeilen[1], 'select')), /geschlossen/)
assert.match(fx.text(fx.finde(zeilen[0], 'select')), /Wochenplan/)
assert.deepEqual(optionen(fx, fx.finde(zeilen[0], 'select')), ['', 'zu', 'zeiten', 'rund', 'vereinbarung'])
assert.deepEqual(optionen(fx, fx.finde(zeilen[1], 'select')), ['', 'zeiten', 'rund', 'vereinbarung'])
}))
test('Feiertage speichern: nur Abweichungen von der Vorgabe gehen in daten.feiertage, alles andere bleibt unverändert', mitDom({}, async (fx) => {
const vorhanden = { '2027-01-01': { modus: 'vereinbarung', fenster: [] }, '2030-05-01': { modus: 'rund', fenster: [] } } // 2030: nicht in der Liste, bleibt
const { root } = await oeffne(fx, { start: stand(daten({ feiertage: vorhanden })) })
const zeile = (tag) => fx.finde(root, `[data-feiertag="${tag}"]`)
assert.equal(fx.finde(zeile('2027-01-01'), 'select').value, 'vereinbarung', 'gespeicherte Abweichung ist vorbelegt')
// 25.12. mit eigenen Zeiten
await fx.setze(fx.finde(zeile('2026-12-25'), 'select'), 'zeiten')
const [von, bis] = zeitFelder(fx, zeile('2026-12-25'))
await fx.setze(von, '10:00')
await fx.setze(bis, '12:00')
// Heiligabend geschlossen (Abweichung vom Wochenplan)
await fx.setze(fx.finde(zeile('2026-12-24'), 'select'), 'zu')
// Neujahr zurück auf die Vorgabe
await fx.setze(fx.finde(zeile('2027-01-01'), 'select'), '')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(abschnitt(fx, root, 'feiertage'), 'form[aria-label="Feiertage"] button[type="submit"]'))
assert.deepEqual(puts(fx)[0].body.daten.feiertage, {
'2026-12-24': { modus: 'zu', fenster: [] },
'2026-12-25': { modus: 'zeiten', fenster: [['10:00', '12:00']] },
'2030-05-01': { modus: 'rund', fenster: [] },
})
assert.match(meldung(fx, root), /Feiertage gespeichert/)
}))
test('Feiertage: „geschlossen“ an einem gesetzlichen Feiertag ist die Vorgabe und wird nicht gespeichert; eigene Zeiten ohne Fenster sind ein Fehler am Tag', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ feiertage: { '2026-12-25': { modus: 'zu', fenster: [] } } })) })
assert.equal(fx.finde(fx.finde(root, '[data-feiertag="2026-12-25"]'), 'select').value, '', 'gespeichertes „zu“ ist die Vorgabe')
await fx.setze(fx.finde(fx.finde(root, '[data-feiertag="2027-01-01"]'), 'select'), 'zeiten')
await fx.klick(fx.finde(root, 'form[aria-label="Feiertage"] button[type="submit"]'))
assert.equal(puts(fx).length, 0)
assert.match(fx.text(fehlerAn(fx, root, 'feiertage.2027-01-01')), /mindestens ein Zeitfenster/)
await fx.setze(fx.finde(fx.finde(root, '[data-feiertag="2027-01-01"]'), 'select'), '')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(root, 'form[aria-label="Feiertage"] button[type="submit"]'))
assert.deepEqual(puts(fx)[0].body.daten.feiertage, {}, 'das gespeicherte „zu“ fällt weg')
}))
test('Bundesland speichern: PUT mit land, danach zeigt die Liste die Feiertage der Antwort', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.setze(fx.finde(root, 'select[data-feld="land"]'), 'BY')
const bayern = [{ tag: '2026-12-25', name: 'Erster Weihnachtstag', art: 'feiertag' }, { tag: '2027-01-06', name: 'Heilige Drei Könige', art: 'feiertag' }]
fx.antworten.push(antwort(daten({ land: 'BY' }), 4, { feiertage: bayern }))
await fx.klick(fx.finde(root, 'form[aria-label="Bundesland"] button[type="submit"]'))
assert.equal(puts(fx)[0].body.daten.land, 'BY')
assert.deepEqual(fx.alle(root, '[data-feiertag]').map((z) => z.getAttribute('data-feiertag')), ['2026-12-25', '2027-01-06'])
assert.equal(fx.finde(root, 'select[data-feld="land"]').value, 'BY')
}))
// ---------------------------------------------------------------------------------------------------------------- Ausnahmen
const urlaubsformular = (fx, root) => fx.finde(root, 'form[aria-label="Betriebsurlaub eintragen"]')
const feldIn = (fx, form, name) => fx.finde(form, (el) => el.nodeType === 1 && (el.getAttribute('data-feld') || '').endsWith(`.${name}`))
const modusAuswahl = (fx, form) => fx.finde(form, (el) => el.tagName === 'SELECT' && (el.getAttribute('data-feld') || '').endsWith('.modus'))
test('Urlaub anlegen: von–bis und Text, Geltung „alle Pläne“ (Vorgabe) oder ein Plan; PUT mit neuer Ausnahme', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)] })) })
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
const von = tagPlus(HEUTE, 20)
const bis = tagPlus(HEUTE, 25)
await fx.setze(feldIn(fx, form, 'von'), von)
await fx.setze(feldIn(fx, form, 'bis'), bis)
await fx.setze(feldIn(fx, form, 'text'), 'Sommerpause')
const geltung = feldIn(fx, form, 'plan')
assert.deepEqual(optionen(fx, geltung), ['', P1, P2])
assert.match(fx.text(geltung), /Alle Pläne/)
assert.equal(geltung.value, '', 'Vorgabe: alle Pläne')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const a = puts(fx)[0].body.daten.ausnahmen
assert.equal(a.length, 1)
assert.match(a[0].id, /^x_[a-z0-9]{8}$/)
assert.deepEqual({ ...a[0], id: 'x' }, { id: 'x', art: 'urlaub', von, bis, text: 'Sommerpause', modus: 'zu', fenster: [], plan: null })
}))
test('Urlaub ab 2 Tagen: „Hinweis-Leiste dazu anlegen?“ – Ja legt den vorbefüllten Hinweis an und speichert, Nein lässt es', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
const von = tagPlus(HEUTE, 20)
const bis = tagPlus(HEUTE, 25)
await fx.setze(feldIn(fx, form, 'von'), von)
await fx.setze(feldIn(fx, form, 'bis'), bis)
const urlaub = { id: 'x_urlaub01', art: 'urlaub', von, bis, text: '', modus: 'zu', fenster: [], plan: null }
fx.antworten.push(antwort(daten({ ausnahmen: [urlaub] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const frage = fx.finde(root, '[data-frage="hinweis"]')
assert.ok(frage, 'die Frage steht in der Seite')
assert.match(fx.text(frage), /Hinweis-Leiste dazu anlegen\?/)
const ymd = (tag) => `${tag.slice(8)}.${tag.slice(5, 7)}.`
assert.ok(fx.text(frage).includes(`Betriebsurlaub vom ${ymd(von)} bis ${ymd(bis)}`), 'der Text des Vorschlags steht in der Frage')
assert.equal(puts(fx).length, 1, 'bis zur Antwort nur der Urlaub')
const vorschlag = { id: 'h_xxxxxxxx', art: 'urlaub', text: `Betriebsurlaub vom ${ymd(von)} bis ${ymd(bis)}`, knopf: null, von: tagPlus(von, -7), bis }
fx.antworten.push(antwort(daten({ ausnahmen: [urlaub], hinweise: [vorschlag] }), 5))
await fx.klick(fx.finde(frage, '[data-aktion="hinweis-ja"]'))
assert.equal(puts(fx).length, 2)
const gesendet = puts(fx)[1].body
assert.equal(gesendet.version, 4)
assert.deepEqual(gesendet.daten.ausnahmen.map((a) => a.id), ['x_urlaub01'], 'der Urlaub bleibt')
assert.equal(gesendet.daten.hinweise.length, 1)
assert.match(gesendet.daten.hinweise[0].id, /^h_[a-z0-9]{8}$/)
assert.deepEqual({ ...gesendet.daten.hinweise[0], id: 'h' }, { id: 'h', art: 'urlaub', text: vorschlag.text, knopf: null, von: tagPlus(von, -7), bis })
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null, 'die Frage ist beantwortet')
assert.match(meldung(fx, root), /Hinweis-Leiste/)
// Nein: kein weiterer Speichervorgang
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const zweites = urlaubsformular(fx, root)
await fx.setze(feldIn(fx, zweites, 'von'), tagPlus(HEUTE, 40))
await fx.setze(feldIn(fx, zweites, 'bis'), tagPlus(HEUTE, 42))
fx.antworten.push(antwort(daten({ ausnahmen: [urlaub] }), 6))
await fx.klick(fx.finde(zweites, 'button[type="submit"]'))
const davor = puts(fx).length
await fx.klick(fx.finde(fx.finde(root, '[data-frage="hinweis"]'), '[data-aktion="hinweis-nein"]'))
assert.equal(puts(fx).length, davor)
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null)
}))
test('Urlaub: keine Frage, wenn es denselben Hinweis schon gibt oder sich beim Bearbeiten der Zeitraum nicht ändert', mitDom({}, async (fx) => {
const von = tagPlus(HEUTE, 20)
const bis = tagPlus(HEUTE, 25)
const vorschlag = hinweisVorschlag({ von, bis })
const hinweis = { id: 'h_schonda1', art: 'urlaub', text: vorschlag.text, knopf: null, von: vorschlag.von, bis: vorschlag.bis }
const urlaub = { id: 'x_urlaub01', art: 'urlaub', von, bis, text: '', modus: 'zu', fenster: [], plan: null }
// 1. ein gleicher Hinweis existiert schon
const { root } = await oeffne(fx, { start: stand(daten({ hinweise: [hinweis] })) })
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
await fx.setze(feldIn(fx, form, 'von'), von)
await fx.setze(feldIn(fx, form, 'bis'), bis)
fx.antworten.push(antwort(daten({ hinweise: [hinweis], ausnahmen: [urlaub] }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1)
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null)
// 2. der Urlaub wird nur im Text geändert: keine Frage; ändert sich der Zeitraum (ohne Hinweis dazu): Frage
const zwei = await oeffne(fx, { start: stand(daten({ ausnahmen: [urlaub] })) })
const zeile = fx.finde(zwei.root, 'li[data-id="x_urlaub01"]')
await fx.klick(fx.finde(zeile, '[data-aktion="bearbeiten"]'))
await fx.setze(feldIn(fx, fx.finde(zeile, 'form'), 'text'), 'Sommer')
fx.antworten.push(antwort(daten({ ausnahmen: [{ ...urlaub, text: 'Sommer' }] }), 4))
await fx.klick(fx.finde(fx.finde(zeile, 'form'), 'button[type="submit"]'))
assert.equal(fx.finde(zwei.root, '[data-frage="hinweis"]'), null, 'nur der Text hat sich geändert')
const zeile2 = fx.finde(zwei.root, 'li[data-id="x_urlaub01"]')
await fx.klick(fx.finde(zeile2, '[data-aktion="bearbeiten"]'))
await fx.setze(feldIn(fx, fx.finde(zeile2, 'form'), 'bis'), tagPlus(bis, 3))
fx.antworten.push(antwort(daten({ ausnahmen: [{ ...urlaub, text: 'Sommer', bis: tagPlus(bis, 3) }] }), 5))
await fx.klick(fx.finde(fx.finde(zeile2, 'form'), 'button[type="submit"]'))
assert.ok(fx.finde(zwei.root, '[data-frage="hinweis"]'), 'der Zeitraum hat sich geändert')
}))
test('Urlaub von einem Tag: keine Frage nach der Hinweis-Leiste', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
const tag = tagPlus(HEUTE, 10)
await fx.setze(feldIn(fx, form, 'von'), tag)
await fx.setze(feldIn(fx, form, 'bis'), tag)
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(puts(fx).length, 1)
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null)
assert.match(meldung(fx, root), /eingetragen/)
}))
test('Hinweis-Leiste anlegen scheitert (400, zu viele Hinweise): Fehler an der Frage, die Frage bleibt', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="urlaub-neu"]'))
const form = urlaubsformular(fx, root)
await fx.setze(feldIn(fx, form, 'von'), tagPlus(HEUTE, 20))
await fx.setze(feldIn(fx, form, 'bis'), tagPlus(HEUTE, 25))
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
fx.antworten.push(fehler(400, 'Bitte prüfe deine Eingaben.', { fehler: { hinweise: 'Es sind höchstens 20 Hinweise möglich.' } }))
await fx.klick(fx.finde(fx.finde(root, '[data-frage="hinweis"]'), '[data-aktion="hinweis-ja"]'))
const frage = fx.finde(root, '[data-frage="hinweis"]')
assert.ok(frage, 'die Frage bleibt')
assert.match(fx.text(frage), /höchstens 20 Hinweise/)
}))
test('Sondertag: Datum, geschlossen oder Zeiten, Text, Geltung; PUT mit bis = von', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="sondertag-neu"]'))
const form = fx.finde(root, 'form[aria-label="Sondertag eintragen"]')
const tag = tagPlus(HEUTE, 14)
await fx.setze(feldIn(fx, form, 'von'), tag)
assert.equal(feldIn(fx, form, 'plan'), null, 'mit einem Plan gibt es keine Wahl der Geltung')
assert.equal(feldIn(fx, form, 'bis'), null, 'ein Sondertag hat nur ein Datum')
const modus = modusAuswahl(fx, form)
assert.deepEqual(optionen(fx, modus), ['zu', 'zeiten', 'rund', 'vereinbarung'])
assert.equal(modus.value, 'zu', 'Vorgabe: geschlossen')
assert.equal(fx.finde(form, '.zt-zeiten').hidden, true)
await fx.setze(modus, 'zeiten')
assert.equal(fx.finde(form, '.zt-zeiten').hidden, false)
const [von1, bis1] = zeitFelder(fx, form)
await fx.setze(von1, '10:00')
await fx.setze(bis1, '14:00')
await fx.setze(feldIn(fx, form, 'text'), 'Tag der offenen Tür')
fx.antworten.push(antwort(daten(), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const a = puts(fx)[0].body.daten.ausnahmen[0]
assert.deepEqual({ ...a, id: 'x' }, { id: 'x', art: 'sondertag', von: tag, bis: tag, text: 'Tag der offenen Tür', modus: 'zeiten', fenster: [['10:00', '14:00']], plan: null })
assert.equal(fx.finde(root, '[data-frage="hinweis"]'), null, 'für einen Sondertag fragt der Editor nicht nach einer Leiste')
}))
test('Ausnahmen: Liste kommend und vergangen, Bearbeiten und Entfernen (je ein Speichern)', mitDom({}, async (fx) => {
const vor = tagPlus(HEUTE, -3)
const spaeter = tagPlus(HEUTE, 30)
const bald = tagPlus(HEUTE, 5)
const ausnahmen = [
{ id: 'x_spaeter1', art: 'sondertag', von: spaeter, bis: spaeter, text: 'Später', modus: 'zeiten', fenster: [['10:00', '14:00']], plan: null },
{ id: 'x_vorbei01', art: 'urlaub', von: vor, bis: vor, text: 'Vorbei', modus: 'zu', fenster: [], plan: null },
{ id: 'x_bald0001', art: 'urlaub', von: bald, bis: tagPlus(bald, 2), text: 'Bald', modus: 'zu', fenster: [], plan: P2 },
]
const { root } = await oeffne(fx, { start: stand(daten({ plaene: [plan(P1), plan(P2)], ausnahmen })) })
const bereich = abschnitt(fx, root, 'ausnahmen')
const kommend = fx.alle(fx.finde(bereich, 'ul[aria-label="Kommende Ausnahmen"]'), 'li[data-id]')
assert.deepEqual(kommend.map((li) => li.getAttribute('data-id')), ['x_bald0001', 'x_spaeter1'], 'nach Beginn sortiert')
assert.match(fx.text(kommend[0]), /Bald/)
assert.match(fx.text(kommend[0]), /Küche/, 'die Geltung steht in der Zeile')
assert.match(fx.text(kommend[1]), /10:00–14:00/)
const vergangen = fx.finde(bereich, 'details.tm-details')
assert.match(fx.text(fx.finde(vergangen, 'summary')), /Vergangene Ausnahmen \(1\)/)
assert.match(fx.text(vergangen), /Vorbei/)
// Bearbeiten: Text ändern
await fx.klick(fx.finde(kommend[1], '[data-aktion="bearbeiten"]'))
const form = fx.finde(kommend[1], 'form')
await fx.setze(feldIn(fx, form, 'text'), 'Später neu')
const nachBearbeiten = ausnahmen.map((a) => (a.id === 'x_spaeter1' ? { ...a, text: 'Später neu' } : a))
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: nachBearbeiten }), 4))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const gesendet = puts(fx)[0].body.daten.ausnahmen
assert.deepEqual(gesendet.map((a) => a.id), ['x_spaeter1', 'x_vorbei01', 'x_bald0001'], 'Reihenfolge und Stelle bleiben, gesucht wird per ID')
assert.equal(gesendet[0].text, 'Später neu')
assert.equal(gesendet[2].plan, P2)
// Entfernen
const zeile = fx.finde(root, 'li[data-id="x_bald0001"]')
await fx.klick(fx.finde(zeile, '[data-aktion="bearbeiten"]'))
await fx.klick(fx.finde(zeile, '[data-aktion="entfernen"]'))
fx.antworten.push(antwort(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: nachBearbeiten.filter((a) => a.id !== 'x_bald0001') }), 5))
await fx.klick(fx.finde(fx.finde(zeile, '.tm-frage-box'), 'button[type="submit"]'))
assert.deepEqual(puts(fx)[1].body.daten.ausnahmen.map((a) => a.id), ['x_spaeter1', 'x_vorbei01'])
assert.equal(fx.finde(root, 'li[data-id="x_bald0001"]'), null)
}))
test('Ausnahmen: bei 100 Einträgen sind beide Knöpfe gesperrt', mitDom({}, async (fx) => {
const viele = Array.from({ length: ZEITEN_LIMITS.ausnahmen }, (_, i) => ({ id: `x_aaaa${String(i).padStart(4, '0')}`, art: 'sondertag', von: tagPlus(HEUTE, 1 + (i % 300)), bis: tagPlus(HEUTE, 1 + (i % 300)), text: '', modus: 'zu', fenster: [], plan: null }))
const { root } = await oeffne(fx, { start: stand(daten({ ausnahmen: viele })) })
assert.equal(fx.finde(root, '[data-werkzeug="urlaub-neu"]').disabled, true)
assert.equal(fx.finde(root, '[data-werkzeug="sondertag-neu"]').disabled, true)
assert.match(fx.text(abschnitt(fx, root, 'ausnahmen')), /Mehr als 100 Ausnahmen sind nicht möglich/)
}))
// ---------------------------------------------------------------------------------------------------------------- Fehler des Servers
test('409 beim Speichern: Meldung „Inzwischen geändert – bitte neu laden.“ und Neu-laden-Knopf; der Knopf lädt und zeichnet neu', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
fx.antworten.push(fehler(409, 'Inzwischen geändert – bitte neu laden.', { code: 'ZEITEN_VERALTET' }))
await fx.klick(speichernKnopf(fx, root, P1))
const box = fx.finde(root, '.tm-box-warn')
assert.equal(box.hidden, false)
assert.match(fx.text(box), /Inzwischen geändert – bitte neu laden\./)
const neuLaden = fx.finde(box, 'button')
assert.equal(fx.text(neuLaden), 'Neu laden')
assert.equal(puts(fx).length, 1)
// die neue Fassung hat einen anderen Namen
const neu = daten({ plaene: [plan(P1, { name: 'Praxis' })] })
fx.antworten.push({ status: 200, body: stand(neu, { version: 7 }) })
await fx.klick(neuLaden)
assert.equal(gets(fx).length, 2, 'die Daten werden neu geholt')
assert.equal(fx.finde(root, `[data-feld="plaene.${P1}.name"]`).value, 'Praxis')
assert.equal(fx.finde(root, '.tm-box-warn').hidden, true)
fx.antworten.push(antwort(neu, 8))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(puts(fx)[1].body.version, 7, 'weiter mit der neuen Version')
}))
test('400: Fehler am richtigen Feld (plaene.<id>.tage.3), Fokus dorthin, Fremdes allgemein mit dem Namen des Plans', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
fx.antworten.push(fehler(400, 'Bitte prüfe deine Eingaben.', { fehler: {
[`plaene.${P1}.tage.3`]: 'Die Zeitfenster überschneiden sich.',
[`plaene.${P1}.name`]: 'Höchstens 60 Zeichen.',
'ausnahmen.x_fremd001.von': 'Bitte gib ein gültiges Datum an.',
daten: 'Zu viele Einträge – bitte lösche alte Ausnahmen oder Hinweise.',
} }))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(fx.text(fehlerAn(fx, root, `plaene.${P1}.tage.3`)), 'Die Zeitfenster überschneiden sich.')
assert.equal(fx.text(fehlerAn(fx, root, `plaene.${P1}.name`)), 'Höchstens 60 Zeichen.')
const feld = fx.finde(root, `[data-feld="plaene.${P1}.tage.3"]`)
assert.equal(feld.getAttribute('aria-invalid'), 'true')
assert.equal(fx.finde(root, `[data-feld="plaene.${P1}.name"]`).getAttribute('aria-invalid'), 'true')
assert.ok(planKarte(fx, root, P1).contains(fx.finde(root, '[data-fehler="_"]')), 'Platz für allgemeine Fehler')
assert.match(fx.text(fx.finde(planKarte(fx, root, P1), '[data-fehler="_"]')), /Zu viele Einträge/)
assert.match(fx.text(fx.finde(planKarte(fx, root, P1), '[data-fehler="_"]')), /gültiges Datum/)
assert.equal(document.activeElement, fx.finde(root, '[aria-invalid="true"]'), 'der Fokus geht zum ersten Feld mit Fehler')
// ein Speichern mit Erfolg räumt die Fehler ab
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(fx.finde(root, '[aria-invalid="true"]'), null)
}))
test('400 in Ausnahmen und Feiertagen: Fehler an Datum, Fenster und Tag (Pfade mit ID)', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(fx.finde(root, '[data-werkzeug="sondertag-neu"]'))
const form = fx.finde(root, 'form[aria-label="Sondertag eintragen"]')
await fx.setze(feldIn(fx, form, 'von'), tagPlus(HEUTE, 3))
await fx.setze(modusAuswahl(fx, form), 'zeiten')
await fx.setze(zeitFelder(fx, form)[0], '09:00')
await fx.setze(zeitFelder(fx, form)[1], '12:00')
fx.antworten.push(fehler(400, 'Bitte prüfe deine Eingaben.', { fehler: {} }))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
const id = puts(fx)[0].body.daten.ausnahmen[0].id
fx.antworten.push(fehler(400, 'Bitte prüfe deine Eingaben.', { fehler: { [`ausnahmen.${id}.von`]: 'Bitte gib ein gültiges Datum an.', [`ausnahmen.${id}.fenster`]: 'Die Zeitfenster überschneiden sich.' } }))
await fx.klick(fx.finde(form, 'button[type="submit"]'))
assert.equal(fx.text(fehlerAn(fx, form, `ausnahmen.${id}.von`)), 'Bitte gib ein gültiges Datum an.')
assert.equal(fx.text(fehlerAn(fx, form, `ausnahmen.${id}.fenster`)), 'Die Zeitfenster überschneiden sich.')
// Feiertag: Pfad feiertage.<tag>
await fx.setze(fx.finde(fx.finde(root, '[data-feiertag="2026-12-25"]'), 'select'), 'rund')
fx.antworten.push(fehler(400, 'Bitte prüfe deine Eingaben.', { fehler: { 'feiertage.2026-12-25': 'Bitte wähle „geschlossen“, „geöffnet“, „rund um die Uhr“ oder „nach Vereinbarung“.' } }))
await fx.klick(fx.finde(root, 'form[aria-label="Feiertage"] button[type="submit"]'))
assert.match(fx.text(fehlerAn(fx, root, 'feiertage.2026-12-25')), /Bitte wähle/)
}))
test('Sonstige Fehler (429, 500, offline) stehen als Meldung über dem Editor, nie als HTML', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const bose = '<img src=x onerror=alert(1)>'
fx.antworten.push(fehler(429, bose))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(meldung(fx, root), bose)
assert.equal(fx.alle(root, 'img').length, 0)
assert.equal(fx.finde(root, '[data-meldung="editor"]').getAttribute('role'), 'alert')
fx.antworten.push(() => { throw new TypeError('x') })
await fx.klick(speichernKnopf(fx, root, P1))
assert.match(meldung(fx, root), /Fehler|Verbindung|Interner/)
// ein Erfolg ersetzt die Fehlermeldung durch eine ruhige Meldung
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(fx.finde(root, '[data-meldung="editor"]').getAttribute('role'), 'status')
}))
test('Laden schlägt fehl: Fehlerkarte mit „Noch einmal versuchen“; eine unbrauchbare Antwort ebenso', mitDom({}, async (fx) => {
const root = h('div', { class: 'zt-editor', 'data-state': 'active' })
document.body.append(root)
fx.antworten.push(fehler(500, 'Interner Serverfehler'))
await editor.renderEditor(root, { projectId: 'p1' })
assert.match(fx.text(root), /Interner Serverfehler/)
const nochmal = fx.finde(root, 'button')
assert.match(fx.text(nochmal), /Noch einmal versuchen/)
fx.antworten.push({ status: 200, body: { irgendwas: true } })
await fx.klick(nochmal)
assert.match(fx.text(root), /konnten gerade nicht geladen werden/)
fx.antworten.push({ status: 200, body: stand(daten()) })
await fx.klick(fx.finde(root, 'button'))
assert.ok(planKarte(fx, root, P1), 'der dritte Versuch zeigt den Editor')
}))
// ---------------------------------------------------------------------------------------------------------------- Kundenansicht, nur lesen, R12
test('Kundenansicht: Hinweis-Box, Speichern ist erlaubt', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { start: stand(daten(), { kundenansicht: true }) })
const box = fx.finde(root, '[data-hinweis="kundenansicht"]')
assert.ok(box)
assert.match(fx.text(box), /Kundenansicht/)
assert.match(fx.text(box), /Protokoll/)
assert.equal(speichernKnopf(fx, root, P1).disabled, false)
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(puts(fx).length, 1)
// ohne Kundenansicht keine Box
const kunde = await oeffne(fx)
assert.equal(fx.finde(kunde.root, '[data-hinweis="kundenansicht"]'), null)
// auch die Angabe der Hülle genügt, falls die Antwort sie nicht trägt
const huelle = await oeffne(fx, { kundenansicht: true })
assert.ok(fx.finde(huelle.root, '[data-hinweis="kundenansicht"]'))
}))
test('abgeschaltet (disabled mit Daten): alles gesperrt, keine Knöpfe zum Ändern, Hinweis „nur ansehen“', mitDom({}, async (fx) => {
const { root } = await oeffne(fx, { state: 'disabled', start: stand(daten({ plaene: [plan(P1), plan(P2)], ausnahmen: [{ id: 'x_aaaaaaa1', art: 'urlaub', von: tagPlus(HEUTE, 3), bis: tagPlus(HEUTE, 4), text: 'Urlaub', modus: 'zu', fenster: [], plan: null }] }), { state: 'disabled' }) })
assert.ok(fx.finde(root, '[data-hinweis="nurlesen"]'))
assert.equal(fx.finde(root, '[data-aktion="speichern"]'), null)
assert.equal(fx.finde(root, '[data-aktion="haupt"]'), null)
assert.equal(fx.finde(root, '[data-aktion="entfernen"]'), null)
assert.equal(fx.finde(root, '[data-werkzeug]'), null)
assert.equal(fx.finde(root, 'button[type="submit"]'), null)
for (const feld of fx.alle(root, 'input, select')) assert.equal(feld.matches(':disabled'), true, `${feld.tagName} ist gesperrt`)
assert.equal(puts(fx).length, 0)
}))
test('R12: die Kontaktdaten gehen unverändert zurück (kein neues bestaetigtAt), auch wenn nur ein Plan gespeichert wird', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, root, P1))
assert.deepEqual(puts(fx)[0].body.daten.kontakt, KONTAKT)
// keine Kontaktdaten: der Editor erfindet keine
const ohne = daten()
delete ohne.kontakt
const zwei = await oeffne(fx, { start: stand(ohne) })
fx.antworten.push(antwort(daten(), 4))
await fx.klick(speichernKnopf(fx, zwei.root, P1))
assert.equal(Object.hasOwn(puts(fx)[1].body.daten, 'kontakt'), false)
}))
// ---------------------------------------------------------------------------------------------------------------- Robustheit
test('Fremdtext (Plan-, Feiertags-, Ausnahme- und Hinweisnamen) kommt nur als Text in die Seite', mitDom({}, async (fx) => {
const bose = '<img src=x onerror=alert(1)>'
const tag = tagPlus(HEUTE, 9)
const d = daten({
plaene: [plan(P1, { name: bose })],
ausnahmen: [{ id: 'x_bose0001', art: 'urlaub', von: tag, bis: tag, text: bose, modus: 'zu', fenster: [], plan: null }],
})
const { root } = await oeffne(fx, { start: stand(d, { feiertage: [{ tag: '2026-12-25', name: bose, art: 'feiertag' }] }) })
assert.equal(fx.alle(root, 'img').length, 0)
assert.ok(fx.text(root).includes(bose))
assert.equal(fx.finde(root, `[data-feld="plaene.${P1}.name"]`).value, bose)
fx.antworten.push(fehler(400, 'x', { fehler: { [`ausnahmen.x_bose0001.text`]: bose } }))
await fx.klick(speichernKnopf(fx, root, P1))
assert.equal(fx.alle(root, 'img').length, 0)
}))
test('IDs wie „__proto__“ und „constructor“ sind gewöhnliche Texte: Speichern, Fehler und Löschen finden den richtigen Plan', mitDom({}, async (fx) => {
const alt = daten({ plaene: [plan('__proto__', { name: 'Eins' }), plan('constructor', { haupt: false, kurz: 'zwei', name: 'Zwei' })] })
const { root } = await oeffne(fx, { start: stand(alt) })
const karte = fx.alle(root, 'details[data-plan]').find((k) => k.getAttribute('data-plan') === 'constructor')
assert.ok(karte)
karte.setAttribute('open', '')
await fx.setze(fx.finde(karte, '[data-feld="plaene.constructor.name"]'), 'Zwei neu')
fx.antworten.push(fehler(400, 'x', { fehler: { 'plaene.constructor.tage.3': 'Die Zeitfenster überschneiden sich.' } }))
await fx.klick(fx.finde(karte, '[data-aktion="speichern"]'))
assert.deepEqual(puts(fx)[0].body.daten.plaene.map((p) => [p.id, p.name]), [['__proto__', 'Eins'], ['constructor', 'Zwei neu']])
assert.equal(fx.text(fehlerAn(fx, karte, 'plaene.constructor.tage.3')), 'Die Zeitfenster überschneiden sich.')
// Löschen
await fx.klick(fx.finde(karte, '[data-aktion="entfernen"]'))
fx.antworten.push(antwort(daten({ plaene: [plan('__proto__', { name: 'Eins' })] }), 4))
await fx.klick(fx.finde(fx.finde(karte, '.tm-frage-box'), 'button[type="submit"]'))
assert.deepEqual(puts(fx)[1].body.daten.plaene.map((p) => p.id), ['__proto__'])
}))
test('Ein Speichern zur Zeit: während es läuft sind die Knöpfe gesperrt und ein zweiter Aufruf geht nicht hinaus', mitDom({}, async (fx) => {
const { root } = await oeffne(fx)
const echtes = globalThis.fetch
let freigeben
const wartet = new Promise((resolve) => { freigeben = resolve })
globalThis.fetch = async (url, optionen) => {
if (optionen?.method === 'PUT') await wartet
return echtes(url, optionen)
}
try {
fx.antworten.push(antwort(daten(), 4))
const erster = fx.klick(speichernKnopf(fx, root, P1))
await fx.ruhe()
assert.equal(planKarte(fx, root, P1).closest('[aria-busy="true"]') !== null || root.getAttribute('aria-busy') === 'true', true, 'der Editor ist beschäftigt')
assert.equal(speichernKnopf(fx, root, P1).disabled, true)
await fx.klick(speichernKnopf(fx, root, P1))
freigeben()
await erster
assert.equal(puts(fx).length, 1, 'nur ein Speichervorgang')
assert.equal(root.hasAttribute('aria-busy'), false)
} finally {
globalThis.fetch = echtes
}
}))
test('neuerZeitenSpeicher: speichere ohne laufende Hülle (ctx mit Stand) – Erfolg liefert true, der Stand wird übernommen', mitDom({}, async (fx) => {
const root = h('div')
document.body.append(root)
const form = h('form', null, h('p', { 'data-fehler': '_' }))
root.append(form)
const ctx = {
projectId: 'p1',
stand: { daten: daten(), version: 3 },
aktuell: () => true,
neuLaden: async () => {},
meldung: () => {},
uebernimm(a) { this.stand = { daten: a.daten, version: a.version } },
}
const sp = speichern.neuerZeitenSpeicher(ctx, root, () => {})
assert.equal(typeof sp.speichere, 'function')
assert.ok(sp.konflikt)
assert.equal(typeof sp.nochHier, 'function')
fx.antworten.push(antwort(daten({ land: 'BY' }), 4))
assert.equal(await sp.speichere((entwurf) => { entwurf.land = 'BY' }, { container: form }), true)
assert.deepEqual(puts(fx)[0], { method: 'PUT', url: '/api/zeiten/p1', body: { version: 3, daten: daten({ land: 'BY' }) } })
assert.equal(ctx.stand.version, 4)
assert.equal(await sp.speichere(() => false, { container: form }), false, 'aendere kann abbrechen (Fehler am Feld schon gezeigt)')
assert.equal(puts(fx).length, 1)
}))
// ---------------------------------------------------------------------------------------------------------------- Hülle: Editor bleibt stehen
const zeitenFeature = (state = 'off', extra = {}) => ({
state, siteKey: null, ticketWoid: null, orderedAt: null, activatedAt: null, invoiceUrl: null, disabledReason: null, disabledAt: null, loeschungAm: null,
unlockedBy: state === 'off' ? null : 'abo', aboEndeAt: null, einbau: null, ...extra,
})
const projekt = (id, state, extra = {}) => ({ projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, zeiten: zeitenFeature(state) }, zeitenStatus: null, zeitenLeer: true, ...extra })
const angebot = { verfuegbar: true, inklusive: true, grund: null, preis: '30 €', einschalten: { consent: {}, leistungsbeschreibung: {} }, belehrung: '' }
const liste = (projects) => ({ status: 200, body: { projects, features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, zeiten: angebot } })
test('Hülle: ein Wechsel des Standes (zeitenLeer, setup → active) zeichnet nur den Kopf neu; der Editor und seine ungespeicherten Eingaben bleiben', mitDom({}, async (fx) => {
const root = h('div', { id: 'zeiten-body' })
document.body.append(root)
fx.antworten.push(liste([projekt('p1', 'setup', { zeitenLeer: true })]), { status: 200, body: stand(null, { state: 'setup' }) })
await initZeiten(root, { projectId: 'p1' })
await fx.ruhe()
const platz = fx.finde(root, '.zt-editor')
assert.ok(platz)
assert.equal(platz.getAttribute('data-state'), 'setup')
const id = fx.finde(platz, 'details[data-plan]').getAttribute('data-plan')
const name = fx.finde(platz, `[data-feld="plaene.${id}.name"]`)
await fx.setze(name, 'Praxis Dr. Muster')
const kopf = fx.finde(root, '.zt-kopf')
// Stand ändert sich (nach dem ersten Speichern ist zeitenLeer false, der Zustand wechselt auf aktiv): neuer Kopf, derselbe Editor
fx.antworten.push(liste([projekt('p1', 'active', { zeitenLeer: false })]))
await initZeiten(root, { projectId: 'p1' })
assert.equal(fx.finde(root, '.zt-editor'), platz, 'derselbe Editorplatz')
assert.equal(platz.getAttribute('data-state'), 'active', 'der Zustand wird nachgeführt')
assert.equal(fx.finde(platz, `[data-feld="plaene.${id}.name"]`), name, 'dasselbe Eingabefeld')
assert.equal(name.value, 'Praxis Dr. Muster', 'die ungespeicherte Eingabe steht noch da')
assert.notEqual(fx.finde(root, '.zt-kopf'), kopf, 'der Kopf (Statuskarte) ist neu')
assert.equal(fx.finde(root, '.zt-status').getAttribute('data-state'), 'active')
assert.equal(gets(fx).filter((a) => a.url === '/api/zeiten/p1').length, 1, 'der Editor lädt nicht noch einmal')
assert.equal(platz.hidden, false)
}))
test('Hülle: erstes Speichern im Editor holt die Liste neu (zeitenLeer) und zeichnet nur den Kopf neu, der Editor bleibt', mitDom({}, async (fx) => {
const root = h('div', { id: 'zeiten-body' })
document.body.append(root)
fx.antworten.push(liste([projekt('p1', 'active', { zeitenLeer: true })]), { status: 200, body: stand(null) })
await initZeiten(root, { projectId: 'p1' })
await fx.ruhe()
const platz = fx.finde(root, '.zt-editor')
const id = fx.finde(platz, 'details[data-plan]').getAttribute('data-plan')
const gespeichert = daten({ plaene: [plan(id)], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } })
fx.antworten.push(antwort(gespeichert, 1), liste([projekt('p1', 'active', { zeitenLeer: false })]))
await fx.klick(fx.finde(platz, '[data-aktion="speichern"]'))
await fx.ruhe()
assert.deepEqual(fx.fetchLog.map((a) => `${a.method} ${a.url}`), ['GET /api/funktionen', 'GET /api/zeiten/p1', 'PUT /api/zeiten/p1', 'GET /api/funktionen'])
assert.equal(fx.finde(root, '.zt-editor'), platz, 'derselbe Editorplatz nach dem Neuzeichnen der Hülle')
assert.ok(abschnitt(fx, platz, 'feiertage'), 'der Editor zeigt jetzt Feiertage und Ausnahmen')
assert.doesNotMatch(fx.text(fx.finde(root, '.zt-kopf')), /Trag jetzt deine Zeiten ein/)
}))
test('Hülle: eine andere Website oder ein anderer Schreibstatus (abgeschaltet) bekommt einen frischen Editor', mitDom({}, async (fx) => {
const root = h('div', { id: 'zeiten-body' })
document.body.append(root)
fx.antworten.push(liste([projekt('p1', 'active', { zeitenLeer: false }), projekt('p2', 'active', { zeitenLeer: false })]), { status: 200, body: stand(daten()) })
await initZeiten(root, { projectId: 'p1' })
await fx.ruhe()
const erster = fx.finde(root, '.zt-editor')
// Website wechseln
fx.antworten.push({ status: 200, body: stand(daten({ plaene: [plan(P1, { name: 'Zweite Seite' })] })) })
await fx.setze(fx.finde(root, '#zeiten-project'), 'p2')
await fx.ruhe()
const zweiter = fx.finde(root, '.zt-editor')
assert.notEqual(zweiter, erster)
assert.equal(fx.finde(zweiter, `[data-feld="plaene.${P1}.name"]`).value, 'Zweite Seite')
assert.equal(erster.isConnected, false)
assert.deepEqual(gets(fx).filter((a) => a.url.startsWith('/api/zeiten/')).map((a) => a.url), ['/api/zeiten/p1', '/api/zeiten/p2'])
}))
// ---------------------------------------------------------------------------------------------------------------- Quelltext
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
const DATEIEN = ['zeiten-speichern.js', 'zeiten-editor.js', 'zeiten-plaene.js', 'zeiten-ausnahmen.js']
test('Quelltext: vier neue Dateien, nur relative Importe, kein HTML aus Daten, keine Browser-Rückfragen, kein Speicher, Du-Form', () => {
const vorhanden = readdirSync(new URL('../public/', import.meta.url))
for (const datei of DATEIEN) {
assert.ok(vorhanden.includes(datei), `${datei} fehlt`)
const quelle = ohneKommentare(lesen(`public/${datei}`))
for (const m of quelle.matchAll(/\bfrom\s+'([^']+)'/g)) assert.match(m[1], /^\.\/(termine-dom|termine-view|zeiten[a-z-]*)\.js$/, `${datei}: Import ${m[1]}`)
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`)
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`)
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`)
assert.doesNotMatch(quelle, /24 Uhr/, `${datei}: Wortwahl`)
assert.doesNotMatch(quelle, /import\s*\(/, `${datei}: kein dynamischer Import`)
assert.doesNotMatch(quelle, /\btermine-(speichern|team|tab|aktionen|formulare)\b/, `${datei}: keine termine-Dateien angefasst`)
}
// die Hülle bindet den Editor ein
assert.match(ohneKommentare(lesen('public/zeiten.js')), /from '\.\/zeiten-editor\.js'/)
})
test('zeiten.css: jede zt-Klasse der Editor-Module ist gestaltet', () => {
const css = lesen('public/zeiten.css')
const quelle = DATEIEN.map((d) => lesen(`public/${d}`)).join('\n')
const gesetzt = new Set([...quelle.matchAll(/class: '([^']+)'/g)].flatMap((m) => m[1].split(/\s+/)).filter((k) => /^zt-/.test(k)))
assert.ok(gesetzt.size >= 5)
for (const klasse of gesetzt) assert.ok(css.includes(`.${klasse}`), `.${klasse} hat keine Gestaltung`)
})