From 68ed1ffd7b61d43279824d79f41d3491bacec087 Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Tue, 13 Jan 2026 13:08:56 +0000 Subject: [PATCH] Bug 37707: (follow-up) Fix timezone handling for bookings Fix date handling issues that caused bookings to shift by a day when the browser timezone differs from the server timezone: - Normalize dates to browser timezone for calendar display - Use dayjs without UTC conversion for bookingsByDate keys - Extract local date and send as explicit UTC day boundaries - Preserve user's selected DATE regardless of browser timezone Timeline display: - Convert server dates to library timezone for display - Add $timezone() and $toDisplayDate() helper functions This ensures that a booking for "June 15" displays and saves as June 15 regardless of the user's timezone offset. --- .../prog/en/includes/js-date-format.inc | 22 +++++ .../prog/en/modules/bookings/list.tt | 7 +- .../prog/js/modals/place_booking.js | 80 +++++++++++++------ 3 files changed, 84 insertions(+), 25 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 ddc434c16a9..f04213fe961 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js +++ b/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js @@ -403,7 +403,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); } @@ -907,32 +907,57 @@ $("#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 let booked_items = bookings.filter( function (booking) { - let start_date = flatpickr.parseDate( + // Parse and normalize dates to start-of-day for consistent comparison + let start_date = dayjs( booking.start_date - ); - let end_date = flatpickr.parseDate( - booking.end_date - ); + ) + .startOf("day") + .toDate(); + let end_date = dayjs(booking.end_date) + .startOf("day") + .toDate(); + let selectedStart = dayjs( + selectedDates[0] + ) + .startOf("day") + .toDate(); + let selectedEnd = dayjs( + selectedDates[1] + ) + .startOf("day") + .toDate(); + // This booking ends before the start of the new booking - if (end_date <= selectedDates[0]) { + if (end_date < selectedStart) { return false; } - // This booking starts after then end of the new booking - if (start_date >= selectedDates[1]) { + // This booking starts after the end of the new booking + if (start_date > selectedEnd) { return false; } // This booking overlaps @@ -999,8 +1024,11 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { const item_id = booking.item_id; // Iterate through each date within the range of start_date and end_date - let currentDate = dayjs(start_date); - while (currentDate.isSameOrBefore(end_date, "day")) { + // Use dayjs to maintain browser timezone consistency + let currentDate = dayjs(start_date).startOf("day"); + const endDate = dayjs(end_date).startOf("day"); + while (currentDate.isSameOrBefore(endDate, "day")) { + // Format in browser timezone - no UTC conversion const currentDateStr = currentDate.format("YYYY-MM-DD"); // If the date key doesn't exist in the hash, create an empty array for it @@ -1024,9 +1052,9 @@ $("#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]; + // Format in browser timezone to match bookingsByDate keys + const dateString = + dayjs(currentDate).format("YYYY-MM-DD"); const isBold = boldDates.some( boldDate => @@ -2237,12 +2265,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, @@ -2346,12 +2377,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