From 923137c4113397bf1f85c684477e2e8a8153e4a3 Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Wed, 21 Jan 2026 09:40:46 +0100 Subject: [PATCH] Bug 37707: (follow-up) Fix timezone handling for cross-timezone bookings This patch fixes timezone issues that caused booking dates to shift when the browser and server are in different timezones. The code used implicit timezone conversions via .toISOString() and .toDate(), which work when browser and server timezones match but fail otherwise. QA testing showed: - PST browser: end date shifts +1 day - NZDT browser: end date shifts +2 days Make timezone handling explicit by establishing that booking dates are interpreted in the library's timezone. - Form submission: Extract date value (YYYY-MM-DD) and send as explicit UTC day boundaries using dayjs.utc().startOf/endOf('day') - Timeline display: Convert UTC to library timezone using $timezone() and new $toDisplayDate() helper for consistent rendering - Added $timezone() to expose server timezone to JavaScript - Added $toDisplayDate() to create display-ready Date objects that render correctly regardless of browser timezone To test: 1. Apply patch and run: yarn build 2. Configure a bookable item with 2-day lead and 3-day trail periods Timezone handling (PST): 3. Set browser timezone to PST (America/Los_Angeles), for example with Chrome Devtools -> Ctrl/Cmd + Shift + P -> sensors -> Location 4. Create a single-day booking for Jan 20 5. Verify the booking table shows start: Jan 20, end: Jan 20 (not Jan 21) 6. Verify the timeline shows the booking on Jan 20 only 7. Create a 3-day booking (Jan 22-24) 8. Verify the table shows start: Jan 22, end: Jan 24 9. Verify the timeline spans exactly 3 days Timezone handling (NZDT): 10. Set browser timezone to NZDT (Pacific/Auckland) 11. Repeat steps 4-9 12. Verify dates do not shift by +2 days as previously reported Test lead/trail conflict detection: 13. With existing booking on Jan 20 (trail period: Jan 21-23) 14. Attempt to create new booking starting Jan 24 (lead: Jan 22-23) 15. Verify conflict is detected (lead overlaps with trail) 16. Verify booking starting Jan 26 (lead: Jan 24-25) is allowed Test multi-item filtering: 17. On a bib with multiple bookable items 18. Create booking on item A for Jan 20 19. Open booking modal, select item B 20. Verify calendar does NOT show conflicts from item A's booking --- .../prog/en/includes/js-date-format.inc | 22 ++++++++++ .../prog/en/modules/bookings/list.tt | 7 ++- .../prog/js/modals/place_booking.js | 43 +++++++++++-------- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/js-date-format.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/js-date-format.inc index 2a95e218c1d..029fa91862d 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/js-date-format.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/js-date-format.inc @@ -118,6 +118,28 @@ } } + /** + * Returns the configured Koha server timezone. + * Useful for components that need to display dates/times in library timezone. + */ + window.$timezone = function() { + return def_tz; + } + + /** + * Converts a dayjs object to a Date for timeline/calendar display. + * Creates a Date that displays these exact values regardless of browser timezone. + * (Strips timezone info so JS interprets the values as browser-local time) + * + * Use this when you need to display a specific date/time from a known timezone + * (e.g., library timezone) and want it to appear the same for all users. + * + * @param {dayjs.Dayjs} dayjsObj - A dayjs object (typically converted to a specific timezone) + * @returns {Date} A Date object that will display the dayjs values in the browser + */ + window.$toDisplayDate = function(dayjsObj) { + return new Date(dayjsObj.format('YYYY-MM-DDTHH:mm:ss')); + } })(); diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt index 56b3da2954c..98b1bf86206 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt @@ -106,13 +106,16 @@ display_cardnumber: true, url: false }); + // Convert to library timezone for timeline display + const startServerTz = dayjs(booking.start_date).tz($timezone()); + const endServerTz = dayjs(booking.end_date).tz($timezone()); bookingsSet.add({ id: booking.booking_id, booking: booking.booking_id, patron: booking.patron_id, pickup_library: booking.pickup_library_id, - start: dayjs(booking.start_date).toDate(), - end: dayjs(booking.end_date).toDate(), + start: $toDisplayDate(startServerTz), + end: $toDisplayDate(endServerTz), content: !isActive ? `${patronContent}` : patronContent, className: booking.status === "cancelled" ? "cancelled" : "", [% IF CAN_user_circulate_manage_bookings %] diff --git a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js b/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js index e1d19f2fc0d..c37896b4ce9 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js +++ b/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js @@ -373,7 +373,7 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { end_date: checkout.due_date, item_id: checkout.item_id, patron_id: checkout.patron_id, - start_date: new Date().toISOString(), + start_date: dayjs().format(), }; bookings.unshift(booking); } @@ -777,15 +777,16 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { // Range set, update hidden fields and set available items else if (selectedDates[0] && selectedDates[1]) { // set form fields from picker - let picker_start = dayjs(selectedDates[0]); - let picker_end = dayjs(selectedDates[1]).endOf( - "day" - ); + // Extract local date and send as explicit UTC day boundaries + // This preserves the user's selected DATE regardless of browser timezone + // Using dayjs.utc() ensures startOf/endOf operate in UTC, not browser TZ + let startDate = dayjs(selectedDates[0]).format("YYYY-MM-DD"); + let endDate = dayjs(selectedDates[1]).format("YYYY-MM-DD"); $("#booking_start_date").val( - picker_start.toISOString() + dayjs.utc(startDate).startOf("day").toISOString() ); $("#booking_end_date").val( - picker_end.toISOString() + dayjs.utc(endDate).endOf("day").toISOString() ); // set available items in select2 @@ -871,9 +872,10 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { // Iterate through each date within the range of start_date and end_date let currentDate = new Date(start_date); while (currentDate <= end_date) { - const currentDateStr = currentDate - .toISOString() - .split("T")[0]; + // Use dayjs to format date in local timezone (not UTC) + const currentDateStr = dayjs(currentDate).format( + "YYYY-MM-DD" + ); // If the date key doesn't exist in the hash, create an empty array for it if (!bookingsByDate[currentDateStr]) { @@ -896,9 +898,10 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { periodPicker.config.onDayCreate.push( function dayCreate(dObj, dStr, instance, dayElem) { const currentDate = dayElem.dateObj; - const dateString = currentDate - .toISOString() - .split("T")[0]; + // Use dayjs to format date in local timezone (not UTC) + const dateString = dayjs(currentDate).format( + "YYYY-MM-DD" + ); const isBold = boldDates.some( boldDate => @@ -1409,12 +1412,15 @@ $("#placeBookingForm").on("submit", function (e) { bookings_table.api().ajax.reload(); } if (typeof timeline !== "undefined" && timeline !== null) { + // Convert to library timezone for timeline display + const startServerTz = dayjs(data.start_date).tz($timezone()); + const endServerTz = dayjs(data.end_date).tz($timezone()); timeline.itemsData.add({ id: data.booking_id, booking: data.booking_id, patron: data.patron_id, - start: dayjs(data.start_date).toDate(), - end: dayjs(data.end_date).toDate(), + start: $toDisplayDate(startServerTz), + end: $toDisplayDate(endServerTz), content: $patron_to_html(booking_patron, { display_cardnumber: true, url: false, @@ -1483,12 +1489,15 @@ $("#placeBookingForm").on("submit", function (e) { bookings_table.api().ajax.reload(); } if (typeof timeline !== "undefined" && timeline !== null) { + // Convert to library timezone for timeline display + const startServerTz = dayjs(data.start_date).tz($timezone()); + const endServerTz = dayjs(data.end_date).tz($timezone()); timeline.itemsData.update({ id: data.booking_id, booking: data.booking_id, patron: data.patron_id, - start: dayjs(data.start_date).toDate(), - end: dayjs(data.end_date).toDate(), + start: $toDisplayDate(startServerTz), + end: $toDisplayDate(endServerTz), content: $patron_to_html(booking_patron, { display_cardnumber: true, url: false, -- 2.52.0