diff --git a/assets/javascripts/discourse/initializers/discourse-calendar.js b/assets/javascripts/discourse/initializers/discourse-calendar.js index 63546a54..cc51bd1a 100644 --- a/assets/javascripts/discourse/initializers/discourse-calendar.js +++ b/assets/javascripts/discourse/initializers/discourse-calendar.js @@ -236,12 +236,18 @@ function initializeDiscourseCalendar(api) { calendar.render(); _setStaticCalendarEvents(calendar, $calendar, post); } else { - _setDynamicCalendarEvents(calendar, post, fullDay); + _setDynamicCalendarEvents(calendar, post, fullDay, timezone); calendar.render(); _setDynamicCalendarOptions(calendar, $calendar); } - _setupTimezonePicker(calendar, timezone); + const resetDynamicEvents = () => { + const selectedTimezone = calendar.getOption("timeZone"); + calendar.getEvents().forEach((event) => event.remove()); + _setDynamicCalendarEvents(calendar, post, fullDay, selectedTimezone); + }; + + _setupTimezonePicker(calendar, timezone, resetDynamicEvents); } function attachCalendar($elem, helper) { @@ -293,6 +299,7 @@ function initializeDiscourseCalendar(api) { center: "title", right: "month,basicWeek,listNextYear", }, + eventOrder: ["start", _orderByTz, "-duration", "allDay", "title"], datesRender: (info) => { if (showAddToCalendar) { _insertAddToCalendarLinks(info); @@ -300,9 +307,24 @@ function initializeDiscourseCalendar(api) { $calendarTitle.innerText = info.view.title; }, + + eventPositioned: (info) => { + _setTimezoneOffset(info); + }, }); } + function _orderByTz(a, b) { + if (!siteSettings.enable_timezone_offset_for_calendar_events) { + return 0; + } + + const offsetA = a.extendedProps.timezoneOffset; + const offsetB = b.extendedProps.timezoneOffset; + + return offsetA === offsetB ? 0 : offsetA < offsetB ? -1 : 1; + } + function _convertHtmlToDate(html) { const date = html.attr("data-date"); @@ -325,19 +347,26 @@ function initializeDiscourseCalendar(api) { function _buildEventObject(from, to) { const hasTimeSpecified = (d) => { + if (!d) { + return false; + } return d.hours() !== 0 || d.minutes() !== 0 || d.seconds() !== 0; }; + const hasTime = + hasTimeSpecified(to?.dateTime) || hasTimeSpecified(from?.dateTime); + const dateFormat = hasTime ? "YYYY-MM-DD HH:mm:ss Z" : "YYYY-MM-DD"; + let event = { - start: from.dateTime.toDate(), + start: from.dateTime.format(dateFormat), allDay: false, }; if (to) { - if (hasTimeSpecified(to.dateTime) || hasTimeSpecified(from.dateTime)) { - event.end = to.dateTime.toDate(); + if (hasTime) { + event.end = to.dateTime.format(dateFormat); } else { - event.end = to.dateTime.add(1, "days").toDate(); + event.end = to.dateTime.add(1, "days").format(dateFormat); event.allDay = true; } } else { @@ -478,6 +507,10 @@ function initializeDiscourseCalendar(api) { event.classNames = ["holiday"]; } + if (detail.timezoneOffset) { + event.extendedProps.timezoneOffset = detail.timezoneOffset; + } + return event; } @@ -514,50 +547,143 @@ function initializeDiscourseCalendar(api) { calendar.addEvent(event); } - function _addGroupedEvent(calendar, post, detail) { - let htmlContent = ""; - let usernames = []; - let localEventNames = []; + function _addGroupedEvent(calendar, post, detail, fullDay, calendarTz) { + const groupedEventData = + siteSettings.enable_timezone_offset_for_calendar_events && fullDay + ? _splitGroupEventByTimezone(detail, calendarTz) + : [detail]; - Object.keys(detail.localEvents) - .sort() - .forEach((key) => { - const localEvent = detail.localEvents[key]; - htmlContent += `${key}: ${localEvent.usernames - .sort() - .join(", ")}
`; - usernames = usernames.concat(localEvent.usernames); - localEventNames.push(key); - }); + groupedEventData.forEach((eventData) => { + let htmlContent = ""; + let users = []; + let localEventNames = []; - const event = _buildEvent(detail); - event.classNames = ["grouped-event"]; + Object.keys(eventData.localEvents) + .sort() + .forEach((key) => { + const localEvent = eventData.localEvents[key]; + htmlContent += `${key}: ${localEvent.users + .map((u) => u.username) + .sort() + .join(", ")}
`; + users = users.concat(localEvent.users); + localEventNames.push(key); + }); - if (usernames.length > 2) { - event.title = `(${usernames.length}) ${localEventNames[0]}`; - } else if (usernames.length === 1) { - event.title = usernames[0]; - } else { - event.title = isMobileView - ? `(${usernames.length}) ${localEventNames[0]}` - : `(${usernames.length}) ` + usernames.join(", "); - } + const event = _buildEvent(eventData); + event.classNames = ["grouped-event"]; - if (localEventNames.length > 1) { - event.extendedProps.htmlContent = htmlContent; - } else { - if (usernames.length > 1) { + if (users.length > 2) { + event.title = `(${users.length}) ${localEventNames[0]}`; + } else if (users.length === 1) { + event.title = users[0].username; + } else { + event.title = isMobileView + ? `(${users.length}) ${localEventNames[0]}` + : `(${users.length}) ` + users.map((u) => u.username).join(", "); + } + + if (localEventNames.length > 1) { event.extendedProps.htmlContent = htmlContent; } else { - event.extendedProps.htmlContent = localEventNames[0]; + if (users.length > 1) { + event.extendedProps.htmlContent = htmlContent; + } else { + event.extendedProps.htmlContent = localEventNames[0]; + } } - } - calendar.addEvent(event); + calendar.addEvent(event); + }); } - function _setDynamicCalendarEvents(calendar, post, fullDay) { + function _splitGroupEventByTimezone(detail, calendarTz) { + const calendarUtcOffset = moment.tz(calendarTz).utcOffset(); + let timezonesOffsets = []; + let splittedEvents = []; + + Object.values(detail.localEvents).forEach((event) => { + event.users.forEach((user) => { + const userUtcOffset = moment.tz(user.timezone).utcOffset(); + const timezoneOffset = (calendarUtcOffset - userUtcOffset) / 60; + user.timezoneOffset = timezoneOffset; + timezonesOffsets.push(timezoneOffset); + }); + }); + + [...new Set(timezonesOffsets)].forEach((offset, i) => { + let filteredLocalEvents = {}; + let eventTimezones = []; + + Object.keys(detail.localEvents).forEach((key) => { + const threshold = + siteSettings.split_grouped_events_by_timezone_threshold; + + const filtered = detail.localEvents[key].users.filter( + (u) => + Math.abs(u.timezoneOffset - (offset + threshold * i)) <= threshold + ); + if (filtered.length > 0) { + filteredLocalEvents[key] = { + users: filtered, + }; + filtered.forEach((u) => { + detail.localEvents[key].users.splice( + detail.localEvents[key].users.findIndex( + (e) => e.username === u.username + ), + 1 + ); + if ( + !eventTimezones.find((t) => t.timezoneOffset === u.timezoneOffset) + ) { + eventTimezones.push({ + timezone: u.timezone, + timezoneOffset: u.timezoneOffset, + }); + } + }); + } + }); + + if (Object.keys(filteredLocalEvents).length > 0) { + const eventTimezone = _findAverageTimezone(eventTimezones); + + let from = moment.tz(detail.from, eventTimezone.timezone); + let to = moment.tz(detail.to, eventTimezone.timezone); + + _modifyDatesForTimezoneOffset(from, to, eventTimezone.timezoneOffset); + + splittedEvents.push({ + timezoneOffset: eventTimezone.timezoneOffset, + localEvents: filteredLocalEvents, + from: from.format("YYYY-MM-DD"), + to: to.format("YYYY-MM-DD"), + }); + } + }); + + return splittedEvents; + } + + function _findAverageTimezone(eventTimezones) { + const totalOffsets = eventTimezones.reduce( + (sum, timezone) => sum + timezone.timezoneOffset, + 0 + ); + const averageOffset = totalOffsets / eventTimezones.length; + + return eventTimezones.reduce((closest, timezone) => { + const difference = Math.abs(timezone.timezoneOffset - averageOffset); + return difference < Math.abs(closest.timezoneOffset - averageOffset) + ? timezone + : closest; + }); + } + + function _setDynamicCalendarEvents(calendar, post, fullDay, calendarTz) { const groupedEvents = []; + const calendarUtcOffset = moment.tz(calendarTz).utcOffset(); (post.calendar_details || []).forEach((detail) => { switch (detail.type) { @@ -566,14 +692,24 @@ function initializeDiscourseCalendar(api) { break; case "standalone": if (fullDay && detail.timezone) { - detail.from = moment - .tz(detail.from, detail.timezone) - .format("YYYY-MM-DD"); - detail.to = moment - .tz(detail.to, detail.timezone) - .format("YYYY-MM-DD"); + const eventDetail = { ...detail }; + let from = moment.tz(detail.from, detail.timezone); + let to = moment.tz(detail.to, detail.timezone); + + if (siteSettings.enable_timezone_offset_for_calendar_events) { + const eventUtcOffset = moment.tz(detail.timezone).utcOffset(); + const timezoneOffset = (calendarUtcOffset - eventUtcOffset) / 60; + eventDetail.timezoneOffset = timezoneOffset; + + _modifyDatesForTimezoneOffset(from, to, timezoneOffset); + } + eventDetail.from = from.format("YYYY-MM-DD"); + eventDetail.to = to.format("YYYY-MM-DD"); + + _addStandaloneEvent(calendar, post, eventDetail); + } else { + _addStandaloneEvent(calendar, post, detail); } - _addStandaloneEvent(calendar, post, detail); break; } }); @@ -601,24 +737,44 @@ function initializeDiscourseCalendar(api) { formattedGroupedEvents[identifier].localEvents[groupedEvent.name] = formattedGroupedEvents[identifier].localEvents[groupedEvent.name] || { - usernames: [], + users: [], }; formattedGroupedEvents[identifier].localEvents[ groupedEvent.name - ].usernames.push.apply( - formattedGroupedEvents[identifier].localEvents[groupedEvent.name] - .usernames, - groupedEvent.usernames + ].users.push.apply( + formattedGroupedEvents[identifier].localEvents[groupedEvent.name].users, + groupedEvent.users ); }); Object.keys(formattedGroupedEvents).forEach((key) => { const formattedGroupedEvent = formattedGroupedEvents[key]; - _addGroupedEvent(calendar, post, formattedGroupedEvent); + _addGroupedEvent( + calendar, + post, + formattedGroupedEvent, + fullDay, + calendarTz + ); }); } + function _modifyDatesForTimezoneOffset(from, to, timezoneOffset) { + if (timezoneOffset > 0) { + if (to.isValid()) { + to.add(1, "day"); + } else { + to = from.clone().add(1, "day"); + } + } else if (timezoneOffset < 0) { + if (!to.isValid()) { + to = from.clone(); + } + from.subtract(1, "day"); + } + } + function _getTimeZone($calendar, currentUser) { let defaultTimezone = $calendar.attr("data-calendar-default-timezone"); const isValidDefaultTimezone = !!moment.tz.zone(defaultTimezone); @@ -629,19 +785,23 @@ function initializeDiscourseCalendar(api) { return defaultTimezone || currentUser?.timezone || moment.tz.guess(); } - function _setupTimezonePicker(calendar, timezone) { + function _setupTimezonePicker(calendar, timezone, resetDynamicEvents) { const tzPicker = document.querySelector( ".discourse-calendar-timezone-picker" ); if (tzPicker) { tzPicker.addEventListener("change", function (event) { calendar.setOption("timeZone", event.target.value); + resetDynamicEvents(); _insertAddToCalendarLinks(calendar); }); - moment.tz.names().forEach((tz) => { - tzPicker.appendChild(new Option(tz, tz)); - }); + moment.tz + .names() + .filter((t) => !t.startsWith("Etc/GMT")) + .forEach((tz) => { + tzPicker.appendChild(new Option(tz, tz)); + }); tzPicker.value = timezone; } else { @@ -663,6 +823,63 @@ function initializeDiscourseCalendar(api) { } } + function _setTimezoneOffset(info) { + if ( + !siteSettings.enable_timezone_offset_for_calendar_events || + info.view.type === "listNextYear" + ) { + return; + } + + // The timezone offset works by calculating the hour difference + // between a target event and the calendar event. This is used to + // determine whether to add an extra day before or after the event. + // Then, it applies inline styling to resize the event to its + // original size while adjusting it to the respective timezone. + + const timezoneOffset = info.event.extendedProps.timezoneOffset; + const segmentDuration = info.el.parentNode?.colSpan; + + const basePctOffset = 100 / segmentDuration; + // Base margin required to shrink down the event by one day + const basePxOffset = 5.5 - segmentDuration; + // Default space between two consecutive events + // 5.5px = ( ( ( 2px margin + 3px padding ) * 2 ) + 1px border ) / 2 + + // K factors are used to adjust each side of the event based on the hour difference + // A '2' is added to the pxOffset to account for the default margin + + if (timezoneOffset > 0) { + // When the event extends into the next day + if (info.isStart) { + const leftK = Math.abs(timezoneOffset) / 24; + const pctOffset = `${basePctOffset * leftK}%`; + const pxOffset = `${basePxOffset * leftK + 2}px`; + info.el.style.marginLeft = `calc(${pctOffset} + ${pxOffset})`; + } + if (info.isEnd) { + const rightK = (24 - Math.abs(timezoneOffset)) / 24; + const pctOffset = `${basePctOffset * rightK}%`; + const pxOffset = `${basePxOffset * rightK + 2}px`; + info.el.style.marginRight = `calc(${pctOffset} + ${pxOffset})`; + } + } else if (timezoneOffset < 0) { + // When the event starts on the previous day + if (info.isStart) { + const leftK = (24 - Math.abs(timezoneOffset)) / 24; + const pctOffset = `${basePctOffset * leftK}%`; + const pxOffset = `${basePxOffset * leftK + 2}px`; + info.el.style.marginLeft = `calc(${pctOffset} + ${pxOffset})`; + } + if (info.isEnd) { + const rightK = Math.abs(timezoneOffset) / 24; + const pctOffset = `${basePctOffset * rightK}%`; + const pxOffset = `${basePxOffset * rightK + 2}px`; + info.el.style.marginRight = `calc(${pctOffset} + ${pxOffset})`; + } + } + } + function _insertAddToCalendarLinkForEvent(event, eventSegmentDefMap) { const eventTitle = event.eventRange.def.title; let map = eventSegmentDefMap[event.eventRange.def.defId]; diff --git a/assets/stylesheets/common/discourse-calendar.scss b/assets/stylesheets/common/discourse-calendar.scss index 86f4d2a6..ca65744d 100644 --- a/assets/stylesheets/common/discourse-calendar.scss +++ b/assets/stylesheets/common/discourse-calendar.scss @@ -66,6 +66,10 @@ display: block; } + .fc-event-container { + padding: 3px; + } + .fc-widget-header span { padding: 3px 3px 3px 0.5em; } diff --git a/config/settings.yml b/config/settings.yml index 00d7880b..f9e9524a 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -49,6 +49,14 @@ discourse_calendar: sidebar_show_upcoming_events: default: true client: true + enable_timezone_offset_for_calendar_events: + default: false + client: true + hidden: true + split_grouped_events_by_timezone_threshold: + default: 0 + client: true + hidden: true discourse_post_event: discourse_post_event_enabled: diff --git a/plugin.rb b/plugin.rb index 31f7d1e2..6fb30b4b 100644 --- a/plugin.rb +++ b/plugin.rb @@ -363,22 +363,23 @@ after_initialize do else identifier = "#{event.region.split("_").first}-#{event.start_date.strftime("%j")}" - grouped[identifier] ||= { - type: :grouped, - from: event.start_date, - name: [], - usernames: [], - } + grouped[identifier] ||= { type: :grouped, from: event.start_date, name: [], users: [] } + + user = User.find_by_username(event.username) grouped[identifier][:name] << event.description - grouped[identifier][:usernames] << event.username + grouped[identifier][:users] << { + username: event.username, + timezone: user.present? ? user.user_option.timezone : nil, + } end end grouped.each do |_, v| v[:name].sort!.uniq! v[:name] = v[:name].join(", ") - v[:usernames].sort!.uniq! + v[:users].sort! { |a, b| a[:username] <=> b[:username] } + v[:users].uniq! { |u| u[:username] } end standalones + grouped.values diff --git a/spec/serializers/post_serializer_spec.rb b/spec/serializers/post_serializer_spec.rb index 4c5a53db..1985412d 100644 --- a/spec/serializers/post_serializer_spec.rb +++ b/spec/serializers/post_serializer_spec.rb @@ -35,9 +35,11 @@ describe PostSerializer do it "groups calendar events correctly" do user = Fabricate(:user) user.upsert_custom_fields(::DiscourseCalendar::REGION_CUSTOM_FIELD => "ar") + user.user_option.update!(timezone: "America/Buenos_Aires") user2 = Fabricate(:user) user2.upsert_custom_fields(::DiscourseCalendar::REGION_CUSTOM_FIELD => "ar") + user2.user_option.update!(timezone: "America/Buenos_Aires") post = create_post(raw: "[calendar]\n[/calendar]") SiteSetting.holiday_calendar_topic_id = post.topic.id @@ -52,8 +54,11 @@ describe PostSerializer do "Feriado puente turístico", "Día de la Independencia", ) - expect(json[:post][:calendar_details].map { |x| x[:usernames] }).to all ( - contain_exactly(user.username, user2.username) + expect(json[:post][:calendar_details].map { |x| x[:users] }).to all ( + contain_exactly( + { username: user.username, timezone: "America/Buenos_Aires" }, + { username: user2.username, timezone: "America/Buenos_Aires" }, + ) ) end end diff --git a/test/javascripts/acceptance/timezone-offset-test.js b/test/javascripts/acceptance/timezone-offset-test.js new file mode 100644 index 00000000..55ca054c --- /dev/null +++ b/test/javascripts/acceptance/timezone-offset-test.js @@ -0,0 +1,363 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { visit } from "@ember/test-helpers"; +import { test } from "qunit"; + +const topicResponse = { + post_stream: { + posts: [ + { + id: 375, + name: null, + username: "jan", + avatar_template: "/letter_avatar_proxy/v4/letter/j/ce7236/{size}.png", + created_at: "2023-09-08T16:50:07.638Z", + raw: '[calendar weekends=true tzPicker="true" fullDay="true" showAddToCalendar="false" defaultTimezone="Europe/Lisbon"] [/calendar]', + cooked: + '\u003cdiv class="discourse-calendar-wrap"\u003e\n\u003cdiv class="discourse-calendar-header"\u003e\n\u003ch2 class="discourse-calendar-title"\u003e\u003c/h2\u003e\n\u003cspan class="discourse-calendar-timezone-wrap"\u003e\n\u003cselect class="discourse-calendar-timezone-picker"\u003e\u003c/select\u003e\n\u003c/span\u003e\n\u003c/div\u003e\n\u003cdiv class="calendar" data-calendar-type="dynamic" data-calendar-default-timezone="Europe/Lisbon" data-weekends="true" data-calendar-show-add-to-calendar="false" data-calendar-full-day="true"\u003e\u003c/div\u003e\n\u003c/div\u003e', + post_number: 1, + post_type: 1, + updated_at: "2023-09-08T16:50:07.638Z", + reply_count: 0, + reply_to_post_number: null, + quote_count: 0, + incoming_link_count: 2, + reads: 1, + readers_count: 0, + score: 10.2, + yours: true, + topic_id: 252, + topic_slug: "awesome-calendar", + display_username: null, + primary_group_name: null, + flair_name: null, + flair_url: null, + flair_bg_color: null, + flair_color: null, + flair_group_id: null, + version: 1, + can_edit: true, + can_delete: false, + can_recover: false, + can_see_hidden_post: true, + can_wiki: true, + read: true, + user_title: null, + bookmarked: false, + actions_summary: [ + { id: 3, can_act: true }, + { id: 4, can_act: true }, + { id: 8, can_act: true }, + { id: 7, can_act: true }, + ], + moderator: false, + admin: true, + staff: true, + user_id: 1, + hidden: false, + trust_level: 1, + deleted_at: null, + user_deleted: false, + edit_reason: null, + can_view_edit_history: true, + wiki: false, + reviewable_id: 0, + reviewable_score_count: 0, + reviewable_score_pending_count: 0, + mentioned_users: [], + calendar_details: [ + { + type: "standalone", + post_number: 2, + message: "Cordoba", + from: "2023-09-14T00:00:00.000Z", + to: "2023-09-14T00:00:00.000Z", + username: "jan", + recurring: null, + post_url: "/t/-/252/2", + timezone: "America/Cordoba", + }, + { + type: "standalone", + post_number: 4, + message: "Moscow", + from: "2023-09-17T00:00:00.000Z", + to: "2023-09-18T00:00:00.000Z", + username: "jan", + recurring: null, + post_url: "/t/-/252/3", + timezone: "Europe/Moscow", + }, + { + type: "standalone", + post_number: 3, + message: "Tokyo", + from: "2023-09-20T00:00:00.000Z", + to: "2023-09-21T00:00:00.000Z", + username: "jan", + recurring: null, + post_url: "/t/-/252/4", + timezone: "Asia/Tokyo", + }, + { + type: "standalone", + post_number: 5, + message: "Lisbon", + from: "2023-09-28T00:00:00.000Z", + to: "2023-09-28T00:00:00.000Z", + username: "jan", + recurring: null, + post_url: "/t/-/252/5", + timezone: "Europe/Lisbon", + }, + { + type: "grouped", + from: "2023-09-04T05:00:00.000Z", + name: "Labor Day", + users: [ + { + username: "gmt-5_user", + timezone: "America/Chicago", + }, + { + username: "gmt-6_user", + timezone: "America/Denver", + }, + { + username: "gmt-7_user", + timezone: "America/Los_Angeles", + }, + ], + }, + ], + }, + ], + }, + timeline_lookup: [[1, 0]], + tags: [], + tags_descriptions: {}, + id: 252, + title: "Awesome Calendar", + fancy_title: "Awesome Calendar", + posts_count: 5, + created_at: "2023-09-08T16:50:07.371Z", + views: 1, + reply_count: 0, + like_count: 0, + last_posted_at: "2023-09-08T16:50:52.936Z", + visible: true, + closed: false, + archived: false, + has_summary: false, + archetype: "regular", + slug: "awesome-calendar", + category_id: 5, + word_count: 56, + deleted_at: null, + user_id: 1, + featured_link: null, + pinned_globally: false, + pinned_at: null, + pinned_until: null, + image_url: null, + slow_mode_seconds: 0, + draft: null, + draft_key: "topic_252", + draft_sequence: 9, + posted: true, + unpinned: null, + pinned: false, + current_post_number: 1, + highest_post_number: 4, + last_read_post_number: 4, + last_read_post_id: 378, + deleted_by: null, + has_deleted: false, + actions_summary: [ + { id: 4, count: 0, hidden: false, can_act: true }, + { id: 8, count: 0, hidden: false, can_act: true }, + { id: 7, count: 0, hidden: false, can_act: true }, + ], + chunk_size: 20, + bookmarked: false, + bookmarks: [], + topic_timer: null, + message_bus_last_id: 16, + participant_count: 1, + show_read_indicator: false, + thumbnails: null, + slow_mode_enabled_until: null, + summarizable: false, + details: { + can_edit: true, + notification_level: 3, + notifications_reason_id: 1, + can_move_posts: true, + can_delete: true, + can_remove_allowed_users: true, + can_invite_to: true, + can_invite_via_email: true, + can_create_post: true, + can_reply_as_new_topic: true, + can_flag_topic: true, + can_convert_topic: true, + can_review_topic: true, + can_close_topic: true, + can_archive_topic: true, + can_split_merge_topic: true, + can_edit_staff_notes: true, + can_toggle_topic_visibility: true, + can_pin_unpin_topic: true, + can_moderate_category: true, + can_remove_self_id: 1, + participants: [ + { + id: 1, + username: "jan", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/j/ce7236/{size}.png", + post_count: 4, + primary_group_name: null, + flair_name: null, + flair_url: null, + flair_color: null, + flair_bg_color: null, + flair_group_id: null, + admin: true, + trust_level: 1, + }, + ], + created_by: { + id: 1, + username: "jan", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/j/ce7236/{size}.png", + }, + last_poster: { + id: 1, + username: "jan", + name: null, + avatar_template: "/letter_avatar_proxy/v4/letter/j/ce7236/{size}.png", + }, + }, +}; + +function getEventByText(text) { + const events = [...document.querySelectorAll(".fc-day-grid-event")].filter( + (event) => event.textContent.includes(text) + ); + return events.length === 1 ? events[0] : events; +} + +function getRoundedPct(marginString) { + return Math.round(marginString.match(/(\d+(\.\d+)?)%/)[1]); +} + +acceptance("Discourse Calendar - Timezone Offset", function (needs) { + needs.settings({ + calendar_enabled: true, + enable_timezone_offset_for_calendar_events: true, + }); + + needs.pretender((server, helper) => { + server.get("/t/252.json", () => { + return helper.response(topicResponse); + }); + }); + + test("doesn't apply an offset for events in the same timezone", async (assert) => { + await visit("/t/252"); + + const eventElement = getEventByText("Lisbon"); + + assert.notOk(eventElement.style.marginLeft); + assert.notOk(eventElement.style.marginRight); + }); + + test("applies the correct offset for events that extend into the next day", async (assert) => { + await visit("/t/252"); + + const eventElement = getEventByText("Cordoba"); + + assert.strictEqual(getRoundedPct(eventElement.style.marginLeft), 8); // ( ( 1 - (-3) ) / 24 ) * 50% + assert.strictEqual(getRoundedPct(eventElement.style.marginRight), 42); // ( ( 24 - ( 1 - (-3) ) ) / 24 ) * 50% + }); + + test("applies the correct offset for events that start on the previous day", async (assert) => { + await visit("/t/252"); + + const eventElement = getEventByText("Tokyo"); + + assert.strictEqual(getRoundedPct(eventElement.style.marginLeft), 22); // ( ( 24 - ( 9 - 1 ) ) / 24 ) * 33.33% + assert.strictEqual(getRoundedPct(eventElement.style.marginRight), 11); // ( ( 9 - 1 ) / 24 ) * 33.33% + }); + + test("applies the correct offset for multiline events", async (assert) => { + await visit("/t/252"); + + const eventElement = getEventByText("Moscow"); + + assert.strictEqual(getRoundedPct(eventElement[0].style.marginLeft), 46); // ( ( 24 - ( 1 - (-1) ) ) / 24 ) * 50% + assert.notOk(eventElement[0].style.marginRight); + + assert.notOk(eventElement[1].style.marginLeft); + assert.strictEqual(getRoundedPct(eventElement[1].style.marginRight), 8); // ( ( 1 - (-1) ) / 24 ) * 100% + }); +}); + +acceptance("Discourse Calendar - Splitted Grouped Events", function (needs) { + needs.settings({ + calendar_enabled: true, + enable_timezone_offset_for_calendar_events: true, + split_grouped_events_by_timezone_threshold: 0, + }); + + needs.pretender((server, helper) => { + server.get("/t/252.json", () => { + return helper.response(topicResponse); + }); + }); + + test("splits holidays events by timezone", async (assert) => { + await visit("/t/252"); + + const eventElement = document.querySelectorAll( + ".fc-day-grid-event.grouped-event" + ); + assert.ok(eventElement.length === 3); + + assert.strictEqual(getRoundedPct(eventElement[0].style.marginLeft), 13); // ( ( 1 - (-5) ) / 24 ) * 50% + assert.strictEqual(getRoundedPct(eventElement[0].style.marginRight), 38); // ( ( 24 - ( 1 - (-5) ) ) / 24 ) * 50% + + assert.strictEqual(getRoundedPct(eventElement[1].style.marginLeft), 15); // ( ( 1 - (-6) ) / 24 ) * 50% + assert.strictEqual(getRoundedPct(eventElement[1].style.marginRight), 35); // ( ( 24 - ( 1 - (-6) ) ) / 24 ) * 50% + + assert.strictEqual(getRoundedPct(eventElement[2].style.marginLeft), 17); // ( ( 1 - (-7) ) / 24 ) * 50% + assert.strictEqual(getRoundedPct(eventElement[2].style.marginRight), 33); // ( ( 24 - ( 1 - (-7) ) ) / 24 ) * 50% + }); +}); + +acceptance("Discourse Calendar - Grouped Events", function (needs) { + needs.settings({ + calendar_enabled: true, + enable_timezone_offset_for_calendar_events: true, + split_grouped_events_by_timezone_threshold: 2, + }); + + needs.pretender((server, helper) => { + server.get("/t/252.json", () => { + return helper.response(topicResponse); + }); + }); + + test("groups holidays events according to threshold", async (assert) => { + await visit("/t/252"); + + const eventElement = document.querySelectorAll( + ".fc-day-grid-event.grouped-event" + ); + assert.ok(eventElement.length === 1); + + assert.strictEqual(getRoundedPct(eventElement[0].style.marginLeft), 15); // ( ( 1 - (-6) ) / 24 ) * 50% + assert.strictEqual(getRoundedPct(eventElement[0].style.marginRight), 35); // ( ( 24 - ( 1 - (-6) ) ) / 24 ) * 50% + }); +});