Terminplaner: Verfuegbarkeit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
759be69250
commit
cce6e64dd1
2 files changed
+788
No files matched your search
@@ -0,0 +1,259 @@
|
||||
import { istFeiertag } from './feiertage.js'
|
||||
import { heute, monatInfo, plusTage, wochentag, zuUtc } from './zeit.js'
|
||||
|
||||
/**
|
||||
* Terminplaner-Verfuegbarkeit (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 3): reine Funktionen ohne
|
||||
* Appwrite und ohne config; `now` kommt immer als Parameter. Oeffnungsfenster sind Berliner Wanduhr, Belegungen
|
||||
* sind UTC-Zeitpunkte und werden nur als Zeitpunkte verglichen (nie je Kalendertag), damit eine Belegung vom Vortag
|
||||
* (22:00 bis 06:00) und die Zeitumstellung stimmen.
|
||||
*
|
||||
* Rangfolge fuer die Fenster eines Tages: Sonderoeffnung > Abwesenheit > Feiertag > Plan.
|
||||
* Eine Sonderoeffnung gilt fuer alle Ressourcen und ersetzt Plan, Feiertag und Abwesenheit (leere Liste = geschlossen).
|
||||
* Sonst schliesst eine Abwesenheit (Betrieb: ressourceId null) die Ressource, dann ein Feiertag (wenn
|
||||
* feiertageGeschlossen), sonst gilt der Wochenplan.
|
||||
*/
|
||||
|
||||
const MIN = 60000
|
||||
const RASTER_VORGABE = 15
|
||||
const FEIERTAG_CACHE_MAX = 5000
|
||||
/** Status, die eine Buchung nicht (mehr) belegen und nicht als Last des Tages zaehlen. */
|
||||
const NICHT_BELEGT = new Set(['abgesagt', 'abgelehnt', 'verfallen'])
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const hhmm = (min) => `${pad(Math.floor(min / 60))}:${pad(min % 60)}`
|
||||
|
||||
function pruefeNow(now, name) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`)
|
||||
}
|
||||
|
||||
/** Zeitpunkt als ms: Date, ISO-String (auch mit +00:00) oder Zahl; sonst NaN. */
|
||||
function zeitMs(wert) {
|
||||
if (wert instanceof Date) return wert.getTime()
|
||||
if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime()
|
||||
if (typeof wert === 'number') return wert
|
||||
return Number.NaN
|
||||
}
|
||||
|
||||
/** 'HH:MM' -> Minuten seit Mitternacht ('24:00' = 1440), sonst null. */
|
||||
function minuten(wert) {
|
||||
const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null
|
||||
if (!m) return null
|
||||
const h = Number(m[1])
|
||||
const min = Number(m[2])
|
||||
if (h > 24 || min > 59 || (h === 24 && min !== 0)) return null
|
||||
return h * 60 + min
|
||||
}
|
||||
|
||||
// istFeiertag kostet ~1,4 ms: das Ergebnis ist je Tag und Land fest, also einmal rechnen und merken.
|
||||
const feiertagMerker = new Map()
|
||||
function feiertag(tag, land) {
|
||||
const schluessel = `${tag}|${land}`
|
||||
let wert = feiertagMerker.get(schluessel)
|
||||
if (wert === undefined) {
|
||||
wert = istFeiertag(tag, land)
|
||||
if (feiertagMerker.size >= FEIERTAG_CACHE_MAX) feiertagMerker.clear()
|
||||
feiertagMerker.set(schluessel, wert)
|
||||
}
|
||||
return wert
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Belegung
|
||||
|
||||
function istBelegend(buchung, jetztMs) {
|
||||
if (buchung.status === 'angefragt' || buchung.status === 'bestaetigt') return true
|
||||
if (buchung.status === 'gehalten') return zeitMs(buchung.heldUntil) > jetztMs // abgelaufen oder ohne Haltezeit = frei
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Belegte Zeiten aus Buchungs-Dokumenten (Appwrite-Form: $id, ressourceId, status, startsAt, blockEndsAt, heldUntil):
|
||||
* belegend sind gehalten (nur solange heldUntil > now), angefragt und bestaetigt (auch Sperren). ende = blockEndsAt.
|
||||
* ohneBuchung ($id) nimmt eine Buchung heraus, z. B. beim Verschieben. Kaputte Eintraege werden uebersprungen.
|
||||
* => [{ ressourceId, start: Date, ende: Date }]
|
||||
*/
|
||||
export function belegungAus(buchungen, now, { ohneBuchung = null } = {}) {
|
||||
pruefeNow(now, 'belegungAus')
|
||||
const jetztMs = now.getTime()
|
||||
const aus = []
|
||||
for (const b of Array.isArray(buchungen) ? buchungen : []) {
|
||||
if (!b || typeof b !== 'object' || typeof b.ressourceId !== 'string') continue
|
||||
if (ohneBuchung !== null && b.$id === ohneBuchung) continue
|
||||
if (!istBelegend(b, jetztMs)) continue
|
||||
const start = zeitMs(b.startsAt)
|
||||
const ende = zeitMs(b.blockEndsAt)
|
||||
if (!Number.isFinite(start) || !Number.isFinite(ende) || ende <= start) continue
|
||||
aus.push({ ressourceId: b.ressourceId, start: new Date(start), ende: new Date(ende) })
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
/** Ende des belegten Blocks: Beginn + Dauer + Pause danach, in echten Minuten (auch ueber die Zeitumstellung). */
|
||||
export function blockEnde(start, leistung) {
|
||||
return new Date(start.getTime() + ((leistung.dauer || 0) + (leistung.pufferNach || 0)) * MIN)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Startzeiten eines Fensters
|
||||
|
||||
/** Naechste existierende Wanduhrzeit ab minute (Luecke der Maerz-Umstellung ueberspringen) als ms, sonst null. */
|
||||
function naechsterInstant(tag, minute) {
|
||||
for (let m = minute; m <= 1440 && m <= minute + 60; m += 5) {
|
||||
const d = zuUtc(tag, hhmm(m))
|
||||
if (d) return d.getTime()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Startzeiten eines Fensters [von, bis] (Minuten der Berliner Wanduhr) im Raster ab Fensterbeginn, deren Block
|
||||
* (blockMs) ganz im Fenster liegt. => { endeMs, starts: [{ uhrzeit, ms }] } oder null (unbrauchbares Fenster).
|
||||
* Wanduhrzeiten ohne Zeitpunkt (Maerz-Luecke) entfallen, doppelte (Oktober) sind das erste Vorkommen.
|
||||
*/
|
||||
function fensterStarts(tag, von, bis, raster, blockMs) {
|
||||
const beginn = zuUtc(tag, hhmm(von))
|
||||
const schluss = zuUtc(tag, hhmm(bis))
|
||||
const starts = []
|
||||
if (beginn && schluss && schluss.getTime() - beginn.getTime() === (bis - von) * MIN) {
|
||||
// Kein Uebergang im Fenster: Wanduhr und echte Zeit laufen gleich, die Zeitpunkte ergeben sich durch Addieren.
|
||||
const endeMs = schluss.getTime()
|
||||
for (let m = von, ms = beginn.getTime(); m < bis && ms + blockMs <= endeMs; m += raster, ms += raster * MIN) starts.push({ uhrzeit: hhmm(m), ms })
|
||||
return { endeMs, starts }
|
||||
}
|
||||
// Umstelltag: jede Startzeit einzeln umrechnen. Die Zeitpunkte wachsen mit der Wanduhr, sobald einer nicht mehr passt, passt keiner mehr.
|
||||
const endeMs = schluss ? schluss.getTime() : naechsterInstant(tag, bis)
|
||||
if (endeMs === null) return null
|
||||
for (let m = von; m < bis; m += raster) {
|
||||
const d = zuUtc(tag, hhmm(m))
|
||||
if (!d) continue
|
||||
if (d.getTime() + blockMs > endeMs) break
|
||||
starts.push({ uhrzeit: hhmm(m), ms: d.getTime() })
|
||||
}
|
||||
return { endeMs, starts }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Kontext und Tagesberechnung
|
||||
|
||||
/** Gemeinsame Vorbereitung fuer freieZeiten und freieTage; null, wenn nichts buchbar sein kann. */
|
||||
function kontext({ einstellung, leistungId, ressourceId = 'egal', belegung, now, ohneRegeln = false }, name) {
|
||||
pruefeNow(now, name)
|
||||
const leistung = (einstellung?.leistungen || []).find((l) => l?.id === leistungId)
|
||||
if (!leistung || !leistung.aktiv || !(leistung.dauer > 0)) return null
|
||||
const zugeordnet = new Set(leistung.ressourcen || [])
|
||||
let ressourcen = (einstellung.ressourcen || []).filter((r) => r?.aktiv && zugeordnet.has(r.id))
|
||||
if (ressourceId !== 'egal') ressourcen = ressourcen.filter((r) => r.id === ressourceId)
|
||||
if (ressourcen.length === 0) return null
|
||||
|
||||
const belegtJeRessource = new Map()
|
||||
for (const b of Array.isArray(belegung) ? belegung : []) {
|
||||
const start = zeitMs(b?.start)
|
||||
const ende = zeitMs(b?.ende)
|
||||
if (typeof b?.ressourceId !== 'string' || !(ende > start)) continue
|
||||
if (!belegtJeRessource.has(b.ressourceId)) belegtJeRessource.set(b.ressourceId, [])
|
||||
belegtJeRessource.get(b.ressourceId).push([start, ende])
|
||||
}
|
||||
|
||||
const regeln = einstellung.regeln || {}
|
||||
const jetzt = now.getTime()
|
||||
const heuteTag = heute(now)
|
||||
return {
|
||||
einstellung,
|
||||
ressourcen,
|
||||
raster: Number.isInteger(regeln.raster) && regeln.raster > 0 ? regeln.raster : RASTER_VORGABE,
|
||||
blockMs: (leistung.dauer + (leistung.pufferNach || 0)) * MIN,
|
||||
belegt: belegtJeRessource,
|
||||
heute: heuteTag,
|
||||
fruehestensMs: ohneRegeln ? jetzt : jetzt + (regeln.vorlaufMin || 0) * MIN, // nie in der Vergangenheit
|
||||
letzterTag: ohneRegeln || !Number.isInteger(regeln.horizontTage) ? null : plusTage(heuteTag, regeln.horizontTage),
|
||||
}
|
||||
}
|
||||
|
||||
const abwesend = (abwesenheiten, ressourceId, tag) => (abwesenheiten || []).some(
|
||||
(a) => a && (a.ressourceId === null || a.ressourceId === undefined || a.ressourceId === ressourceId) && a.von <= tag && tag <= a.bis,
|
||||
)
|
||||
|
||||
/**
|
||||
* Freie Startzeiten eines (gueltigen) Berliner Kalendertags, aufsteigend; nurPruefen bricht nach der ersten ab.
|
||||
* => [{ uhrzeit, start: Date, ressourceIds }]
|
||||
*/
|
||||
function tagZeiten(ctx, tag, nurPruefen) {
|
||||
if (tag < ctx.heute) return []
|
||||
if (ctx.letzterTag !== null && tag > ctx.letzterTag) return []
|
||||
const e = ctx.einstellung
|
||||
const sonder = Object.hasOwn(e.sonderoeffnungen || {}, tag) && Array.isArray(e.sonderoeffnungen[tag]) ? e.sonderoeffnungen[tag] : null
|
||||
const feiertagZu = sonder === null && e.feiertageGeschlossen !== false && feiertag(tag, e.bundesland)
|
||||
const wt = wochentag(tag)
|
||||
|
||||
const treffer = new Map() // ms -> Eintrag
|
||||
for (const r of ctx.ressourcen) {
|
||||
let fenster = sonder
|
||||
if (fenster === null) fenster = feiertagZu || abwesend(e.abwesenheiten, r.id, tag) ? [] : (r.plan?.[wt] || [])
|
||||
const belegtR = ctx.belegt.get(r.id) || []
|
||||
for (const f of Array.isArray(fenster) ? fenster : []) {
|
||||
const von = minuten(f?.[0])
|
||||
const bis = minuten(f?.[1])
|
||||
if (von === null || bis === null || bis <= von) continue
|
||||
const fs = fensterStarts(tag, von, bis, ctx.raster, ctx.blockMs)
|
||||
if (!fs || fs.starts.length === 0) continue
|
||||
const imFenster = belegtR.filter(([s, en]) => s < fs.endeMs && en > fs.starts[0].ms)
|
||||
for (const { uhrzeit, ms } of fs.starts) {
|
||||
if (ms < ctx.fruehestensMs) continue
|
||||
const ende = ms + ctx.blockMs
|
||||
if (imFenster.some(([s, en]) => s < ende && en > ms)) continue
|
||||
const vorhanden = treffer.get(ms)
|
||||
if (vorhanden) vorhanden.ressourceIds.push(r.id)
|
||||
else treffer.set(ms, { uhrzeit, start: new Date(ms), ressourceIds: [r.id] })
|
||||
if (nurPruefen) return [...treffer.values()]
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...treffer.values()].sort((a, b) => a.start - b.start)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Oeffentliche Abfragen
|
||||
|
||||
/**
|
||||
* Freie Startzeiten einer Leistung an einem Berliner Kalendertag. ressourceId: eine Ressource oder 'egal'
|
||||
* (Vereinigung ueber alle aktiven Ressourcen der Leistung, ressourceIds in der Reihenfolge der Einstellung).
|
||||
* belegung = belegungAus(...). ohneRegeln hebt Vorlauf und Horizont auf (nie die Vergangenheit), z. B. fuer manuelle Termine.
|
||||
* Ungueltiger Tag, unbekannte oder inaktive Leistung, nicht zugeordnete oder inaktive Ressource => [].
|
||||
* => [{ uhrzeit: 'HH:MM', start: Date, ressourceIds }]
|
||||
*/
|
||||
export function freieZeiten(parameter) {
|
||||
const ctx = kontext(parameter, 'freieZeiten')
|
||||
const tag = parameter.tag
|
||||
if (!ctx || typeof tag !== 'string' || zuUtc(tag, '12:00') === null) return []
|
||||
return tagZeiten(ctx, tag, false)
|
||||
}
|
||||
|
||||
/** Tage ('YYYY-MM-DD') des Monats ('YYYY-MM') mit mindestens einer freien Startzeit; sonst wie freieZeiten. */
|
||||
export function freieTage(parameter) {
|
||||
const info = monatInfo(parameter.monat)
|
||||
const ctx = kontext(parameter, 'freieTage')
|
||||
if (!info || !ctx) return []
|
||||
const tage = []
|
||||
for (let i = 1; i <= info.anzahlTage; i += 1) {
|
||||
const tag = `${parameter.monat}-${pad(i)}`
|
||||
if (tagZeiten(ctx, tag, true).length > 0) tage.push(tag)
|
||||
}
|
||||
return tage
|
||||
}
|
||||
|
||||
/**
|
||||
* Wahl unter mehreren freien Ressourcen ('egal'): die mit den wenigsten Buchungen an dem Tag; bei Gleichstand die
|
||||
* in der Einstellung zuerst genannte. Abgesagte, abgelehnte und verfallene Buchungen sowie Sperren zaehlen nicht.
|
||||
* Keine Kandidaten => null.
|
||||
*/
|
||||
export function waehleRessource(ids, buchungenAmTag, einstellung) {
|
||||
const kandidaten = [...new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string'))]
|
||||
if (kandidaten.length === 0) return null
|
||||
const anzahl = new Map(kandidaten.map((id) => [id, 0]))
|
||||
for (const b of Array.isArray(buchungenAmTag) ? buchungenAmTag : []) {
|
||||
if (!b || b.quelle === 'sperre' || NICHT_BELEGT.has(b.status) || !anzahl.has(b.ressourceId)) continue
|
||||
anzahl.set(b.ressourceId, anzahl.get(b.ressourceId) + 1)
|
||||
}
|
||||
const rang = new Map((einstellung?.ressourcen || []).map((r, i) => [r?.id, i]))
|
||||
const rangVon = (id) => (rang.has(id) ? rang.get(id) : Infinity) // unbekannte zuletzt, dort gilt die Reihenfolge von ids
|
||||
return kandidaten.reduce((beste, id) => {
|
||||
if (anzahl.get(id) !== anzahl.get(beste)) return anzahl.get(id) < anzahl.get(beste) ? id : beste
|
||||
return rangVon(id) < rangVon(beste) ? id : beste
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { belegungAus, freieZeiten, freieTage, waehleRessource, blockEnde } from '../server/services/termine/slots.js'
|
||||
import { zuUtc } from '../server/services/termine/zeit.js'
|
||||
import { ausVorlage } from '../server/services/termine/vorlagen.js'
|
||||
|
||||
// ---------------------------------------------------------------- Bausteine
|
||||
|
||||
const R1 = 'r_aaaaaaaa'
|
||||
const R2 = 'r_bbbbbbbb'
|
||||
const R3 = 'r_cccccccc'
|
||||
const S = 's_aaaaaaaa'
|
||||
const JETZT = new Date('2026-10-01T06:00:00Z') // Do 01.10.2026 08:00 Berlin
|
||||
const MO = '2026-10-12' // Montag
|
||||
|
||||
const wochenplan = (fenster, tage = [1, 2, 3, 4, 5, 6, 7]) => Object.fromEntries(tage.map((t) => [t, fenster]))
|
||||
const ressource = (id, plan, extra = {}) => ({ id, typ: 'person', name: id, aktiv: true, plan, ...extra })
|
||||
const TAG = [['09:00', '17:00']]
|
||||
|
||||
function einstellung({
|
||||
ressourcen = [ressource(R1, wochenplan(TAG))], dauer = 60, pufferNach = 0, raster = 15, vorlaufMin = 0, horizontTage = 365,
|
||||
zuordnung = null, ...rest
|
||||
} = {}) {
|
||||
return {
|
||||
bundesland: 'RP',
|
||||
feiertageGeschlossen: true,
|
||||
sonderoeffnungen: {},
|
||||
abwesenheiten: [],
|
||||
regeln: { raster, vorlaufMin, horizontTage },
|
||||
ressourcen,
|
||||
leistungen: [{ id: S, dauer, pufferNach, ressourcen: zuordnung || ressourcen.map((r) => r.id), aktiv: true }],
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
const frei = (e, tag, extra = {}) => freieZeiten({ einstellung: e, leistungId: S, ressourceId: 'egal', tag, belegung: [], now: JETZT, ...extra })
|
||||
const uhrzeiten = (liste) => liste.map((z) => z.uhrzeit)
|
||||
const appwrite = (date) => date.toISOString().replace('Z', '+00:00') // Appwrite liefert +00:00
|
||||
const buchung = (id, ressourceId, von, bis, status = 'bestaetigt', extra = {}) => ({
|
||||
$id: id, ressourceId, status, startsAt: appwrite(von), blockEndsAt: appwrite(bis), ...extra,
|
||||
})
|
||||
const belegt = (ressourceId, von, bis) => ({ ressourceId, start: von, ende: bis })
|
||||
const um = (tag, hhmm) => zuUtc(tag, hhmm)
|
||||
|
||||
// ---------------------------------------------------------------- belegungAus
|
||||
|
||||
test('belegungAus: nur belegende Status, Haltung nur solange heldUntil in der Zukunft liegt', () => {
|
||||
const now = new Date('2026-10-12T08:00:00Z')
|
||||
const von = new Date('2026-10-12T10:00:00Z')
|
||||
const bis = new Date('2026-10-12T11:00:00Z')
|
||||
const buchungen = [
|
||||
buchung('b1', R1, von, bis, 'bestaetigt'),
|
||||
buchung('b2', R1, von, bis, 'angefragt'),
|
||||
buchung('b3', R1, von, bis, 'gehalten', { heldUntil: appwrite(new Date('2026-10-12T08:00:01Z')) }),
|
||||
buchung('b4', R1, von, bis, 'gehalten', { heldUntil: appwrite(new Date('2026-10-12T08:00:00Z')) }), // genau jetzt = abgelaufen
|
||||
buchung('b5', R1, von, bis, 'gehalten', { heldUntil: appwrite(new Date('2026-10-12T07:00:00Z')) }),
|
||||
buchung('b6', R1, von, bis, 'gehalten'), // ohne heldUntil
|
||||
...['erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen', 'unbekannt'].map((s, i) => buchung(`x${i}`, R1, von, bis, s)),
|
||||
]
|
||||
const ergebnis = belegungAus(buchungen, now)
|
||||
assert.equal(ergebnis.length, 3)
|
||||
assert.deepEqual(ergebnis[0], { ressourceId: R1, start: von, ende: bis })
|
||||
assert.ok(ergebnis.every((b) => b.start instanceof Date && b.ende instanceof Date))
|
||||
})
|
||||
|
||||
test('belegungAus: ende ist blockEndsAt (mit Puffer), +00:00-Schreibweise wird gelesen', () => {
|
||||
const [eintrag] = belegungAus([{ $id: 'b', ressourceId: R2, status: 'bestaetigt', startsAt: '2026-10-12T08:00:00.000+00:00', blockEndsAt: '2026-10-12T09:15:00.000+00:00' }], JETZT)
|
||||
assert.equal(eintrag.ressourceId, R2)
|
||||
assert.equal(eintrag.start.toISOString(), '2026-10-12T08:00:00.000Z')
|
||||
assert.equal(eintrag.ende.toISOString(), '2026-10-12T09:15:00.000Z')
|
||||
})
|
||||
|
||||
test('belegungAus: ohneBuchung nimmt die eigene Buchung heraus', () => {
|
||||
const von = new Date('2026-10-12T08:00:00Z')
|
||||
const bis = new Date('2026-10-12T09:00:00Z')
|
||||
const buchungen = [buchung('eigene', R1, von, bis), buchung('fremde', R1, new Date('2026-10-12T10:00:00Z'), new Date('2026-10-12T11:00:00Z'))]
|
||||
assert.equal(belegungAus(buchungen, JETZT, { ohneBuchung: 'eigene' }).length, 1)
|
||||
assert.equal(belegungAus(buchungen, JETZT, { ohneBuchung: 'eigene' })[0].start.toISOString(), '2026-10-12T10:00:00.000Z')
|
||||
assert.equal(belegungAus(buchungen, JETZT, {}).length, 2)
|
||||
assert.equal(belegungAus(buchungen, JETZT).length, 2)
|
||||
})
|
||||
|
||||
test('belegungAus: kaputte Eintraege werden uebersprungen, falsche Parameter bleiben ohne Absturz', () => {
|
||||
const gut = buchung('g', R1, new Date('2026-10-12T08:00:00Z'), new Date('2026-10-12T09:00:00Z'))
|
||||
const liste = [
|
||||
null, undefined, 'x', 42, {},
|
||||
{ ...gut, $id: 'a', startsAt: 'kein Datum' },
|
||||
{ ...gut, $id: 'b', blockEndsAt: null },
|
||||
{ ...gut, $id: 'c', blockEndsAt: gut.startsAt }, // Ende nicht nach Beginn
|
||||
{ ...gut, $id: 'd', ressourceId: undefined },
|
||||
gut,
|
||||
]
|
||||
assert.deepEqual(belegungAus(liste, JETZT).map((b) => b.ressourceId), [R1])
|
||||
assert.deepEqual(belegungAus(null, JETZT), [])
|
||||
assert.throws(() => belegungAus([], undefined), TypeError)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- blockEnde
|
||||
|
||||
test('blockEnde: Dauer plus Puffer in echten Minuten', () => {
|
||||
const start = new Date('2026-10-12T08:00:00Z')
|
||||
assert.equal(blockEnde(start, { dauer: 45, pufferNach: 15 }).toISOString(), '2026-10-12T09:00:00.000Z')
|
||||
assert.equal(blockEnde(start, { dauer: 30 }).toISOString(), '2026-10-12T08:30:00.000Z')
|
||||
assert.equal(start.toISOString(), '2026-10-12T08:00:00.000Z', 'start bleibt unveraendert')
|
||||
})
|
||||
|
||||
test('blockEnde: 60 Minuten um 02:00 am 25.10.2026 dauern real 60 Minuten', () => {
|
||||
const start = um('2026-10-25', '02:00')
|
||||
assert.equal(start.toISOString(), '2026-10-25T00:00:00.000Z')
|
||||
assert.equal(blockEnde(start, { dauer: 60, pufferNach: 0 }).toISOString(), '2026-10-25T01:00:00.000Z')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Plan, Pause, Puffer, Fensterende
|
||||
|
||||
test('Pause: zwischen den Fenstern gibt es keine Startzeiten', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '12:00'], ['13:00', '17:00']]))], raster: 60 })
|
||||
assert.deepEqual(uhrzeiten(frei(e, MO)), ['09:00', '10:00', '11:00', '13:00', '14:00', '15:00', '16:00'])
|
||||
})
|
||||
|
||||
test('Puffer am Pausenrand: Termin plus Puffer muss ganz ins Fenster passen', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '12:00'], ['13:00', '17:00']]))], dauer: 60, pufferNach: 15, raster: 15 })
|
||||
const zeiten = uhrzeiten(frei(e, MO))
|
||||
assert.ok(zeiten.includes('10:45'), 'letzter Start vor der Pause: 10:45 + 75 min = 12:00')
|
||||
assert.ok(!zeiten.includes('11:00'), '11:00 + 75 min ragt in die Pause')
|
||||
assert.ok(zeiten.includes('15:45'))
|
||||
assert.ok(!zeiten.includes('16:00'))
|
||||
assert.equal(zeiten.at(-1), '15:45')
|
||||
})
|
||||
|
||||
test('letzter Termin endet genau am Fensterende, nicht eine Rasterstufe spaeter', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '12:00']]))], dauer: 45, raster: 15 })
|
||||
const liste = frei(e, MO)
|
||||
assert.deepEqual(uhrzeiten(liste).slice(-2), ['11:00', '11:15'])
|
||||
assert.equal(blockEnde(liste.at(-1).start, { dauer: 45, pufferNach: 0 }).getTime(), um(MO, '12:00').getTime())
|
||||
})
|
||||
|
||||
test('Raster beginnt am Fensterbeginn (13:10 mit 30-Minuten-Raster)', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['13:10', '15:00']]))], dauer: 30, raster: 30 })
|
||||
assert.deepEqual(uhrzeiten(frei(e, MO)), ['13:10', '13:40', '14:10'])
|
||||
})
|
||||
|
||||
test('Fenster bis 24:00 funktioniert', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['22:00', '24:00']]))], dauer: 60, raster: 30 })
|
||||
assert.deepEqual(uhrzeiten(frei(e, MO)), ['22:00', '22:30', '23:00'])
|
||||
})
|
||||
|
||||
test('Tag ohne Plan ist leer, start ist ein Date in UTC', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan(TAG, [1, 2, 3, 4, 5]))] })
|
||||
assert.deepEqual(frei(e, '2026-10-17'), []) // Samstag
|
||||
const [erste] = frei(e, MO)
|
||||
assert.equal(erste.uhrzeit, '09:00')
|
||||
assert.ok(erste.start instanceof Date)
|
||||
assert.equal(erste.start.toISOString(), '2026-10-12T07:00:00.000Z')
|
||||
assert.deepEqual(erste.ressourceIds, [R1])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Vorlauf, Horizont, Vergangenheit
|
||||
|
||||
test('Vorlauf: Start fruehestens now + vorlaufMin (genau gleich ist erlaubt)', () => {
|
||||
const now = new Date('2026-10-12T06:00:00Z') // Mo 08:00 Berlin
|
||||
const plan = [ressource(R1, wochenplan([['09:00', '12:00']]))]
|
||||
assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 120 }), MO, { now })), ['10:00', '10:30', '11:00'])
|
||||
assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 60 }), MO, { now })), ['09:00', '09:30', '10:00', '10:30', '11:00'])
|
||||
assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 65 }), MO, { now })), ['09:30', '10:00', '10:30', '11:00'])
|
||||
assert.deepEqual(uhrzeiten(frei(einstellung({ ressourcen: plan, raster: 30, vorlaufMin: 120 }), MO, { now, ohneRegeln: true })), ['09:00', '09:30', '10:00', '10:30', '11:00'])
|
||||
})
|
||||
|
||||
test('Horizont: letzter buchbarer Tag ist heute + horizontTage', () => {
|
||||
const e = einstellung({ horizontTage: 7 })
|
||||
assert.ok(frei(e, '2026-10-08').length > 0, 'Tag 7 ist noch buchbar')
|
||||
assert.deepEqual(frei(e, '2026-10-09'), [])
|
||||
assert.ok(frei(e, '2026-10-09', { ohneRegeln: true }).length > 0, 'ohneRegeln hebt den Horizont auf')
|
||||
assert.ok(frei(e, '2027-03-01', { ohneRegeln: true }).length > 0)
|
||||
})
|
||||
|
||||
test('Horizont zaehlt ab dem Berliner Kalendertag (nicht UTC)', () => {
|
||||
const e = einstellung({ horizontTage: 1 })
|
||||
const now = new Date('2026-10-11T22:30:00Z') // schon Mo 12.10. 00:30 Berlin
|
||||
assert.ok(frei(e, '2026-10-13', { now }).length > 0)
|
||||
assert.deepEqual(frei(e, '2026-10-14', { now }), [])
|
||||
})
|
||||
|
||||
test('nie Vergangenheit, auch nicht mit ohneRegeln', () => {
|
||||
const e = einstellung({ raster: 30, vorlaufMin: 240 })
|
||||
const now = new Date('2026-10-12T10:07:00Z') // 12:07 Berlin
|
||||
assert.equal(frei(e, MO, { now, ohneRegeln: true })[0].uhrzeit, '12:30')
|
||||
assert.deepEqual(frei(e, MO, { now }), [], 'mit Vorlauf 240 min: fruehestens 16:07, 16:30 + 60 min passt nicht mehr in den Tag')
|
||||
assert.deepEqual(frei(e, '2026-10-11', { now, ohneRegeln: true }), [])
|
||||
assert.deepEqual(frei(e, '2026-09-01', { now, ohneRegeln: true }), [])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Abwesenheit, Feiertag, Sonderoeffnung
|
||||
|
||||
test('Abwesenheit einer Ressource: nur diese Ressource faellt aus, von und bis gelten ganztaegig inklusive', () => {
|
||||
const e = einstellung({
|
||||
ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))],
|
||||
abwesenheiten: [{ id: 'a_aaaaaaaa', ressourceId: R1, von: '2026-10-12', bis: '2026-10-13', titel: 'Urlaub' }],
|
||||
})
|
||||
assert.deepEqual(frei(e, '2026-10-12', { ressourceId: R1 }), [])
|
||||
assert.deepEqual(frei(e, '2026-10-13', { ressourceId: R1 }), [])
|
||||
assert.ok(frei(e, '2026-10-14', { ressourceId: R1 }).length > 0)
|
||||
assert.ok(frei(e, '2026-10-11', { ressourceId: R1 }).length > 0)
|
||||
assert.deepEqual(frei(e, '2026-10-12', { ressourceId: R2 })[0].ressourceIds, [R2])
|
||||
assert.deepEqual(frei(e, '2026-10-12')[0].ressourceIds, [R2], 'egal: nur die anwesende Person')
|
||||
})
|
||||
|
||||
test('Abwesenheit des Betriebs (ressourceId null) sperrt alle Ressourcen', () => {
|
||||
const e = einstellung({
|
||||
ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))],
|
||||
abwesenheiten: [{ id: 'a_bbbbbbbb', ressourceId: null, von: '2026-10-12', bis: '2026-10-14', titel: 'Betriebsurlaub' }],
|
||||
})
|
||||
for (const tag of ['2026-10-12', '2026-10-13', '2026-10-14']) {
|
||||
assert.deepEqual(frei(e, tag), [], tag)
|
||||
assert.deepEqual(frei(e, tag, { ressourceId: R1 }), [], tag)
|
||||
}
|
||||
assert.ok(frei(e, '2026-10-15').length > 0)
|
||||
})
|
||||
|
||||
test('Feiertag schliesst, wenn feiertageGeschlossen; abhaengig vom Bundesland', () => {
|
||||
const ressourcen = [ressource(R1, wochenplan(TAG))]
|
||||
const feiertag = '2026-10-03' // Samstag, Tag der Deutschen Einheit
|
||||
assert.deepEqual(frei(einstellung({ ressourcen }), feiertag), [])
|
||||
assert.ok(frei(einstellung({ ressourcen, feiertageGeschlossen: false }), feiertag).length > 0)
|
||||
assert.deepEqual(frei(einstellung({ ressourcen, bundesland: 'BE' }), '2026-12-25'), [])
|
||||
// Allerheiligen (01.11.2026, Sonntag): in RP Feiertag, in BE nicht
|
||||
assert.deepEqual(frei(einstellung({ ressourcen, bundesland: 'RP' }), '2026-11-01'), [])
|
||||
assert.ok(frei(einstellung({ ressourcen, bundesland: 'BE' }), '2026-11-01').length > 0)
|
||||
})
|
||||
|
||||
test('Sonderoeffnung schlaegt den Feiertag und ersetzt den Plan', () => {
|
||||
const ressourcen = [ressource(R1, wochenplan([['09:00', '17:00']]))]
|
||||
const feiertag = '2026-10-03'
|
||||
const e = einstellung({ ressourcen, raster: 60, sonderoeffnungen: { [feiertag]: [['10:00', '13:00']] } })
|
||||
assert.deepEqual(uhrzeiten(frei(e, feiertag)), ['10:00', '11:00', '12:00'])
|
||||
// auch an einem normalen Tag ersetzt sie den Plan
|
||||
const e2 = einstellung({ ressourcen, raster: 60, sonderoeffnungen: { [MO]: [['14:00', '16:00']] } })
|
||||
assert.deepEqual(uhrzeiten(frei(e2, MO)), ['14:00', '15:00'])
|
||||
assert.equal(frei(e2, '2026-10-13').length, 8)
|
||||
})
|
||||
|
||||
test('Sonderoeffnung mit leerer Liste: an diesem Tag geschlossen', () => {
|
||||
const e = einstellung({ sonderoeffnungen: { [MO]: [] } })
|
||||
assert.deepEqual(frei(e, MO), [])
|
||||
assert.ok(frei(e, '2026-10-13').length > 0)
|
||||
})
|
||||
|
||||
test('Sonderoeffnung gilt fuer alle Ressourcen und schlaegt die Abwesenheit (Rangfolge Sonderoeffnung > Abwesenheit > Feiertag > Plan)', () => {
|
||||
const e = einstellung({
|
||||
ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan([['08:00', '09:00']]))],
|
||||
raster: 60,
|
||||
sonderoeffnungen: { [MO]: [['10:00', '12:00']] },
|
||||
abwesenheiten: [{ id: 'a_cccccccc', ressourceId: R1, von: MO, bis: MO, titel: 'Urlaub' }],
|
||||
})
|
||||
const liste = frei(e, MO)
|
||||
assert.deepEqual(uhrzeiten(liste), ['10:00', '11:00'])
|
||||
assert.deepEqual(liste[0].ressourceIds, [R1, R2])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Belegung
|
||||
|
||||
test('Belegung blockiert Ueberlappung, angrenzende Zeiten bleiben frei', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '13:00']]))], raster: 30 })
|
||||
const belegung = [belegt(R1, um(MO, '10:00'), um(MO, '11:00'))]
|
||||
assert.deepEqual(uhrzeiten(frei(e, MO, { belegung })), ['09:00', '11:00', '11:30', '12:00'])
|
||||
// 09:30 + 60 min ragt in die Belegung, 09:00 endet genau am Beginn der Belegung
|
||||
})
|
||||
|
||||
test('Belegung mit Puffer: blockEndsAt zaehlt, nicht das Terminende', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '13:00']]))], raster: 15, dauer: 30 })
|
||||
const belegung = belegungAus([buchung('b', R1, um(MO, '10:00'), um(MO, '10:45'))], JETZT) // 30 min + 15 min Puffer
|
||||
const zeiten = uhrzeiten(frei(e, MO, { belegung }))
|
||||
assert.ok(!zeiten.includes('10:30'))
|
||||
assert.ok(zeiten.includes('10:45'))
|
||||
assert.ok(zeiten.includes('09:30'))
|
||||
assert.ok(!zeiten.includes('09:45'), '09:45 + 30 min ragt in die Belegung')
|
||||
})
|
||||
|
||||
test('Belegung einer anderen Ressource stoert nicht', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))], raster: 60 })
|
||||
const belegung = [belegt(R2, um(MO, '09:00'), um(MO, '17:00'))]
|
||||
assert.equal(frei(e, MO, { ressourceId: R1, belegung }).length, 8)
|
||||
assert.deepEqual(frei(e, MO, { ressourceId: R2, belegung }), [])
|
||||
})
|
||||
|
||||
test('abgelaufene Haltung blockiert nicht, gueltige Haltung schon', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '11:00']]))], raster: 60 })
|
||||
const now = new Date('2026-10-12T05:00:00Z') // Mo 07:00 Berlin
|
||||
const haltung = (heldUntil) => buchung('h', R1, um(MO, '09:00'), um(MO, '10:00'), 'gehalten', { heldUntil: appwrite(heldUntil) })
|
||||
const abgelaufen = belegungAus([haltung(new Date('2026-10-12T04:59:00Z'))], now)
|
||||
const gueltig = belegungAus([haltung(new Date('2026-10-12T05:30:00Z'))], now)
|
||||
assert.deepEqual(uhrzeiten(frei(e, MO, { belegung: abgelaufen, now })), ['09:00', '10:00'])
|
||||
assert.deepEqual(uhrzeiten(frei(e, MO, { belegung: gueltig, now })), ['10:00'])
|
||||
})
|
||||
|
||||
test('ohneBuchung gibt die eigene Zeit beim Verschieben frei', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['09:00', '11:00']]))], raster: 60 })
|
||||
const buchungen = [buchung('eigene', R1, um(MO, '09:00'), um(MO, '10:00')), buchung('fremde', R1, um(MO, '10:00'), um(MO, '11:00'))]
|
||||
assert.deepEqual(frei(e, MO, { belegung: belegungAus(buchungen, JETZT) }), [])
|
||||
assert.deepEqual(uhrzeiten(frei(e, MO, { belegung: belegungAus(buchungen, JETZT, { ohneBuchung: 'eigene' }) })), ['09:00'])
|
||||
})
|
||||
|
||||
test('Belegung von 22:00 am Vortag bis 06:00 blockiert 05:30', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['05:00', '09:00']]))], raster: 30, dauer: 30 })
|
||||
const belegung = belegungAus([buchung('nacht', R1, um('2026-10-11', '22:00'), um(MO, '06:00'))], JETZT)
|
||||
assert.equal(belegung[0].start.toISOString(), '2026-10-11T20:00:00.000Z')
|
||||
const zeiten = uhrzeiten(frei(e, MO, { belegung }))
|
||||
assert.ok(!zeiten.includes('05:00'))
|
||||
assert.ok(!zeiten.includes('05:30'))
|
||||
assert.deepEqual(zeiten, ['06:00', '06:30', '07:00', '07:30', '08:00', '08:30'])
|
||||
// am Vortag blockiert dieselbe Belegung 22:00 bis 24:00
|
||||
const vortag = einstellung({ ressourcen: [ressource(R1, wochenplan([['20:00', '24:00']]))], raster: 60, dauer: 60 })
|
||||
assert.deepEqual(uhrzeiten(frei(vortag, '2026-10-11', { belegung })), ['20:00', '21:00'])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Egal, Ressourcen, Leistungen
|
||||
|
||||
test('egal: Vereinigung ueber alle Ressourcen der Leistung, ressourceIds nach Liste', () => {
|
||||
const e = einstellung({
|
||||
ressourcen: [ressource(R1, wochenplan([['09:00', '12:00']])), ressource(R2, wochenplan([['11:00', '14:00']]))],
|
||||
raster: 60,
|
||||
})
|
||||
const liste = frei(e, MO)
|
||||
assert.deepEqual(liste.map((z) => [z.uhrzeit, z.ressourceIds]), [
|
||||
['09:00', [R1]], ['10:00', [R1]], ['11:00', [R1, R2]], ['12:00', [R2]], ['13:00', [R2]],
|
||||
])
|
||||
})
|
||||
|
||||
test('egal mit zwei Personen: belegte Person verschwindet nur dort, wo die andere frei ist', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))], raster: 60, dauer: 60 })
|
||||
const belegung = [belegt(R1, um(MO, '10:00'), um(MO, '11:00')), belegt(R1, um(MO, '12:00'), um(MO, '13:00')), belegt(R2, um(MO, '12:00'), um(MO, '13:00'))]
|
||||
const liste = frei(e, MO, { belegung })
|
||||
const zu = (uhr) => liste.find((z) => z.uhrzeit === uhr)
|
||||
assert.deepEqual(zu('09:00').ressourceIds, [R1, R2])
|
||||
assert.deepEqual(zu('10:00').ressourceIds, [R2])
|
||||
assert.equal(zu('12:00'), undefined, 'beide belegt: Zeit entfaellt')
|
||||
assert.deepEqual(zu('13:00').ressourceIds, [R1, R2])
|
||||
assert.ok(!uhrzeiten(frei(e, MO, { belegung, ressourceId: R1 })).includes('10:00'))
|
||||
assert.ok(uhrzeiten(frei(e, MO, { belegung, ressourceId: R2 })).includes('10:00'))
|
||||
})
|
||||
|
||||
test('Ressourcen: nur aktive und der Leistung zugeordnete, unbekannte oder nicht zugeordnete ergeben nichts', () => {
|
||||
const e = einstellung({
|
||||
ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG), { aktiv: false }), ressource(R3, wochenplan(TAG))],
|
||||
zuordnung: [R1, R2],
|
||||
raster: 60,
|
||||
})
|
||||
assert.deepEqual(frei(e, MO)[0].ressourceIds, [R1])
|
||||
assert.deepEqual(frei(e, MO, { ressourceId: R2 }), [], 'inaktive Ressource')
|
||||
assert.deepEqual(frei(e, MO, { ressourceId: R3 }), [], 'Leistung nicht zugeordnet')
|
||||
assert.deepEqual(frei(e, MO, { ressourceId: 'r_unbekannt' }), [])
|
||||
})
|
||||
|
||||
test('Leistung: unbekannt oder inaktiv ergibt nichts, ungueltiger Tag auch', () => {
|
||||
const e = einstellung()
|
||||
assert.ok(frei(e, MO).length > 0)
|
||||
assert.deepEqual(freieZeiten({ einstellung: e, leistungId: 's_unbekannt', ressourceId: 'egal', tag: MO, belegung: [], now: JETZT }), [])
|
||||
const inaktiv = { ...e, leistungen: [{ ...e.leistungen[0], aktiv: false }] }
|
||||
assert.deepEqual(frei(inaktiv, MO), [])
|
||||
for (const tag of ['2026-02-30', '2026-10-1', 'morgen', '', undefined, null, 20261012]) assert.deepEqual(frei(e, tag), [], String(tag))
|
||||
assert.throws(() => freieZeiten({ einstellung: e, leistungId: S, tag: MO, belegung: [] }), TypeError)
|
||||
})
|
||||
|
||||
test('mit einer echten Branchenvorlage (Salon) liefert die Berechnung Termine', () => {
|
||||
const e = ausVorlage('salon', { name: 'Salon Test', email: 'salon@example.com' }, JETZT)
|
||||
const leistung = e.leistungen[0]
|
||||
const liste = freieZeiten({ einstellung: e, leistungId: leistung.id, ressourceId: 'egal', tag: '2026-10-13', belegung: [], now: JETZT })
|
||||
assert.ok(liste.length > 0)
|
||||
for (const z of liste) {
|
||||
assert.match(z.uhrzeit, /^\d\d:\d\d$/)
|
||||
assert.ok(z.start instanceof Date)
|
||||
assert.ok(z.ressourceIds.length > 0)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Zeitumstellung
|
||||
|
||||
test('2026-03-29: keine Startzeiten 02:xx, Termine laufen ueber die Luecke in echten Minuten', () => {
|
||||
const now = new Date('2026-03-01T00:00:00Z')
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '06:00']]))], dauer: 30, raster: 30 })
|
||||
const liste = frei(e, '2026-03-29', { now })
|
||||
assert.deepEqual(uhrzeiten(liste), ['00:00', '00:30', '01:00', '01:30', '03:00', '03:30', '04:00', '04:30', '05:00', '05:30'])
|
||||
assert.ok(!uhrzeiten(liste).some((u) => u.startsWith('02:')))
|
||||
const start = (uhr) => liste.find((z) => z.uhrzeit === uhr).start.toISOString()
|
||||
assert.equal(start('01:30'), '2026-03-29T00:30:00.000Z')
|
||||
assert.equal(start('03:00'), '2026-03-29T01:00:00.000Z') // direkt nach 01:30 + 30 min
|
||||
assert.equal(start('05:30'), '2026-03-29T03:30:00.000Z')
|
||||
})
|
||||
|
||||
test('2026-03-29: Fenster, das in der Luecke beginnt oder endet', () => {
|
||||
const now = new Date('2026-03-01T00:00:00Z')
|
||||
const beginn = einstellung({ ressourcen: [ressource(R1, wochenplan([['02:00', '05:00']]))], dauer: 60, raster: 30 })
|
||||
assert.deepEqual(uhrzeiten(frei(beginn, '2026-03-29', { now })), ['03:00', '03:30', '04:00'])
|
||||
const ende = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '02:30']]))], dauer: 30, raster: 30 })
|
||||
assert.deepEqual(uhrzeiten(frei(ende, '2026-03-29', { now })), ['00:00', '00:30', '01:00', '01:30'])
|
||||
const nurLuecke = einstellung({ ressourcen: [ressource(R1, wochenplan([['02:00', '03:00']]))], dauer: 30, raster: 30 })
|
||||
assert.deepEqual(frei(nurLuecke, '2026-03-29', { now }), [])
|
||||
})
|
||||
|
||||
test('2026-10-25: doppelte Stunde, 60-Minuten-Termin um 02:00 dauert real 60 Minuten', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '06:00']]))], dauer: 60, raster: 60 })
|
||||
const liste = frei(e, '2026-10-25')
|
||||
assert.deepEqual(uhrzeiten(liste), ['00:00', '01:00', '02:00', '03:00', '04:00', '05:00'], 'jede Uhrzeit hoechstens einmal')
|
||||
const start = (uhr) => liste.find((z) => z.uhrzeit === uhr).start.toISOString()
|
||||
assert.equal(start('02:00'), '2026-10-25T00:00:00.000Z', 'erstes Vorkommen (Sommerzeit)')
|
||||
assert.equal(start('03:00'), '2026-10-25T02:00:00.000Z')
|
||||
// Fremdbuchung in der zweiten 02-Uhr-Stunde (01:00Z bis 02:00Z): Termine um 02:00 (00:00Z bis 01:00Z) und 03:00 (ab 02:00Z) bleiben frei
|
||||
const zweiteStunde = [belegt(R1, new Date('2026-10-25T01:00:00Z'), new Date('2026-10-25T02:00:00Z'))]
|
||||
assert.deepEqual(uhrzeiten(frei(e, '2026-10-25', { belegung: zweiteStunde })), ['00:00', '01:00', '02:00', '03:00', '04:00', '05:00'])
|
||||
// Fremdbuchung 00:30Z bis 01:30Z blockiert den Termin um 02:00 (00:00Z bis 01:00Z), nicht 01:00 (23:00Z bis 00:00Z) und nicht 03:00 (ab 02:00Z)
|
||||
const ueberlappt = [belegt(R1, new Date('2026-10-25T00:30:00Z'), new Date('2026-10-25T01:30:00Z'))]
|
||||
assert.deepEqual(uhrzeiten(frei(e, '2026-10-25', { belegung: ueberlappt })), ['00:00', '01:00', '03:00', '04:00', '05:00'])
|
||||
})
|
||||
|
||||
test('2026-10-25: letzter Termin endet genau um 06:00', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([['00:00', '06:00']]))], dauer: 60, raster: 30 })
|
||||
const liste = frei(e, '2026-10-25')
|
||||
assert.equal(liste.at(-1).uhrzeit, '05:00')
|
||||
assert.equal(blockEnde(liste.at(-1).start, { dauer: 60, pufferNach: 0 }).toISOString(), '2026-10-25T05:00:00.000Z') // 06:00 MEZ
|
||||
})
|
||||
|
||||
test('Umstelltage: Ergebnis entspricht einer Referenzrechnung je Startzeit (zuUtc je Kandidat)', () => {
|
||||
const dauer = 30
|
||||
const raster = 15
|
||||
const mins = (hhmm) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3))
|
||||
const hhmm = (m) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
|
||||
const fenster = [['00:00', '06:00'], ['01:00', '04:00'], ['02:00', '05:00'], ['00:00', '02:30'], ['02:30', '06:00'], ['08:00', '24:00'], ['22:00', '24:00'], ['09:00', '17:00']]
|
||||
for (const tag of ['2026-03-27', '2026-03-28', '2026-03-29', '2026-03-30', '2026-10-24', '2026-10-25', '2026-10-26']) {
|
||||
const now = tag < '2026-06' ? new Date('2026-03-01T00:00:00Z') : JETZT
|
||||
for (const [von, bis] of fenster) {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, wochenplan([[von, bis]]))], dauer, raster })
|
||||
const ende = (zuUtc(tag, bis) || zuUtc(tag, '03:00')).getTime()
|
||||
const erwartet = []
|
||||
for (let m = mins(von); m < mins(bis); m += raster) {
|
||||
const d = zuUtc(tag, hhmm(m))
|
||||
if (d && d.getTime() + dauer * 60000 <= ende) erwartet.push([hhmm(m), d.toISOString()])
|
||||
}
|
||||
const ist = frei(e, tag, { now }).map((z) => [z.uhrzeit, z.start.toISOString()])
|
||||
assert.deepEqual(ist, erwartet, `${tag} ${von}-${bis}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- freieTage
|
||||
|
||||
test('freieTage: Tage mit mindestens einer freien Zeit, ohne Feiertage und volle Tage', () => {
|
||||
const e = einstellung({
|
||||
ressourcen: [ressource(R1, wochenplan(TAG, [1, 2, 3, 4, 5, 6]))],
|
||||
abwesenheiten: [{ id: 'a_dddddddd', ressourceId: null, von: '2026-10-19', bis: '2026-10-20', titel: 'Fortbildung' }],
|
||||
})
|
||||
const belegung = [belegt(R1, um('2026-10-14', '08:00'), um('2026-10-14', '18:00'))]
|
||||
const tage = freieTage({ einstellung: e, leistungId: S, ressourceId: 'egal', monat: '2026-10', belegung, now: JETZT })
|
||||
assert.deepEqual(tage, [
|
||||
'2026-10-01', '2026-10-02', /* 03. Feiertag */ '2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10',
|
||||
'2026-10-12', '2026-10-13', /* 14. voll */ '2026-10-15', '2026-10-16', '2026-10-17',
|
||||
/* 19./20. abwesend */ '2026-10-21', '2026-10-22', '2026-10-23', '2026-10-24',
|
||||
'2026-10-26', '2026-10-27', '2026-10-28', '2026-10-29', '2026-10-30', '2026-10-31',
|
||||
])
|
||||
})
|
||||
|
||||
test('freieTage: vergangene Tage, Vorlauf und Horizont begrenzen den Monat', () => {
|
||||
const e = einstellung({ horizontTage: 10, vorlaufMin: 120 })
|
||||
const jetzt = new Date('2026-10-05T14:00:00Z') // Mo 16:00 Berlin: 16:00 + 2 h = 18:00, heute nichts mehr
|
||||
const tage = freieTage({ einstellung: e, leistungId: S, ressourceId: 'egal', monat: '2026-10', belegung: [], now: jetzt })
|
||||
assert.equal(tage[0], '2026-10-06')
|
||||
assert.equal(tage.at(-1), '2026-10-15')
|
||||
const ohne = freieTage({ einstellung: e, leistungId: S, ressourceId: 'egal', monat: '2026-10', belegung: [], now: jetzt, ohneRegeln: true })
|
||||
assert.equal(ohne[0], '2026-10-05', 'ohneRegeln: heute 16:00 + 60 min passt noch bis 17:00')
|
||||
assert.equal(ohne.at(-1), '2026-10-31')
|
||||
})
|
||||
|
||||
test('freieTage: jeder Tag stimmt mit freieZeiten ueberein, ungueltiger Monat ergibt nichts', () => {
|
||||
const e = einstellung({
|
||||
ressourcen: [ressource(R1, wochenplan([['09:00', '12:00']], [1, 3, 5])), ressource(R2, wochenplan([['10:00', '11:00']], [2]))],
|
||||
dauer: 60,
|
||||
})
|
||||
const belegung = [belegt(R1, um('2026-11-04', '09:00'), um('2026-11-04', '12:00'))]
|
||||
const basis = { einstellung: e, leistungId: S, ressourceId: 'egal', belegung, now: JETZT }
|
||||
const tage = freieTage({ ...basis, monat: '2026-11' })
|
||||
assert.ok(tage.length > 5)
|
||||
for (let i = 1; i <= 30; i += 1) {
|
||||
const tag = `2026-11-${String(i).padStart(2, '0')}`
|
||||
assert.equal(tage.includes(tag), freieZeiten({ ...basis, tag }).length > 0, tag)
|
||||
}
|
||||
for (const monat of ['2026-13', '2026-00', '2026-1', 'Oktober', '', undefined, null]) assert.deepEqual(freieTage({ ...basis, monat }), [], String(monat))
|
||||
assert.deepEqual(freieTage({ ...basis, leistungId: 's_unbekannt', monat: '2026-11' }), [])
|
||||
assert.deepEqual(freieTage({ ...basis, ressourceId: R3, monat: '2026-11' }), [])
|
||||
})
|
||||
|
||||
test('freieTage: Februar und Schaltjahr haben die richtige Tageszahl', () => {
|
||||
const e = einstellung({ horizontTage: 365 * 3 })
|
||||
const basis = { einstellung: e, leistungId: S, ressourceId: 'egal', belegung: [], now: JETZT }
|
||||
assert.equal(freieTage({ ...basis, monat: '2027-02' }).at(-1), '2027-02-28')
|
||||
assert.equal(freieTage({ ...basis, monat: '2028-02' }).at(-1), '2028-02-29')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- waehleRessource
|
||||
|
||||
test('waehleRessource: die Ressource mit den wenigsten Buchungen an dem Tag', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, {}), ressource(R2, {}), ressource(R3, {})] })
|
||||
const tagesBuchungen = [
|
||||
{ ressourceId: R1, status: 'bestaetigt' }, { ressourceId: R1, status: 'angefragt' },
|
||||
{ ressourceId: R2, status: 'bestaetigt' },
|
||||
{ ressourceId: R3, status: 'bestaetigt' }, { ressourceId: R3, status: 'bestaetigt' }, { ressourceId: R3, status: 'bestaetigt' },
|
||||
]
|
||||
assert.equal(waehleRessource([R1, R2, R3], tagesBuchungen, e), R2)
|
||||
assert.equal(waehleRessource([R1, R3], tagesBuchungen, e), R1)
|
||||
assert.equal(waehleRessource([R3], tagesBuchungen, e), R3)
|
||||
})
|
||||
|
||||
test('waehleRessource: bei Gleichstand gilt die Reihenfolge der Liste, abgesagte Termine und Sperren zaehlen nicht', () => {
|
||||
const e = einstellung({ ressourcen: [ressource(R1, {}), ressource(R2, {}), ressource(R3, {})] })
|
||||
assert.equal(waehleRessource([R3, R2, R1], [], e), R1)
|
||||
assert.equal(waehleRessource([R2, R3], [], e), R2)
|
||||
const tagesBuchungen = [
|
||||
{ ressourceId: R1, status: 'abgesagt' }, { ressourceId: R1, status: 'verfallen' }, { ressourceId: R1, status: 'abgelehnt' },
|
||||
{ ressourceId: R1, status: 'bestaetigt', quelle: 'sperre' },
|
||||
{ ressourceId: R2, status: 'bestaetigt' },
|
||||
]
|
||||
assert.equal(waehleRessource([R2, R1], tagesBuchungen, e), R1)
|
||||
assert.equal(waehleRessource([R2, R1], [{ ressourceId: R1, status: 'erledigt' }], e), R2, 'erledigte Termine des Tages zaehlen mit')
|
||||
})
|
||||
|
||||
test('waehleRessource: keine Kandidaten ergibt null, fremde Ressourcen werden nicht gezaehlt', () => {
|
||||
const e = einstellung()
|
||||
assert.equal(waehleRessource([], [], e), null)
|
||||
assert.equal(waehleRessource(undefined, [], e), null)
|
||||
assert.equal(waehleRessource([R1], [{ ressourceId: R2, status: 'bestaetigt' }], e), R1)
|
||||
assert.equal(waehleRessource(['r_unbekannt', R1], [], e), R1, 'bekannte Ressourcen der Einstellung zuerst')
|
||||
})
|
||||
Reference in new issue
Block a user