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%
+ });
+});