View | Details | Raw Unified | Return to bug 41129
Collapse All | Expand All

(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt (-2 / +2 lines)
Lines 66-72 Link Here
66
    [% Asset.js("js/combobox.js") | $raw %]
66
    [% Asset.js("js/combobox.js") | $raw %]
67
    [% Asset.js("js/additional-filters.js") | $raw %]
67
    [% Asset.js("js/additional-filters.js") | $raw %]
68
    <script>
68
    <script>
69
    var cancel_success = 0;
69
        var cancel_success = 0;
70
        var bookings_table;
70
        var bookings_table;
71
        var timeline;
71
        var timeline;
72
        let biblionumber = "[% biblionumber | uri %]";
72
        let biblionumber = "[% biblionumber | uri %]";
Lines 205-211 Link Here
205
                    timeline = new vis.Timeline(container, bookingsSet, itemsSet, options);
205
                    timeline = new vis.Timeline(container, bookingsSet, itemsSet, options);
206
                },
206
                },
207
                function (jqXHR, textStatus, errorThrown) {
207
                function (jqXHR, textStatus, errorThrown) {
208
                    console.log("Fetch failed");
208
                    console.warn("Timeline data fetch failed:", textStatus, errorThrown);
209
                }
209
                }
210
            );
210
            );
211
211
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue (-44 / +23 lines)
Lines 767-773 onUnmounted(() => { Link Here
767
</script>
767
</script>
768
768
769
<style>
769
<style>
770
/* Global variables for external libraries (flatpickr) and cross-block usage */
770
/* Design System: CSS Custom Properties */
771
:root {
771
:root {
772
    /* Success colors for constraint highlighting */
772
    /* Success colors for constraint highlighting */
773
    --booking-success-hue: 134;
773
    --booking-success-hue: 134;
Lines 776-837 onUnmounted(() => { Link Here
776
    --booking-success-border: hsl(var(--booking-success-hue), 70%, 40%);
776
    --booking-success-border: hsl(var(--booking-success-hue), 70%, 40%);
777
    --booking-success-border-hover: hsl(var(--booking-success-hue), 75%, 30%);
777
    --booking-success-border-hover: hsl(var(--booking-success-hue), 75%, 30%);
778
    --booking-success-text: hsl(var(--booking-success-hue), 80%, 20%);
778
    --booking-success-text: hsl(var(--booking-success-hue), 80%, 20%);
779
    --booking-constraint-marker: hsl(var(--booking-success-hue), 61%, 41%);
779
780
780
    /* Border width used by flatpickr */
781
    /* Border width */
781
    --booking-border-width: 1px;
782
    --booking-border-width: 1px;
782
783
783
    /* Variables used by second style block (booking markers, calendar states) */
784
    /* Booking markers */
784
    --booking-marker-size: max(4px, 0.25em);
785
    --booking-marker-size: max(4px, 0.25em);
785
    --booking-marker-grid-gap: 0.25rem;
786
    --booking-marker-grid-gap: 0.25rem;
786
    --booking-marker-grid-offset: -0.75rem;
787
    --booking-marker-grid-offset: -0.75rem;
787
788
788
    /* Color hues used in second style block */
789
    /* Color hues */
789
    --booking-warning-hue: 45;
790
    --booking-warning-hue: 45;
790
    --booking-danger-hue: 354;
791
    --booking-danger-hue: 354;
791
    --booking-info-hue: 195;
792
    --booking-info-hue: 195;
792
    --booking-neutral-hue: 210;
793
    --booking-neutral-hue: 210;
793
    --booking-holiday-hue: 0;
794
    --booking-holiday-hue: 0;
794
795
795
    /* Colors derived from hues (used in second style block) */
796
    /* Colors derived from hues */
796
    --booking-warning-bg: hsl(var(--booking-warning-hue), 100%, 85%);
797
    --booking-warning-bg: hsl(var(--booking-warning-hue), 100%, 85%);
797
    --booking-neutral-600: hsl(var(--booking-neutral-hue), 10%, 45%);
798
    --booking-holiday-bg: hsl(var(--booking-holiday-hue), 0%, 85%);
799
    --booking-holiday-text: hsl(var(--booking-holiday-hue), 0%, 40%);
800
801
    /* Spacing used in second style block */
802
    --booking-space-xs: 0.125rem;
803
804
    /* Typography used in second style block */
805
    --booking-text-xs: 0.7rem;
806
807
    /* Border radius used in second style block and other components */
808
    --booking-border-radius-sm: 0.25rem;
809
    --booking-border-radius-md: 0.5rem;
810
    --booking-border-radius-full: 50%;
811
}
812
813
/* Design System: CSS Custom Properties (First Style Block Only) */
814
:root {
815
    /* Colors not used in second style block */
816
    --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%);
798
    --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%);
817
    --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%);
799
    --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%);
818
    --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%);
800
    --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%);
819
    --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%);
801
    --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%);
802
    --booking-neutral-600: hsl(var(--booking-neutral-hue), 10%, 45%);
803
    --booking-holiday-bg: hsl(var(--booking-holiday-hue), 0%, 85%);
804
    --booking-holiday-text: hsl(var(--booking-holiday-hue), 0%, 40%);
820
805
821
    /* Spacing Scale (first block only) */
806
    /* Spacing Scale */
807
    --booking-space-xs: 0.125rem;
822
    --booking-space-sm: 0.25rem; /* 4px */
808
    --booking-space-sm: 0.25rem; /* 4px */
823
    --booking-space-md: 0.5rem; /* 8px */
809
    --booking-space-md: 0.5rem; /* 8px */
824
    --booking-space-lg: 1rem; /* 16px */
810
    --booking-space-lg: 1rem; /* 16px */
825
    --booking-space-xl: 1.5rem; /* 24px */
811
    --booking-space-xl: 1.5rem; /* 24px */
826
    --booking-space-2xl: 2rem; /* 32px */
812
    --booking-space-2xl: 2rem; /* 32px */
827
813
828
    /* Typography Scale (first block only) */
814
    /* Typography Scale */
815
    --booking-text-xs: 0.7rem;
829
    --booking-text-sm: 0.8125rem;
816
    --booking-text-sm: 0.8125rem;
830
    --booking-text-base: 1rem;
817
    --booking-text-base: 1rem;
831
    --booking-text-lg: 1.1rem;
818
    --booking-text-lg: 1.1rem;
832
    --booking-text-xl: 1.3rem;
819
    --booking-text-xl: 1.3rem;
833
    --booking-text-2xl: 2rem;
820
    --booking-text-2xl: 2rem;
834
821
822
    /* Border radius */
823
    --booking-border-radius-sm: 0.25rem;
824
    --booking-border-radius-md: 0.5rem;
825
    --booking-border-radius-full: 50%;
826
835
    /* Layout */
827
    /* Layout */
836
    --booking-modal-max-height: calc(100vh - var(--booking-space-2xl));
828
    --booking-modal-max-height: calc(100vh - var(--booking-space-2xl));
837
    --booking-input-min-width: 15rem;
829
    --booking-input-min-width: 15rem;
Lines 939-963 hr { Link Here
939
    margin-left: var(--booking-space-lg);
931
    margin-left: var(--booking-space-lg);
940
}
932
}
941
933
942
/* Legend colors match actual calendar markers exactly */
934
/* === Date Picker & Marker System === */
943
.calendar-legend .booking-marker-dot--booked {
944
    background: var(--booking-warning-bg) !important;
945
}
946
947
.calendar-legend .booking-marker-dot--checked-out {
948
    background: hsl(var(--booking-danger-hue), 60%, 85%) !important;
949
}
950
951
.calendar-legend .booking-marker-dot--lead {
952
    background: hsl(var(--booking-info-hue), 60%, 85%) !important;
953
}
954
935
955
.calendar-legend .booking-marker-dot--trail {
956
    background: var(--booking-warning-bg) !important;
957
}
958
</style>
959
960
<style>
961
.booking-date-picker {
936
.booking-date-picker {
962
    display: flex;
937
    display: flex;
963
    align-items: stretch;
938
    align-items: stretch;
Lines 1053-1058 hr { Link Here
1053
    background: var(--booking-holiday-bg);
1028
    background: var(--booking-holiday-bg);
1054
}
1029
}
1055
1030
1031
.booking-marker-dot--constraint {
1032
    background: var(--booking-constraint-marker);
1033
}
1034
1056
/* Calendar Day States */
1035
/* Calendar Day States */
1057
.booked {
1036
.booked {
1058
    background: var(--booking-warning-bg) !important;
1037
    background: var(--booking-warning-bg) !important;
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue (-37 / +6 lines)
Lines 68-75 Link Here
68
            {{ $__("Closed") }}
68
            {{ $__("Closed") }}
69
            <span
69
            <span
70
                v-if="dateRangeConstraint && hasSelectedDates"
70
                v-if="dateRangeConstraint && hasSelectedDates"
71
                class="booking-marker-dot ml-3"
71
                class="booking-marker-dot booking-marker-dot--constraint ml-3"
72
                style="background-color: #28a745"
73
            ></span>
72
            ></span>
74
            <span v-if="dateRangeConstraint && hasSelectedDates" class="ml-1">
73
            <span v-if="dateRangeConstraint && hasSelectedDates" class="ml-1">
75
                {{ $__("Required end date") }}
74
                {{ $__("Required end date") }}
Lines 167-175 const constraintHelpText = computed((): string => { Link Here
167
              ).format(props.maxBookingPeriod)
166
              ).format(props.maxBookingPeriod)
168
            : $__("Booking period limited to checkout length with renewals"),
167
            : $__("Booking period limited to checkout length with renewals"),
169
        default: props.maxBookingPeriod
168
        default: props.maxBookingPeriod
170
            ? $__("Booking period limited by circulation rules (%s days)").format(
169
            ? $__(
171
                  props.maxBookingPeriod
170
                  "Booking period limited by circulation rules (%s days)"
172
              )
171
              ).format(props.maxBookingPeriod)
173
            : $__("Booking period limited by circulation rules"),
172
            : $__("Booking period limited by circulation rules"),
174
    };
173
    };
175
174
Lines 222-228 const { clear } = useFlatpickr(inputEl as { value: HTMLInputElement | null }, { Link Here
222
221
223
watch(
222
watch(
224
    () => availability.value?.unavailableByDate,
223
    () => availability.value?.unavailableByDate,
225
    (map) => {
224
    map => {
226
        try {
225
        try {
227
            store.setUnavailableByDate(map ?? {});
226
            store.setUnavailableByDate(map ?? {});
228
        } catch (e) {
227
        } catch (e) {
Lines 245-251 const debouncedExtendHolidays = debounce( Link Here
245
244
246
watch(
245
watch(
247
    () => visibleRangeRef.value,
246
    () => visibleRangeRef.value,
248
    (newRange) => {
247
    newRange => {
249
        const libraryId = pickupLibraryId.value;
248
        const libraryId = pickupLibraryId.value;
250
        if (
249
        if (
251
            !libraryId ||
250
            !libraryId ||
Lines 302-336 const clearDateRange = (): void => { Link Here
302
    margin-top: var(--booking-space-lg);
301
    margin-top: var(--booking-space-lg);
303
}
302
}
304
303
305
.booking-marker-dot {
306
    display: inline-block;
307
    width: calc(var(--booking-marker-size) * 3);
308
    height: calc(var(--booking-marker-size) * 3);
309
    border-radius: var(--booking-border-radius-full);
310
    margin-right: var(--booking-space-sm);
311
    border: var(--booking-border-width) solid hsla(0, 0%, 0%, 0.15);
312
}
313
314
.booking-marker-dot--booked {
315
    background-color: var(--booking-warning-bg);
316
}
317
318
.booking-marker-dot--lead {
319
    background-color: hsl(var(--booking-info-hue), 60%, 85%);
320
}
321
322
.booking-marker-dot--trail {
323
    background-color: var(--booking-warning-bg);
324
}
325
326
.booking-marker-dot--checked-out {
327
    background-color: hsl(var(--booking-danger-hue), 60%, 85%);
328
}
329
330
.booking-marker-dot--holiday {
331
    background-color: var(--booking-holiday-bg);
332
}
333
334
.alert {
304
.alert {
335
    padding: calc(var(--booking-space-lg) * 0.75) var(--booking-space-lg);
305
    padding: calc(var(--booking-space-lg) * 0.75) var(--booking-space-lg);
336
    border: var(--booking-border-width) solid transparent;
306
    border: var(--booking-border-width) solid transparent;
Lines 348-352 const clearDateRange = (): void => { Link Here
348
    background-color: hsl(var(--booking-danger-hue), 40%, 90%);
318
    background-color: hsl(var(--booking-danger-hue), 40%, 90%);
349
    border-color: hsl(var(--booking-danger-hue), 40%, 70%);
319
    border-color: hsl(var(--booking-danger-hue), 40%, 70%);
350
}
320
}
351
352
</style>
321
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue (-23 / +4 lines)
Lines 31-37 Link Here
31
</template>
31
</template>
32
32
33
<script setup lang="ts">
33
<script setup lang="ts">
34
import { defineProps, withDefaults } from "vue";
35
import { $__ } from "../../i18n";
34
import { $__ } from "../../i18n";
36
import { getMarkerTypeLabel } from "./lib/ui/marker-labels.mjs";
35
import { getMarkerTypeLabel } from "./lib/ui/marker-labels.mjs";
37
import type { CalendarMarker } from "./types/bookings";
36
import type { CalendarMarker } from "./types/bookings";
Lines 56-64 withDefaults( Link Here
56
.booking-tooltip {
55
.booking-tooltip {
57
    background: hsl(var(--booking-warning-hue), 100%, 95%);
56
    background: hsl(var(--booking-warning-hue), 100%, 95%);
58
    color: hsl(var(--booking-neutral-hue), 20%, 20%);
57
    color: hsl(var(--booking-neutral-hue), 20%, 20%);
59
    border: var(--booking-border-width) solid hsl(var(--booking-neutral-hue), 15%, 75%);
58
    border: var(--booking-border-width) solid
59
        hsl(var(--booking-neutral-hue), 15%, 75%);
60
    border-radius: var(--booking-border-radius-md);
60
    border-radius: var(--booking-border-radius-md);
61
    box-shadow: 0 0.125rem 0.5rem hsla(var(--booking-neutral-hue), 10%, 0%, 0.08);
61
    box-shadow: 0 0.125rem 0.5rem
62
        hsla(var(--booking-neutral-hue), 10%, 0%, 0.08);
62
    padding: calc(var(--booking-space-xs) * 3) calc(var(--booking-space-xs) * 5);
63
    padding: calc(var(--booking-space-xs) * 3) calc(var(--booking-space-xs) * 5);
63
    font-size: var(--booking-text-lg);
64
    font-size: var(--booking-text-lg);
64
    pointer-events: none;
65
    pointer-events: none;
Lines 72-95 withDefaults( Link Here
72
    margin: 0 var(--booking-space-xs) 0 0;
73
    margin: 0 var(--booking-space-xs) 0 0;
73
    vertical-align: middle;
74
    vertical-align: middle;
74
}
75
}
75
76
.booking-marker-dot--booked {
77
    background: var(--booking-warning-bg);
78
}
79
80
.booking-marker-dot--checked-out {
81
    background: hsl(var(--booking-danger-hue), 60%, 85%);
82
}
83
84
.booking-marker-dot--lead {
85
    background: hsl(var(--booking-info-hue), 60%, 85%);
86
}
87
88
.booking-marker-dot--trail {
89
    background: var(--booking-warning-bg);
90
}
91
92
.booking-marker-dot--holiday {
93
    background: var(--booking-holiday-bg);
94
}
95
</style>
76
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs (-4 / +1 lines)
Lines 87-95 export function useAvailability(storeRefs, optionsRef) { Link Here
87
    const disableFnRef = computed(
87
    const disableFnRef = computed(
88
        () => availability.value.disable || (() => false)
88
        () => availability.value.disable || (() => false)
89
    );
89
    );
90
    const unavailableByDateRef = computed(
91
        () => availability.value.unavailableByDate || {}
92
    );
93
90
94
    return { availability, disableFnRef, unavailableByDateRef };
91
    return { availability, disableFnRef };
95
}
92
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js (-3 / +9 lines)
Lines 161-167 export async function fetchPatrons(term, page = 1) { Link Here
161
            if (errorData.error) {
161
            if (errorData.error) {
162
                error.message += ` - ${errorData.error}`;
162
                error.message += ` - ${errorData.error}`;
163
            }
163
            }
164
        } catch (e) {}
164
        } catch (_) {
165
            /* response body may not be JSON */
166
        }
165
167
166
        throw error;
168
        throw error;
167
    }
169
    }
Lines 349-355 export async function createBooking(bookingData) { Link Here
349
            if (errorData.error) {
351
            if (errorData.error) {
350
                errorMessage += ` - ${errorData.error}`;
352
                errorMessage += ` - ${errorData.error}`;
351
            }
353
            }
352
        } catch (e) {}
354
        } catch (_) {
355
            /* response body may not be JSON */
356
        }
353
        /** @type {Error & { status?: number }} */
357
        /** @type {Error & { status?: number }} */
354
        const error = Object.assign(new Error(errorMessage), {
358
        const error = Object.assign(new Error(errorMessage), {
355
            status: response.status,
359
            status: response.status,
Lines 405-411 export async function updateBooking(bookingId, bookingData) { Link Here
405
            if (errorData.error) {
409
            if (errorData.error) {
406
                errorMessage += ` - ${errorData.error}`;
410
                errorMessage += ` - ${errorData.error}`;
407
            }
411
            }
408
        } catch (e) {}
412
        } catch (_) {
413
            /* response body may not be JSON */
414
        }
409
        /** @type {Error & { status?: number }} */
415
        /** @type {Error & { status?: number }} */
410
        const error = Object.assign(new Error(errorMessage), {
416
        const error = Object.assign(new Error(errorMessage), {
411
            status: response.status,
417
            status: response.status,
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/events.mjs (-42 / +42 lines)
Lines 413-460 function ensureFeedbackBar(fp) { Link Here
413
    return /** @type {HTMLDivElement} */ (bar);
413
    return /** @type {HTMLDivElement} */ (bar);
414
}
414
}
415
415
416
/** @type {number|null} */
417
let _feedbackHideTimer = null;
418
419
/**
420
 * Update the feedback bar inside the flatpickr calendar container.
421
 * Hides are deferred so that rapid mouseout→mouseover between adjacent
422
 * days doesn't trigger a visible flicker.
423
 *
424
 * @param {HTMLDivElement} bar
425
 * @param {{ message: string, variant: string } | null} feedback
426
 */
427
function updateFeedbackBar(bar, feedback) {
428
    if (!feedback) {
429
        if (_feedbackHideTimer == null) {
430
            _feedbackHideTimer = setTimeout(() => {
431
                _feedbackHideTimer = null;
432
                bar.classList.remove(
433
                    "booking-hover-feedback--visible",
434
                    "booking-hover-feedback--info",
435
                    "booking-hover-feedback--warning",
436
                    "booking-hover-feedback--danger"
437
                );
438
            }, 16);
439
        }
440
        return;
441
    }
442
    if (_feedbackHideTimer != null) {
443
        clearTimeout(_feedbackHideTimer);
444
        _feedbackHideTimer = null;
445
    }
446
    bar.textContent = feedback.message;
447
    bar.classList.remove(
448
        "booking-hover-feedback--info",
449
        "booking-hover-feedback--warning",
450
        "booking-hover-feedback--danger"
451
    );
452
    bar.classList.add(
453
        "booking-hover-feedback--visible",
454
        `booking-hover-feedback--${feedback.variant}`
455
    );
456
}
457
458
/**
416
/**
459
 * Create Flatpickr `onDayCreate` handler.
417
 * Create Flatpickr `onDayCreate` handler.
460
 *
418
 *
Lines 478-483 export function createOnDayCreate( Link Here
478
    tooltipX,
436
    tooltipX,
479
    tooltipY
437
    tooltipY
480
) {
438
) {
439
    /** @type {number|null} */
440
    let _feedbackHideTimer = null;
441
442
    /**
443
     * Update the feedback bar inside the flatpickr calendar container.
444
     * Hides are deferred so that rapid mouseout→mouseover between adjacent
445
     * days doesn't trigger a visible flicker.
446
     *
447
     * @param {HTMLDivElement} bar
448
     * @param {{ message: string, variant: string } | null} feedback
449
     */
450
    function updateFeedbackBar(bar, feedback) {
451
        if (!feedback) {
452
            if (_feedbackHideTimer == null) {
453
                _feedbackHideTimer = setTimeout(() => {
454
                    _feedbackHideTimer = null;
455
                    bar.classList.remove(
456
                        "booking-hover-feedback--visible",
457
                        "booking-hover-feedback--info",
458
                        "booking-hover-feedback--warning",
459
                        "booking-hover-feedback--danger"
460
                    );
461
                }, 16);
462
            }
463
            return;
464
        }
465
        if (_feedbackHideTimer != null) {
466
            clearTimeout(_feedbackHideTimer);
467
            _feedbackHideTimer = null;
468
        }
469
        bar.textContent = feedback.message;
470
        bar.classList.remove(
471
            "booking-hover-feedback--info",
472
            "booking-hover-feedback--warning",
473
            "booking-hover-feedback--danger"
474
        );
475
        bar.classList.add(
476
            "booking-hover-feedback--visible",
477
            `booking-hover-feedback--${feedback.variant}`
478
        );
479
    }
480
481
    return function (
481
    return function (
482
        ...[
482
        ...[
483
            ,
483
            ,
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/highlighting.mjs (-16 / +3 lines)
Lines 122-140 export function fixDateAvailability( Link Here
122
    }
122
    }
123
}
123
}
124
124
125
/**
126
 * Fix incorrect target-end unavailability via a CSS-based override.
127
 * Wrapper for backward compatibility.
128
 *
129
 * @param {import('flatpickr/dist/types/instance').Instance} _instance
130
 * @param {NodeListOf<Element>|Element[]} dayElements
131
 * @param {Date} targetEndDate
132
 * @returns {void}
133
 */
134
function fixTargetEndDateAvailability(_instance, dayElements, targetEndDate) {
135
    fixDateAvailability(dayElements, targetEndDate, "target end date");
136
}
137
138
/**
125
/**
139
 * Apply constraint highlighting to the Flatpickr calendar.
126
 * Apply constraint highlighting to the Flatpickr calendar.
140
 *
127
 *
Lines 259-268 export function applyCalendarHighlighting(instance, highlightingData) { Link Here
259
246
260
        if (highlightingData.constraintMode === CONSTRAINT_MODE_END_DATE_ONLY) {
247
        if (highlightingData.constraintMode === CONSTRAINT_MODE_END_DATE_ONLY) {
261
            applyClickPrevention(instance);
248
            applyClickPrevention(instance);
262
            fixTargetEndDateAvailability(
249
            fixDateAvailability(
263
                instance,
264
                dayElements,
250
                dayElements,
265
                highlightingData.targetEndDate
251
                highlightingData.targetEndDate,
252
                "target end date"
266
            );
253
            );
267
254
268
            const targetEndElem = Array.from(dayElements).find(
255
            const targetEndElem = Array.from(dayElements).find(
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs (-166 lines)
Lines 6-12 Link Here
6
 */
6
 */
7
7
8
import { BookingDate, startOfDayTs, endOfDayTs } from "../BookingDate.mjs";
8
import { BookingDate, startOfDayTs, endOfDayTs } from "../BookingDate.mjs";
9
import { MAX_SEARCH_DAYS } from "../constants.mjs";
10
9
11
/**
10
/**
12
 * Event types for the sweep line algorithm
11
 * Event types for the sweep line algorithm
Lines 161-329 export class SweepLineProcessor { Link Here
161
160
162
        return unavailableByDate;
161
        return unavailableByDate;
163
    }
162
    }
164
165
    /**
166
     * Process intervals and return aggregated statistics
167
     * @param {Array} intervals
168
     * @param {Date|import("dayjs").Dayjs} viewStart
169
     * @param {Date|import("dayjs").Dayjs} viewEnd
170
     * @returns {Object} Statistics about the date range
171
     */
172
    getDateRangeStatistics(intervals, viewStart, viewEnd) {
173
        const stats = {
174
            totalDays: 0,
175
            daysWithBookings: 0,
176
            daysWithCheckouts: 0,
177
            fullyBookedDays: 0,
178
            peakBookingCount: 0,
179
            peakDate: null,
180
            itemUtilization: new Map(),
181
        };
182
183
        const startDate = BookingDate.from(viewStart).toDayjs();
184
        const endDate = BookingDate.from(viewEnd, { preserveTime: true }).toDayjs().endOf("day");
185
186
        stats.totalDays = endDate.diff(startDate, "day") + 1;
187
188
        for (
189
            let date = startDate;
190
            date.isSameOrBefore(endDate, "day");
191
            date = date.add(1, "day")
192
        ) {
193
            const dayStart = date.valueOf();
194
            const dayEnd = date.endOf("day").valueOf();
195
196
            let bookingCount = 0;
197
            let checkoutCount = 0;
198
            const itemsInUse = new Set();
199
200
            intervals.forEach(interval => {
201
                if (interval.start <= dayEnd && interval.end >= dayStart) {
202
                    if (interval.type === "booking") {
203
                        bookingCount++;
204
                        itemsInUse.add(interval.itemId);
205
                    } else if (interval.type === "checkout") {
206
                        checkoutCount++;
207
                        itemsInUse.add(interval.itemId);
208
                    }
209
                }
210
            });
211
212
            if (bookingCount > 0) stats.daysWithBookings++;
213
            if (checkoutCount > 0) stats.daysWithCheckouts++;
214
215
            const totalCount = bookingCount + checkoutCount;
216
            if (totalCount > stats.peakBookingCount) {
217
                stats.peakBookingCount = totalCount;
218
                stats.peakDate = date.format("YYYY-MM-DD");
219
            }
220
221
            itemsInUse.forEach(itemId => {
222
                if (!stats.itemUtilization.has(itemId)) {
223
                    stats.itemUtilization.set(itemId, 0);
224
                }
225
                stats.itemUtilization.set(
226
                    itemId,
227
                    stats.itemUtilization.get(itemId) + 1
228
                );
229
            });
230
        }
231
232
        return stats;
233
    }
234
235
    /**
236
     * Find the next available date for a specific item
237
     * @param {Array} intervals
238
     * @param {string} itemId
239
     * @param {Date|import('dayjs').Dayjs} startDate
240
     * @param {number} maxDaysToSearch
241
     * @returns {Date|null}
242
     */
243
    findNextAvailableDate(
244
        intervals,
245
        itemId,
246
        startDate,
247
        maxDaysToSearch = MAX_SEARCH_DAYS
248
    ) {
249
        const start = BookingDate.from(startDate).toDayjs();
250
        const itemIntervals = intervals.filter(
251
            interval => interval.itemId === itemId
252
        );
253
254
        itemIntervals.sort((a, b) => a.start - b.start);
255
256
        for (let i = 0; i < maxDaysToSearch; i++) {
257
            const checkDate = start.add(i, "day");
258
            const dateStart = checkDate.valueOf();
259
            const dateEnd = checkDate.endOf("day").valueOf();
260
261
            const isAvailable = !itemIntervals.some(
262
                interval =>
263
                    interval.start <= dateEnd && interval.end >= dateStart
264
            );
265
266
            if (isAvailable) {
267
                return checkDate.toDate();
268
            }
269
        }
270
271
        return null;
272
    }
273
274
    /**
275
     * Find gaps (available periods) for an item
276
     * @param {Array} intervals
277
     * @param {string} itemId
278
     * @param {Date|import('dayjs').Dayjs} viewStart
279
     * @param {Date|import('dayjs').Dayjs} viewEnd
280
     * @param {number} minGapDays - Minimum gap size to report
281
     * @returns {Array<{start: Date, end: Date, days: number}>}
282
     */
283
    findAvailableGaps(intervals, itemId, viewStart, viewEnd, minGapDays = 1) {
284
        const gaps = [];
285
        const itemIntervals = intervals
286
            .filter(interval => interval.itemId === itemId)
287
            .sort((a, b) => a.start - b.start);
288
289
        const rangeStart = BookingDate.from(viewStart).valueOf();
290
        const rangeEnd = BookingDate.from(viewEnd, { preserveTime: true }).toDayjs().endOf("day").valueOf();
291
292
        let lastEnd = rangeStart;
293
294
        itemIntervals.forEach(interval => {
295
            if (interval.end < rangeStart || interval.start > rangeEnd) {
296
                return;
297
            }
298
299
            const gapStart = Math.max(lastEnd, rangeStart);
300
            const gapEnd = Math.min(interval.start, rangeEnd);
301
302
            if (gapEnd > gapStart) {
303
                const gapDays = BookingDate.from(gapEnd).diff(BookingDate.from(gapStart), "day");
304
                if (gapDays >= minGapDays) {
305
                    gaps.push({
306
                        start: new Date(gapStart),
307
                        end: new Date(gapEnd - 1), // End of previous day
308
                        days: gapDays,
309
                    });
310
                }
311
            }
312
313
            lastEnd = Math.max(lastEnd, interval.end + 1); // Start of next day
314
        });
315
316
        if (lastEnd < rangeEnd) {
317
            const gapDays = BookingDate.from(rangeEnd).diff(BookingDate.from(lastEnd), "day");
318
            if (gapDays >= minGapDays) {
319
                gaps.push({
320
                    start: new Date(lastEnd),
321
                    end: new Date(rangeEnd),
322
                    days: gapDays,
323
                });
324
            }
325
        }
326
327
        return gaps;
328
    }
329
}
163
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs (-4 lines)
Lines 7-16 Link Here
7
export const CONSTRAINT_MODE_END_DATE_ONLY = "end_date_only";
7
export const CONSTRAINT_MODE_END_DATE_ONLY = "end_date_only";
8
export const CONSTRAINT_MODE_NORMAL = "normal";
8
export const CONSTRAINT_MODE_NORMAL = "normal";
9
9
10
// Selection semantics (logging, diagnostics)
11
export const SELECTION_ANY_AVAILABLE = "ANY_AVAILABLE";
12
export const SELECTION_SPECIFIC_ITEM = "SPECIFIC_ITEM";
13
14
// UI class names (used across calendar/adapters/composables)
10
// UI class names (used across calendar/adapters/composables)
15
export const CLASS_BOOKING_CONSTRAINED_RANGE_MARKER =
11
export const CLASS_BOOKING_CONSTRAINED_RANGE_MARKER =
16
    "booking-constrained-range-marker";
12
    "booking-constrained-range-marker";
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/functions.mjs (-20 lines)
Lines 18-40 export function debounce(fn, delay) { Link Here
18
        timeout = setTimeout(() => fn.apply(this, args), delay);
18
        timeout = setTimeout(() => fn.apply(this, args), delay);
19
    };
19
    };
20
}
20
}
21
22
/**
23
 * Creates a throttled version of a function that only invokes
24
 * at most once per `limit` milliseconds.
25
 *
26
 * @template {(...args: any[]) => any} T
27
 * @param {T} fn - The function to throttle
28
 * @param {number} limit - Minimum time between invocations in milliseconds
29
 * @returns {(...args: Parameters<T>) => void}
30
 */
31
export function throttle(fn, limit) {
32
    let inThrottle;
33
    return function (...args) {
34
        if (!inThrottle) {
35
            fn.apply(this, args);
36
            inThrottle = true;
37
            setTimeout(() => (inThrottle = false), limit);
38
        }
39
    };
40
}
(-)a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts (+198 lines)
Lines 892-895 describe("Booking Modal Basic Tests", () => { Link Here
892
            "✓ CONFIRMED: Error handling and recovery workflow working correctly"
892
            "✓ CONFIRMED: Error handling and recovery workflow working correctly"
893
        );
893
        );
894
    });
894
    });
895
896
    it("should reset modal state after canceling", () => {
897
        cy.visit(
898
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
899
        );
900
901
        cy.get("booking-modal-island .modal").should("exist");
902
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
903
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
904
            "be.visible"
905
        );
906
907
        // Fill some fields
908
        cy.vueSelect(
909
            "booking_patron",
910
            testData.patron.cardnumber,
911
            `${testData.patron.surname} ${testData.patron.firstname}`
912
        );
913
        cy.vueSelectShouldBeEnabled("pickup_library_id");
914
        cy.vueSelectByIndex("pickup_library_id", 0);
915
916
        // Close modal and wait for Bootstrap transition to fully complete
917
        cy.get("booking-modal-island .modal .btn-close").first().click();
918
        cy.get("booking-modal-island .modal").should("not.be.visible");
919
        cy.get("body").should("not.have.class", "modal-open");
920
921
        // Reopen and verify state is reset
922
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
923
        cy.get("booking-modal-island .modal.show", { timeout: 10000 }).should(
924
            "be.visible"
925
        );
926
927
        cy.vueSelectShouldBeEnabled("booking_patron");
928
        cy.vueSelectShouldBeDisabled("pickup_library_id");
929
        cy.vueSelectShouldBeDisabled("booking_itemtype");
930
        cy.vueSelectShouldBeDisabled("booking_item_id");
931
        cy.get("#booking_period").should("be.disabled");
932
        cy.get('button[form="form-booking"][type="submit"]').should(
933
            "be.disabled"
934
        );
935
    });
936
937
    it("should show capacity warning for zero-day circulation rules", () => {
938
        cy.intercept(
939
            "GET",
940
            `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*`
941
        ).as("getPickupLocations");
942
        cy.intercept("GET", "/api/v1/circulation_rules*", {
943
            body: [
944
                {
945
                    library_id: testData.libraries[0].library_id,
946
                    item_type_id: "BK",
947
                    patron_category_id: testData.patron.category_id,
948
                    issuelength: 0,
949
                    renewalsallowed: 0,
950
                    renewalperiod: 0,
951
                    bookings_lead_period: 0,
952
                    bookings_trail_period: 0,
953
                    calculated_period_days: 0,
954
                },
955
            ],
956
        }).as("getCirculationRules");
957
958
        cy.visit(
959
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
960
        );
961
962
        cy.get("booking-modal-island .modal").should("exist");
963
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
964
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
965
            "be.visible"
966
        );
967
968
        cy.vueSelect(
969
            "booking_patron",
970
            testData.patron.cardnumber,
971
            `${testData.patron.surname} ${testData.patron.firstname}`
972
        );
973
        cy.wait("@getPickupLocations");
974
975
        cy.vueSelectShouldBeEnabled("pickup_library_id");
976
        cy.vueSelectByIndex("pickup_library_id", 0);
977
978
        cy.vueSelectShouldBeEnabled("booking_item_id");
979
        cy.vueSelectByIndex("booking_item_id", 1);
980
        cy.wait("@getCirculationRules");
981
982
        cy.get("booking-modal-island .modal .alert-warning")
983
            .scrollIntoView()
984
            .should("be.visible")
985
            .and("contain", "Bookings are not permitted");
986
        cy.get("#booking_period").should("be.disabled");
987
        cy.get('button[form="form-booking"][type="submit"]').should(
988
            "be.disabled"
989
        );
990
    });
991
992
    it("should show error on 409 conflict response", () => {
993
        cy.intercept(
994
            "GET",
995
            `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*`
996
        ).as("getPickupLocations");
997
        cy.intercept("GET", "/api/v1/circulation_rules*").as(
998
            "getCirculationRules"
999
        );
1000
        cy.intercept("POST", "/api/v1/bookings", {
1001
            statusCode: 409,
1002
            body: { error: "Booking conflict detected" },
1003
        }).as("conflictBooking");
1004
1005
        cy.visit(
1006
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
1007
        );
1008
1009
        cy.get("booking-modal-island .modal").should("exist");
1010
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
1011
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
1012
            "be.visible"
1013
        );
1014
1015
        cy.vueSelect(
1016
            "booking_patron",
1017
            testData.patron.cardnumber,
1018
            `${testData.patron.surname} ${testData.patron.firstname}`
1019
        );
1020
        cy.wait("@getPickupLocations");
1021
1022
        cy.vueSelectShouldBeEnabled("pickup_library_id");
1023
        cy.vueSelectByIndex("pickup_library_id", 0);
1024
1025
        cy.vueSelectShouldBeEnabled("booking_item_id");
1026
        cy.vueSelectByIndex("booking_item_id", 1);
1027
        cy.wait("@getCirculationRules");
1028
1029
        cy.get("#booking_period").should("not.be.disabled");
1030
        const startDate = dayjs().add(5, "day");
1031
        const endDate = dayjs().add(10, "day");
1032
        cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate);
1033
1034
        cy.get('button[form="form-booking"][type="submit"]')
1035
            .should("not.be.disabled")
1036
            .click();
1037
        cy.wait("@conflictBooking");
1038
1039
        cy.get("booking-modal-island .modal .alert-danger").should("exist");
1040
        cy.get("booking-modal-island .modal").should("be.visible");
1041
    });
1042
1043
    it("should show error on 500 server error response", () => {
1044
        cy.intercept(
1045
            "GET",
1046
            `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*`
1047
        ).as("getPickupLocations");
1048
        cy.intercept("GET", "/api/v1/circulation_rules*").as(
1049
            "getCirculationRules"
1050
        );
1051
        cy.intercept("POST", "/api/v1/bookings", {
1052
            statusCode: 500,
1053
            body: { error: "Internal server error" },
1054
        }).as("serverError");
1055
1056
        cy.visit(
1057
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
1058
        );
1059
1060
        cy.get("booking-modal-island .modal").should("exist");
1061
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
1062
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
1063
            "be.visible"
1064
        );
1065
1066
        cy.vueSelect(
1067
            "booking_patron",
1068
            testData.patron.cardnumber,
1069
            `${testData.patron.surname} ${testData.patron.firstname}`
1070
        );
1071
        cy.wait("@getPickupLocations");
1072
1073
        cy.vueSelectShouldBeEnabled("pickup_library_id");
1074
        cy.vueSelectByIndex("pickup_library_id", 0);
1075
1076
        cy.vueSelectShouldBeEnabled("booking_item_id");
1077
        cy.vueSelectByIndex("booking_item_id", 1);
1078
        cy.wait("@getCirculationRules");
1079
1080
        cy.get("#booking_period").should("not.be.disabled");
1081
        const startDate = dayjs().add(5, "day");
1082
        const endDate = dayjs().add(10, "day");
1083
        cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate);
1084
1085
        cy.get('button[form="form-booking"][type="submit"]')
1086
            .should("not.be.disabled")
1087
            .click();
1088
        cy.wait("@serverError");
1089
1090
        cy.get("booking-modal-island .modal .alert-danger").should("exist");
1091
        cy.get("booking-modal-island .modal").should("be.visible");
1092
    });
895
});
1093
});
(-)a/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts (-139 / +183 lines)
Lines 27-39 describe("Booking Modal Date Picker Tests", () => { Link Here
27
                testData = objects;
27
                testData = objects;
28
28
29
                // Update items to be bookable with predictable itemtypes
29
                // Update items to be bookable with predictable itemtypes
30
                return cy.task("query", {
30
                return cy
31
                    sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?",
31
                    .task("query", {
32
                    values: [objects.items[0].item_id],
32
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?",
33
                }).then(() => cy.task("query", {
33
                        values: [objects.items[0].item_id],
34
                    sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?",
34
                    })
35
                    values: [objects.items[1].item_id],
35
                    .then(() =>
36
                }));
36
                        cy.task("query", {
37
                            sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?",
38
                            values: [objects.items[1].item_id],
39
                        })
40
                    );
37
            })
41
            })
38
            .then(() => {
42
            .then(() => {
39
                // Create a test patron using upstream pattern
43
                // Create a test patron using upstream pattern
Lines 98-104 describe("Booking Modal Date Picker Tests", () => { Link Here
98
102
99
        // Open the modal
103
        // Open the modal
100
        cy.get("booking-modal-island .modal").should("exist");
104
        cy.get("booking-modal-island .modal").should("exist");
101
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
105
        cy.get("[data-booking-modal]")
106
            .first()
107
            .then($btn => $btn[0].click());
102
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
108
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
103
            "be.visible"
109
            "be.visible"
104
        );
110
        );
Lines 195-201 describe("Booking Modal Date Picker Tests", () => { Link Here
195
201
196
        // Select the specific item that has the existing bookings (by barcode, not index)
202
        // Select the specific item that has the existing bookings (by barcode, not index)
197
        cy.vueSelectShouldBeEnabled("booking_item_id");
203
        cy.vueSelectShouldBeEnabled("booking_item_id");
198
        cy.vueSelect("booking_item_id", testData.items[0].external_id, testData.items[0].external_id);
204
        cy.vueSelect(
205
            "booking_item_id",
206
            testData.items[0].external_id,
207
            testData.items[0].external_id
208
        );
199
        cy.wait("@getCirculationRules");
209
        cy.wait("@getCirculationRules");
200
210
201
        // Verify date picker is now enabled
211
        // Verify date picker is now enabled
Lines 380-388 describe("Booking Modal Date Picker Tests", () => { Link Here
380
390
381
        // Should either succeed (modal closes) or show specific validation error
391
        // Should either succeed (modal closes) or show specific validation error
382
        cy.get("body").then($body => {
392
        cy.get("body").then($body => {
383
            if (
393
            if ($body.find("booking-modal-island .modal:visible").length > 0) {
384
                $body.find("booking-modal-island .modal:visible").length > 0
385
            ) {
386
                // If modal is still visible, check for validation messages
394
                // If modal is still visible, check for validation messages
387
                cy.log(
395
                cy.log(
388
                    "Modal still visible - checking for validation feedback"
396
                    "Modal still visible - checking for validation feedback"
Lines 458-467 describe("Booking Modal Date Picker Tests", () => { Link Here
458
            dateTestCirculationRules.issuelength +
466
            dateTestCirculationRules.issuelength +
459
            dateTestCirculationRules.renewalsallowed *
467
            dateTestCirculationRules.renewalsallowed *
460
                dateTestCirculationRules.renewalperiod; // 10 + 3*5 = 25
468
                dateTestCirculationRules.renewalperiod; // 10 + 3*5 = 25
461
        const calculatedMaxDate = clearZoneStart.add(
469
        const calculatedMaxDate = clearZoneStart.add(maxPeriod - 1, "day"); // Day 50 + 24 = Day 74
462
            maxPeriod - 1,
463
            "day"
464
        ); // Day 50 + 24 = Day 74
465
470
466
        const beyondMaxDate = calculatedMaxDate.add(1, "day"); // Day 75
471
        const beyondMaxDate = calculatedMaxDate.add(1, "day"); // Day 75
467
472
Lines 554-579 describe("Booking Modal Date Picker Tests", () => { Link Here
554
            });
559
            });
555
        });
560
        });
556
561
557
        // For dates in the currently visible month, also verify the DOM class
562
        // For dates in the currently visible month, also verify the DOM class.
558
        expectedBoldDates
563
        // Query calendarContainer directly to avoid navigation races (the
559
            .filter(d => d.month() === clearZoneStart.month())
564
        // booking modal's onMonthChange handler can jump the calendar away).
560
            .forEach(boldDate => {
565
        cy.get("@dateTestFlatpickr").should($el => {
561
                cy.get("@dateTestFlatpickr")
566
            const fp = $el[0]._flatpickr;
562
                    .getFlatpickrDate(boldDate.toDate())
567
            // Ensure calendar shows the start date's month
563
                    .should("have.class", "booking-loan-boundary");
568
            if (
564
                cy.log(
569
                fp.currentMonth !== clearZoneStart.month() ||
565
                    `✓ Day ${boldDate.format("YYYY-MM-DD")}: Has 'booking-loan-boundary' class (bold)`
570
                fp.currentYear !== clearZoneStart.year()
566
                );
571
            ) {
567
            });
572
                fp.jumpToDate(clearZoneStart.toDate());
573
                throw new Error("Jumped to target month, retrying assertion");
574
            }
575
576
            expectedBoldDates
577
                .filter(d => d.month() === clearZoneStart.month())
578
                .forEach(boldDate => {
579
                    const label = `${boldDate.format("MMMM D, YYYY")}`;
580
                    const el = fp.calendarContainer.querySelector(
581
                        `.flatpickr-day[aria-label="${label}"]`
582
                    );
583
                    expect(
584
                        el,
585
                        `Day ${boldDate.format("YYYY-MM-DD")} should exist`
586
                    ).to.not.be.null;
587
                    expect(
588
                        el.classList.contains("booking-loan-boundary"),
589
                        `Day ${boldDate.format("YYYY-MM-DD")} should have booking-loan-boundary class`
590
                    ).to.be.true;
591
                });
568
592
569
        // Verify that only expected dates are bold in the current view
593
            // Verify no unexpected bold dates in the current view
570
        cy.get(".flatpickr-day.booking-loan-boundary").each($el => {
594
            const boldElements = fp.calendarContainer.querySelectorAll(
571
            const ariaLabel = $el.attr("aria-label");
595
                ".flatpickr-day.booking-loan-boundary"
572
            const date = dayjs(ariaLabel, "MMMM D, YYYY");
573
            const isExpected = expectedBoldDates.some(expected =>
574
                date.isSame(expected, "day")
575
            );
596
            );
576
            expect(isExpected, `Unexpected bold date: ${ariaLabel}`).to.be.true;
597
            boldElements.forEach(boldEl => {
598
                const ariaLabel = boldEl.getAttribute("aria-label");
599
                const date = dayjs(ariaLabel, "MMMM D, YYYY");
600
                const isExpected = expectedBoldDates.some(expected =>
601
                    date.isSame(expected, "day")
602
                );
603
                expect(isExpected, `Unexpected bold date: ${ariaLabel}`).to.be
604
                    .true;
605
            });
577
        });
606
        });
578
607
579
        cy.log(
608
        cy.log(
Lines 704-711 describe("Booking Modal Date Picker Tests", () => { Link Here
704
        cy.get("@fp").openFlatpickr();
733
        cy.get("@fp").openFlatpickr();
705
734
706
        // Helpers that use native events to avoid detached DOM errors from Vue re-renders
735
        // Helpers that use native events to avoid detached DOM errors from Vue re-renders
707
        const monthNames = ["January", "February", "March", "April", "May", "June",
736
        const monthNames = [
708
            "July", "August", "September", "October", "November", "December"];
737
            "January",
738
            "February",
739
            "March",
740
            "April",
741
            "May",
742
            "June",
743
            "July",
744
            "August",
745
            "September",
746
            "October",
747
            "November",
748
            "December",
749
        ];
709
        const getDateSelector = (isoDate: string) => {
750
        const getDateSelector = (isoDate: string) => {
710
            const d = dayjs(isoDate);
751
            const d = dayjs(isoDate);
711
            return `.flatpickr-day[aria-label="${monthNames[d.month()]} ${d.date()}, ${d.year()}"]`;
752
            return `.flatpickr-day[aria-label="${monthNames[d.month()]} ${d.date()}, ${d.year()}"]`;
Lines 714-720 describe("Booking Modal Date Picker Tests", () => { Link Here
714
            cy.get(getDateSelector(isoDate))
755
            cy.get(getDateSelector(isoDate))
715
                .should("be.visible")
756
                .should("be.visible")
716
                .then($el => {
757
                .then($el => {
717
                    $el[0].dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
758
                    $el[0].dispatchEvent(
759
                        new MouseEvent("mouseover", { bubbles: true })
760
                    );
718
                });
761
                });
719
        };
762
        };
720
        const clickDateByISO = (isoDate: string) => {
763
        const clickDateByISO = (isoDate: string) => {
Lines 780-789 describe("Booking Modal Date Picker Tests", () => { Link Here
780
823
781
        // Hover June 29 - Lead period (June 27-28), June 27 is in blocker booking
824
        // Hover June 29 - Lead period (June 27-28), June 27 is in blocker booking
782
        hoverDateByISO("2026-06-29");
825
        hoverDateByISO("2026-06-29");
783
        getDateByISO("2026-06-29").should(
826
        getDateByISO("2026-06-29").should("have.class", "flatpickr-disabled");
784
            "have.class",
785
            "flatpickr-disabled"
786
        );
787
827
788
        // ========================================================================
828
        // ========================================================================
789
        // PHASE 3c: BIDIRECTIONAL - Lead Period Conflicts with Existing Booking TRAIL
829
        // PHASE 3c: BIDIRECTIONAL - Lead Period Conflicts with Existing Booking TRAIL
Lines 791-807 describe("Booking Modal Date Picker Tests", () => { Link Here
791
831
792
        // July 1: Lead June 29-30 → overlaps blocker trail (June 28-30) → DISABLED
832
        // July 1: Lead June 29-30 → overlaps blocker trail (June 28-30) → DISABLED
793
        hoverDateByISO("2026-07-01");
833
        hoverDateByISO("2026-07-01");
794
        getDateByISO("2026-07-01").should(
834
        getDateByISO("2026-07-01").should("have.class", "flatpickr-disabled");
795
            "have.class",
796
            "flatpickr-disabled"
797
        );
798
835
799
        // July 2: Lead June 30-July 1 → June 30 in blocker trail → DISABLED
836
        // July 2: Lead June 30-July 1 → June 30 in blocker trail → DISABLED
800
        hoverDateByISO("2026-07-02");
837
        hoverDateByISO("2026-07-02");
801
        getDateByISO("2026-07-02").should(
838
        getDateByISO("2026-07-02").should("have.class", "flatpickr-disabled");
802
            "have.class",
803
            "flatpickr-disabled"
804
        );
805
839
806
        // ========================================================================
840
        // ========================================================================
807
        // PHASE 3d: First Clear Start Date After Blocker's Protected Period
841
        // PHASE 3d: First Clear Start Date After Blocker's Protected Period
Lines 823-832 describe("Booking Modal Date Picker Tests", () => { Link Here
823
857
824
        // Hover June 23 - Trail (June 24-26) overlaps blocker ACTUAL (June 25-27)
858
        // Hover June 23 - Trail (June 24-26) overlaps blocker ACTUAL (June 25-27)
825
        hoverDateByISO("2026-06-23");
859
        hoverDateByISO("2026-06-23");
826
        getDateByISO("2026-06-23").should(
860
        getDateByISO("2026-06-23").should("have.class", "flatpickr-disabled");
827
            "have.class",
828
            "flatpickr-disabled"
829
        );
830
861
831
        // Clear selection for next phase
862
        // Clear selection for next phase
832
        cy.get("#booking_period").clearFlatpickr();
863
        cy.get("#booking_period").clearFlatpickr();
Lines 841-857 describe("Booking Modal Date Picker Tests", () => { Link Here
841
872
842
        // Hover June 21 - Trail (June 22-24) overlaps blocker LEAD (June 23-24) → DISABLED
873
        // Hover June 21 - Trail (June 22-24) overlaps blocker LEAD (June 23-24) → DISABLED
843
        hoverDateByISO("2026-06-21");
874
        hoverDateByISO("2026-06-21");
844
        getDateByISO("2026-06-21").should(
875
        getDateByISO("2026-06-21").should("have.class", "flatpickr-disabled");
845
            "have.class",
846
            "flatpickr-disabled"
847
        );
848
876
849
        // June 20 - Trail (June 21-23), June 23 overlaps blocker lead → DISABLED
877
        // June 20 - Trail (June 21-23), June 23 overlaps blocker lead → DISABLED
850
        hoverDateByISO("2026-06-20");
878
        hoverDateByISO("2026-06-20");
851
        getDateByISO("2026-06-20").should(
879
        getDateByISO("2026-06-20").should("have.class", "flatpickr-disabled");
852
            "have.class",
853
            "flatpickr-disabled"
854
        );
855
880
856
        // June 19 - Trail (June 20-22) doesn't reach blocker lead (starts June 23) → NOT disabled
881
        // June 19 - Trail (June 20-22) doesn't reach blocker lead (starts June 23) → NOT disabled
857
        hoverDateByISO("2026-06-19");
882
        hoverDateByISO("2026-06-19");
Lines 873-882 describe("Booking Modal Date Picker Tests", () => { Link Here
873
898
874
        // June 20: trail (June 21-23) overlaps blocker lead (June 23-24) → DISABLED
899
        // June 20: trail (June 21-23) overlaps blocker lead (June 23-24) → DISABLED
875
        hoverDateByISO("2026-06-20");
900
        hoverDateByISO("2026-06-20");
876
        getDateByISO("2026-06-20").should(
901
        getDateByISO("2026-06-20").should("have.class", "flatpickr-disabled");
877
            "have.class",
878
            "flatpickr-disabled"
879
        );
880
902
881
        // June 19: trail (June 20-22) clear of blocker lead → NOT disabled
903
        // June 19: trail (June 20-22) clear of blocker lead → NOT disabled
882
        hoverDateByISO("2026-06-19");
904
        hoverDateByISO("2026-06-19");
Lines 1133-1146 describe("Booking Modal Date Picker Tests", () => { Link Here
1133
                let chain = cy.wrap(null);
1155
                let chain = cy.wrap(null);
1134
                testItems.forEach((item, index) => {
1156
                testItems.forEach((item, index) => {
1135
                    const enumchron = String.fromCharCode(65 + index);
1157
                    const enumchron = String.fromCharCode(65 + index);
1136
                    chain = chain.then(() => cy.task("query", {
1158
                    chain = chain.then(() =>
1137
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1159
                        cy.task("query", {
1138
                        values: [
1160
                            sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1139
                            enumchron,
1161
                            values: [
1140
                            `2024-12-0${4 - index}`,
1162
                                enumchron,
1141
                            item.item_id,
1163
                                `2024-12-0${4 - index}`,
1142
                        ],
1164
                                item.item_id,
1143
                    }));
1165
                            ],
1166
                        })
1167
                    );
1144
                });
1168
                });
1145
                return chain;
1169
                return chain;
1146
            })
1170
            })
Lines 1178-1201 describe("Booking Modal Date Picker Tests", () => { Link Here
1178
                    { item: testItems[0], start: 10, end: 15 }, // ITEM 0
1202
                    { item: testItems[0], start: 10, end: 15 }, // ITEM 0
1179
                    { item: testItems[1], start: 13, end: 20 }, // ITEM 1
1203
                    { item: testItems[1], start: 13, end: 20 }, // ITEM 1
1180
                    { item: testItems[2], start: 18, end: 25 }, // ITEM 2
1204
                    { item: testItems[2], start: 18, end: 25 }, // ITEM 2
1181
                    { item: testItems[3], start: 1, end: 7 },   // ITEM 3
1205
                    { item: testItems[3], start: 1, end: 7 }, // ITEM 3
1182
                    { item: testItems[3], start: 23, end: 30 }, // ITEM 3
1206
                    { item: testItems[3], start: 23, end: 30 }, // ITEM 3
1183
                ];
1207
                ];
1184
                let chain = cy.wrap(null);
1208
                let chain = cy.wrap(null);
1185
                bookings.forEach(b => {
1209
                bookings.forEach(b => {
1186
                    chain = chain.then(() => cy.task("query", {
1210
                    chain = chain.then(() =>
1187
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1211
                        cy.task("query", {
1212
                            sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1188
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1213
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1189
                        values: [
1214
                            values: [
1190
                            testBiblio.biblio_id,
1215
                                testBiblio.biblio_id,
1191
                            testPatron.patron_id,
1216
                                testPatron.patron_id,
1192
                            b.item.item_id,
1217
                                b.item.item_id,
1193
                            "CPL",
1218
                                "CPL",
1194
                            today.add(b.start, "day").format("YYYY-MM-DD"),
1219
                                today.add(b.start, "day").format("YYYY-MM-DD"),
1195
                            today.add(b.end, "day").format("YYYY-MM-DD"),
1220
                                today.add(b.end, "day").format("YYYY-MM-DD"),
1196
                            "new",
1221
                                "new",
1197
                        ],
1222
                            ],
1198
                    }));
1223
                        })
1224
                    );
1199
                });
1225
                });
1200
                return chain;
1226
                return chain;
1201
            })
1227
            })
Lines 1213-1219 describe("Booking Modal Date Picker Tests", () => { Link Here
1213
                );
1239
                );
1214
1240
1215
                cy.get("booking-modal-island .modal").should("exist");
1241
                cy.get("booking-modal-island .modal").should("exist");
1216
                cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
1242
                cy.get("[data-booking-modal]")
1243
                    .first()
1244
                    .then($btn => $btn[0].click());
1217
                cy.get("booking-modal-island .modal", {
1245
                cy.get("booking-modal-island .modal", {
1218
                    timeout: 10000,
1246
                    timeout: 10000,
1219
                }).should("be.visible");
1247
                }).should("be.visible");
Lines 1272-1291 describe("Booking Modal Date Picker Tests", () => { Link Here
1272
                // SCENARIO 1: Start day 5
1300
                // SCENARIO 1: Start day 5
1273
                cy.log("=== Scenario 1: Start day 5 ===");
1301
                cy.log("=== Scenario 1: Start day 5 ===");
1274
                cy.get("@flatpickrInput").openFlatpickr();
1302
                cy.get("@flatpickrInput").openFlatpickr();
1275
                cy.get("@flatpickrInput")
1303
                cy.get("@flatpickrInput").selectFlatpickrDate(
1276
                    .selectFlatpickrDate(today.add(5, "day").toDate());
1304
                    today.add(5, "day").toDate()
1305
                );
1277
1306
1278
                checkDatesAvailable(6, 17);
1307
                checkDatesAvailable(6, 17);
1279
                checkDatesDisabled(18, 20);
1308
                checkDatesDisabled(18, 20);
1280
1309
1281
                // SCENARIO 2: Start day 8
1310
                // SCENARIO 2: Start day 8
1282
                cy.log(
1311
                cy.log("=== Scenario 2: Start day 8 (all items available) ===");
1283
                    "=== Scenario 2: Start day 8 (all items available) ==="
1284
                );
1285
                cy.get("@flatpickrInput").clearFlatpickr();
1312
                cy.get("@flatpickrInput").clearFlatpickr();
1286
                cy.get("@flatpickrInput").openFlatpickr();
1313
                cy.get("@flatpickrInput").openFlatpickr();
1287
                cy.get("@flatpickrInput")
1314
                cy.get("@flatpickrInput").selectFlatpickrDate(
1288
                    .selectFlatpickrDate(today.add(8, "day").toDate());
1315
                    today.add(8, "day").toDate()
1316
                );
1289
1317
1290
                checkDatesAvailable(9, 22);
1318
                checkDatesAvailable(9, 22);
1291
                checkDatesDisabled(23, 25);
1319
                checkDatesDisabled(23, 25);
Lines 1294-1301 describe("Booking Modal Date Picker Tests", () => { Link Here
1294
                cy.log("=== Scenario 3: Start day 19 ===");
1322
                cy.log("=== Scenario 3: Start day 19 ===");
1295
                cy.get("@flatpickrInput").clearFlatpickr();
1323
                cy.get("@flatpickrInput").clearFlatpickr();
1296
                cy.get("@flatpickrInput").openFlatpickr();
1324
                cy.get("@flatpickrInput").openFlatpickr();
1297
                cy.get("@flatpickrInput")
1325
                cy.get("@flatpickrInput").selectFlatpickrDate(
1298
                    .selectFlatpickrDate(today.add(19, "day").toDate());
1326
                    today.add(19, "day").toDate()
1327
                );
1299
1328
1300
                checkDatesAvailable(20, 25);
1329
                checkDatesAvailable(20, 25);
1301
            });
1330
            });
Lines 1356-1369 describe("Booking Modal Date Picker Tests", () => { Link Here
1356
                let chain = cy.wrap(null);
1385
                let chain = cy.wrap(null);
1357
                testItems.forEach((item, index) => {
1386
                testItems.forEach((item, index) => {
1358
                    const enumchron = String.fromCharCode(65 + index);
1387
                    const enumchron = String.fromCharCode(65 + index);
1359
                    chain = chain.then(() => cy.task("query", {
1388
                    chain = chain.then(() =>
1360
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1389
                        cy.task("query", {
1361
                        values: [
1390
                            sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1362
                            enumchron,
1391
                            values: [
1363
                            `2024-12-0${4 - index}`,
1392
                                enumchron,
1364
                            item.item_id,
1393
                                `2024-12-0${4 - index}`,
1365
                        ],
1394
                                item.item_id,
1366
                    }));
1395
                            ],
1396
                        })
1397
                    );
1367
                });
1398
                });
1368
                return chain;
1399
                return chain;
1369
            })
1400
            })
Lines 1397-1427 describe("Booking Modal Date Picker Tests", () => { Link Here
1397
            })
1428
            })
1398
            .then(() => {
1429
            .then(() => {
1399
                // Create bookings on ITEM 0 and ITEM 1 for days 10-12
1430
                // Create bookings on ITEM 0 and ITEM 1 for days 10-12
1400
                return cy.task("query", {
1431
                return cy
1401
                    sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1432
                    .task("query", {
1433
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1402
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1434
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1403
                    values: [
1435
                        values: [
1404
                        testBiblio.biblio_id,
1436
                            testBiblio.biblio_id,
1405
                        testPatron.patron_id,
1437
                            testPatron.patron_id,
1406
                        testItems[0].item_id,
1438
                            testItems[0].item_id,
1407
                        "CPL",
1439
                            "CPL",
1408
                        today.add(10, "day").format("YYYY-MM-DD"),
1440
                            today.add(10, "day").format("YYYY-MM-DD"),
1409
                        today.add(12, "day").format("YYYY-MM-DD"),
1441
                            today.add(12, "day").format("YYYY-MM-DD"),
1410
                        "new",
1442
                            "new",
1411
                    ],
1443
                        ],
1412
                }).then(() => cy.task("query", {
1444
                    })
1413
                    sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1445
                    .then(() =>
1446
                        cy.task("query", {
1447
                            sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1414
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1448
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1415
                    values: [
1449
                            values: [
1416
                        testBiblio.biblio_id,
1450
                                testBiblio.biblio_id,
1417
                        testPatron.patron_id,
1451
                                testPatron.patron_id,
1418
                        testItems[1].item_id,
1452
                                testItems[1].item_id,
1419
                        "CPL",
1453
                                "CPL",
1420
                        today.add(10, "day").format("YYYY-MM-DD"),
1454
                                today.add(10, "day").format("YYYY-MM-DD"),
1421
                        today.add(12, "day").format("YYYY-MM-DD"),
1455
                                today.add(12, "day").format("YYYY-MM-DD"),
1422
                        "new",
1456
                                "new",
1423
                    ],
1457
                            ],
1424
                }));
1458
                        })
1459
                    );
1425
            })
1460
            })
1426
            .then(() => {
1461
            .then(() => {
1427
                cy.intercept(
1462
                cy.intercept(
Lines 1437-1443 describe("Booking Modal Date Picker Tests", () => { Link Here
1437
                );
1472
                );
1438
1473
1439
                cy.get("booking-modal-island .modal").should("exist");
1474
                cy.get("booking-modal-island .modal").should("exist");
1440
                cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
1475
                cy.get("[data-booking-modal]")
1476
                    .first()
1477
                    .then($btn => $btn[0].click());
1441
                cy.get("booking-modal-island .modal", {
1478
                cy.get("booking-modal-island .modal", {
1442
                    timeout: 10000,
1479
                    timeout: 10000,
1443
                }).should("be.visible");
1480
                }).should("be.visible");
Lines 1470-1477 describe("Booking Modal Date Picker Tests", () => { Link Here
1470
                );
1507
                );
1471
1508
1472
                cy.get("@flatpickrInput").openFlatpickr();
1509
                cy.get("@flatpickrInput").openFlatpickr();
1473
                cy.get("@flatpickrInput")
1510
                cy.get("@flatpickrInput").hoverFlatpickrDate(
1474
                    .hoverFlatpickrDate(today.add(15, "day").toDate());
1511
                    today.add(15, "day").toDate()
1512
                );
1475
1513
1476
                // Day 15 should NOT be disabled (at least one item is free)
1514
                // Day 15 should NOT be disabled (at least one item is free)
1477
                cy.get("@flatpickrInput")
1515
                cy.get("@flatpickrInput")
Lines 1479-1486 describe("Booking Modal Date Picker Tests", () => { Link Here
1479
                    .should("not.have.class", "flatpickr-disabled");
1517
                    .should("not.have.class", "flatpickr-disabled");
1480
1518
1481
                // Actually click day 15 to verify it's selectable
1519
                // Actually click day 15 to verify it's selectable
1482
                cy.get("@flatpickrInput")
1520
                cy.get("@flatpickrInput").selectFlatpickrDate(
1483
                    .selectFlatpickrDate(today.add(15, "day").toDate());
1521
                    today.add(15, "day").toDate()
1522
                );
1484
1523
1485
                // Reset for next scenario
1524
                // Reset for next scenario
1486
                cy.get("@flatpickrInput").clearFlatpickr();
1525
                cy.get("@flatpickrInput").clearFlatpickr();
Lines 1512-1518 describe("Booking Modal Date Picker Tests", () => { Link Here
1512
                    );
1551
                    );
1513
1552
1514
                    cy.get("booking-modal-island .modal").should("exist");
1553
                    cy.get("booking-modal-island .modal").should("exist");
1515
                    cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
1554
                    cy.get("[data-booking-modal]")
1555
                        .first()
1556
                        .then($btn => $btn[0].click());
1516
                    cy.get("booking-modal-island .modal", {
1557
                    cy.get("booking-modal-island .modal", {
1517
                        timeout: 10000,
1558
                        timeout: 10000,
1518
                    }).should("be.visible");
1559
                    }).should("be.visible");
Lines 1538-1545 describe("Booking Modal Date Picker Tests", () => { Link Here
1538
                    cy.get("#booking_period").as("flatpickrInput2");
1579
                    cy.get("#booking_period").as("flatpickrInput2");
1539
1580
1540
                    cy.get("@flatpickrInput2").openFlatpickr();
1581
                    cy.get("@flatpickrInput2").openFlatpickr();
1541
                    cy.get("@flatpickrInput2")
1582
                    cy.get("@flatpickrInput2").hoverFlatpickrDate(
1542
                        .hoverFlatpickrDate(today.add(15, "day").toDate());
1583
                        today.add(15, "day").toDate()
1584
                    );
1543
1585
1544
                    // Day 15 should NOW be disabled (all items have conflicts)
1586
                    // Day 15 should NOW be disabled (all items have conflicts)
1545
                    cy.get("@flatpickrInput2")
1587
                    cy.get("@flatpickrInput2")
Lines 1555-1562 describe("Booking Modal Date Picker Tests", () => { Link Here
1555
1597
1556
                    // Hover a date whose trail overlaps with bookings
1598
                    // Hover a date whose trail overlaps with bookings
1557
                    // Day 13 trail should get hover-trail class on hover
1599
                    // Day 13 trail should get hover-trail class on hover
1558
                    cy.get("@flatpickrInput2")
1600
                    cy.get("@flatpickrInput2").hoverFlatpickrDate(
1559
                        .hoverFlatpickrDate(today.add(13, "day").toDate());
1601
                        today.add(13, "day").toDate()
1602
                    );
1560
                    cy.get("@flatpickrInput2")
1603
                    cy.get("@flatpickrInput2")
1561
                        .getFlatpickrDate(today.add(13, "day").toDate())
1604
                        .getFlatpickrDate(today.add(13, "day").toDate())
1562
                        .should("have.class", "booking-day--hover-trail");
1605
                        .should("have.class", "booking-day--hover-trail");
Lines 1569-1576 describe("Booking Modal Date Picker Tests", () => { Link Here
1569
                    );
1612
                    );
1570
1613
1571
                    // Hover a date whose lead period overlaps with bookings
1614
                    // Hover a date whose lead period overlaps with bookings
1572
                    cy.get("@flatpickrInput2")
1615
                    cy.get("@flatpickrInput2").hoverFlatpickrDate(
1573
                        .hoverFlatpickrDate(today.add(8, "day").toDate());
1616
                        today.add(8, "day").toDate()
1617
                    );
1574
                    cy.get("@flatpickrInput2")
1618
                    cy.get("@flatpickrInput2")
1575
                        .getFlatpickrDate(today.add(8, "day").toDate())
1619
                        .getFlatpickrDate(today.add(8, "day").toDate())
1576
                        .should("have.class", "booking-day--hover-lead");
1620
                        .should("have.class", "booking-day--hover-lead");
(-)a/t/cypress/support/flatpickr.js (-186 / +19 lines)
Lines 196-235 Cypress.Commands.add( Link Here
196
    }
196
    }
197
);
197
);
198
198
199
/**
200
 * Helper to close an open Flatpickr calendar.
201
 */
202
Cypress.Commands.add("closeFlatpickr", { prevSubject: true }, subject => {
203
    return cy.wrap(subject).then($input => {
204
        // Wait for flatpickr to be initialized and then close it
205
        return cy
206
            .wrap($input)
207
            .should($el => {
208
                expect($el[0]).to.have.property("_flatpickr");
209
            })
210
            .then(() => {
211
                $input[0]._flatpickr.close();
212
                return cy.wrap(subject);
213
            });
214
    });
215
});
216
217
/**
218
 * Helper to navigate to a specific month and year in a Flatpickr calendar.
219
 */
220
Cypress.Commands.add(
221
    "navigateToFlatpickrMonth",
222
    { prevSubject: true },
223
    (subject, targetDate, timeout = 10000) => {
224
        return ensureCalendarIsOpen(cy.wrap(subject), timeout).then($input => {
225
            const dayjsDate = dayjs(targetDate);
226
            return navigateToMonthAndYear(dayjsDate, $input, timeout).then(() =>
227
                cy.wrap($input)
228
            );
229
        });
230
    }
231
);
232
233
/**
199
/**
234
 * Helper to get the Flatpickr mode ('single', 'range', 'multiple').
200
 * Helper to get the Flatpickr mode ('single', 'range', 'multiple').
235
 */
201
 */
Lines 271-277 Cypress.Commands.add( Link Here
271
                            cy.wrap($input).should($el => {
237
                            cy.wrap($input).should($el => {
272
                                const fp = $el[0]._flatpickr;
238
                                const fp = $el[0]._flatpickr;
273
                                expect(fp.selectedDates.length).to.eq(1);
239
                                expect(fp.selectedDates.length).to.eq(1);
274
                                expect(dayjs(fp.selectedDates[0]).format("YYYY-MM-DD")).to.eq(dayjsDate.format("YYYY-MM-DD"));
240
                                expect(
241
                                    dayjs(fp.selectedDates[0]).format(
242
                                        "YYYY-MM-DD"
243
                                    )
244
                                ).to.eq(dayjsDate.format("YYYY-MM-DD"));
275
                            });
245
                            });
276
                            cy.get(".flatpickr-calendar.open").should(
246
                            cy.get(".flatpickr-calendar.open").should(
277
                                "not.exist",
247
                                "not.exist",
Lines 363-375 Cypress.Commands.add( Link Here
363
                            cy.wrap($input).should($el => {
333
                            cy.wrap($input).should($el => {
364
                                const fp = $el[0]._flatpickr;
334
                                const fp = $el[0]._flatpickr;
365
                                expect(fp.selectedDates.length).to.eq(2);
335
                                expect(fp.selectedDates.length).to.eq(2);
366
                                expect(dayjs(fp.selectedDates[0]).format("YYYY-MM-DD")).to.eq(startDayjsDate.format("YYYY-MM-DD"));
336
                                expect(
367
                                expect(dayjs(fp.selectedDates[1]).format("YYYY-MM-DD")).to.eq(endDayjsDate.format("YYYY-MM-DD"));
337
                                    dayjs(fp.selectedDates[0]).format(
338
                                        "YYYY-MM-DD"
339
                                    )
340
                                ).to.eq(startDayjsDate.format("YYYY-MM-DD"));
341
                                expect(
342
                                    dayjs(fp.selectedDates[1]).format(
343
                                        "YYYY-MM-DD"
344
                                    )
345
                                ).to.eq(endDayjsDate.format("YYYY-MM-DD"));
368
                            });
346
                            });
369
347
370
                            // Close calendar if it stayed open
348
                            // Close calendar if it stayed open
371
                            cy.get("body").then($body => {
349
                            cy.get("body").then($body => {
372
                                if ($body.find(".flatpickr-calendar.open").length > 0) {
350
                                if (
351
                                    $body.find(".flatpickr-calendar.open")
352
                                        .length > 0
353
                                ) {
373
                                    $input[0]._flatpickr.close();
354
                                    $input[0]._flatpickr.close();
374
                                }
355
                                }
375
                            });
356
                            });
Lines 408-415 Cypress.Commands.add( Link Here
408
    }
389
    }
409
);
390
);
410
391
411
// --- Enhanced Assertion Commands ---
412
413
/**
392
/**
414
 * Helper to get a specific Flatpickr day element by its date.
393
 * Helper to get a specific Flatpickr day element by its date.
415
 */
394
 */
Lines 445-595 Cypress.Commands.add( Link Here
445
    }
424
    }
446
);
425
);
447
426
448
/**
449
 * Assertion helper to check if a Flatpickr date is disabled.
450
 */
451
Cypress.Commands.add(
452
    "flatpickrDateShouldBeDisabled",
453
    { prevSubject: true },
454
    (subject, date) => {
455
        return cy
456
            .wrap(subject)
457
            .getFlatpickrDate(date)
458
            .should("have.class", "flatpickr-disabled")
459
            .then(() => cy.wrap(subject));
460
    }
461
);
462
463
/**
464
 * Assertion helper to check if a Flatpickr date is enabled.
465
 */
466
Cypress.Commands.add(
467
    "flatpickrDateShouldBeEnabled",
468
    { prevSubject: true },
469
    (subject, date) => {
470
        return cy
471
            .wrap(subject)
472
            .getFlatpickrDate(date)
473
            .should("not.have.class", "flatpickr-disabled")
474
            .then(() => cy.wrap(subject));
475
    }
476
);
477
478
/**
479
 * Assertion helper to check if a Flatpickr date is selected.
480
 */
481
Cypress.Commands.add(
482
    "flatpickrDateShouldBeSelected",
483
    { prevSubject: true },
484
    (subject, date) => {
485
        return cy
486
            .wrap(subject)
487
            .getFlatpickrDate(date)
488
            .should("have.class", "selected")
489
            .then(() => cy.wrap(subject));
490
    }
491
);
492
493
/**
494
 * Assertion helper to check if a Flatpickr date is not selected.
495
 */
496
Cypress.Commands.add(
497
    "flatpickrDateShouldNotBeSelected",
498
    { prevSubject: true },
499
    (subject, date) => {
500
        return cy
501
            .wrap(subject)
502
            .getFlatpickrDate(date)
503
            .should("not.have.class", "selected")
504
            .then(() => cy.wrap(subject));
505
    }
506
);
507
508
/**
509
 * Assertion helper to check if a Flatpickr date is today.
510
 */
511
Cypress.Commands.add(
512
    "flatpickrDateShouldBeToday",
513
    { prevSubject: true },
514
    (subject, date) => {
515
        return cy
516
            .wrap(subject)
517
            .getFlatpickrDate(date)
518
            .should("have.class", "today")
519
            .then(() => cy.wrap(subject));
520
    }
521
);
522
523
/**
524
 * Assertion helper to check if a Flatpickr date is in a range (has inRange class).
525
 */
526
Cypress.Commands.add(
527
    "flatpickrDateShouldBeInRange",
528
    { prevSubject: true },
529
    (subject, date) => {
530
        return cy
531
            .wrap(subject)
532
            .getFlatpickrDate(date)
533
            .should("have.class", "inRange")
534
            .then(() => cy.wrap(subject));
535
    }
536
);
537
538
/**
539
 * Assertion helper to check if a Flatpickr date is the start of a range.
540
 */
541
Cypress.Commands.add(
542
    "flatpickrDateShouldBeRangeStart",
543
    { prevSubject: true },
544
    (subject, date) => {
545
        return cy
546
            .wrap(subject)
547
            .getFlatpickrDate(date)
548
            .should("have.class", "startRange")
549
            .then(() => cy.wrap(subject));
550
    }
551
);
552
553
/**
554
 * Assertion helper to check if a Flatpickr date is the end of a range.
555
 */
556
Cypress.Commands.add(
557
    "flatpickrDateShouldBeRangeEnd",
558
    { prevSubject: true },
559
    (subject, date) => {
560
        return cy
561
            .wrap(subject)
562
            .getFlatpickrDate(date)
563
            .should("have.class", "endRange")
564
            .then(() => cy.wrap(subject));
565
    }
566
);
567
568
/**
569
 * Helper to get the selected dates from a Flatpickr instance.
570
 * Returns the selected dates as an array of YYYY-MM-DD formatted strings.
571
 */
572
Cypress.Commands.add(
573
    "getFlatpickrSelectedDates",
574
    { prevSubject: true },
575
    subject => {
576
        return cy.wrap(subject).then($input => {
577
            const fpInstance = $input[0]._flatpickr;
578
            if (!fpInstance) {
579
                throw new Error(
580
                    `Flatpickr: Cannot find flatpickr instance on element. Make sure it's initialized with flatpickr.`
581
                );
582
            }
583
584
            const selectedDates = fpInstance.selectedDates.map(date =>
585
                dayjs(date).format("YYYY-MM-DD")
586
            );
587
588
            return selectedDates;
589
        });
590
    }
591
);
592
593
/**
427
/**
594
 * Helper to clear a Flatpickr input by setting its value to empty.
428
 * Helper to clear a Flatpickr input by setting its value to empty.
595
 * Works with hidden inputs by using the Flatpickr API directly.
429
 * Works with hidden inputs by using the Flatpickr API directly.
596
- 

Return to bug 41129