From 361029ad4d96dc6cb2fa1d246a0083fc9313e7b0 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 26 Aug 2025 10:35:05 +0200 Subject: [PATCH] DEV: full calendar v6 (#33737) This commit makes the following changes: - uses fullcalendar 6 - replaces the server generated upcoming dates for recurring events with a frontend implementation of the RRULE standard (using the fullcalendar RRULE plugin) - displays a preview of the event on click on the upcoming events calendar --------- Co-authored-by: chapoi <101828855+chapoi@users.noreply.github.com> Co-authored-by: Martin Brennan --- .../components/modal/download-calendar.gjs | 4 +- .../discourse/app/lib/download-calendar.js | 8 +- .../discourse/app/lib/plugin-api.gjs | 2 +- .../app/static/full-calendar-bundle.js | 2 + app/assets/javascripts/discourse/package.json | 5 +- .../tests/unit/lib/download-calendar-test.js | 6 +- .../common/float-kit/d-tooltip.scss | 8 +- docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md | 4 +- .../discourse_post_event/events_controller.rb | 6 +- .../app/models/discourse_post_event/event.rb | 109 +- .../basic_event_serializer.rb | 81 ++ .../discourse_post_event/event_serializer.rb | 16 +- .../event_summary_serializer.rb | 63 - .../components/category-calendar.gjs | 166 +++ .../discourse-post-event/chat-channel.gjs | 6 +- .../components/discourse-post-event/dates.gjs | 30 +- .../components/discourse-post-event/index.gjs | 205 ++-- .../discourse-post-event/invitees.gjs | 25 +- .../discourse-post-event/more-menu.gjs | 3 +- .../discourse/components/full-calendar.gjs | 170 +++ .../modal/post-event-invitees/index.gjs | 28 +- .../discourse/components/post-calendar.gjs | 310 +++++ .../components/upcoming-events-calendar.gjs | 414 ++++--- .../category-events-calendar.gjs | 3 +- ...course-post-event-upcoming-events-index.js | 15 +- ...scourse-post-event-upcoming-events-mine.js | 15 +- ...scourse-event-upcoming-events-route-map.js | 6 +- .../initializers/discourse-calendar.gjs | 209 ++++ .../initializers/discourse-calendar.js | 1042 ----------------- .../discourse/lib/add-recurrent-events.js | 28 - .../discourse/lib/calendar-locale.js | 2 +- .../discourse/lib/calendar-view-helper.js | 28 + .../discourse-markdown/discourse-calendar.js | 24 - .../lib/full-calendar-default-options.js | 14 - .../models/discourse-post-event-event.js | 7 +- ...st-event-upcoming-events-default-index.gjs | 22 + ...ost-event-upcoming-events-default-mine.gjs | 22 + ...ourse-post-event-upcoming-events-index.gjs | 3 + ...course-post-event-upcoming-events-index.js | 19 - ...course-post-event-upcoming-events-mine.gjs | 23 +- .../routes/upcoming-events-base-route.js | 71 ++ .../services/discourse-post-event-api.js | 9 +- .../services/discourse-post-event-service.js | 1 + .../discourse/services/post-calendar.js | 21 + ...ourse-post-event-upcoming-events-index.gjs | 6 +- ...course-post-event-upcoming-events-mine.gjs | 7 +- .../assets/stylesheets/colors.scss | 13 + .../common/discourse-calendar.scss | 248 +--- .../discourse-post-event-upcoming-events.scss | 19 - .../common/discourse-post-event.scss | 30 +- .../stylesheets/common/full-calendar-ext.scss | 79 ++ .../common/upcoming-events-calendar.scss | 129 -- .../desktop/discourse-calendar.scss | 14 - .../mobile/discourse-calendar.scss | 35 +- .../stylesheets/vendor/fullcalendar.min.css | 5 - .../config/locales/client.en.yml | 2 +- .../config/locales/server.en.yml | 1 - plugins/discourse-calendar/config/routes.rb | 2 + .../discourse-calendar/config/settings.yml | 11 - ..._remove_unusused_calendar_site_settings.rb | 13 + .../discourse_post_event/bulk_invite.rb | 2 +- .../lib/discourse_post_event/event_finder.rb | 217 ++-- .../rrule_configurator.rb | 4 +- .../discourse_post_event/rrule_generator.rb | 34 +- plugins/discourse-calendar/plugin.rb | 5 +- .../fullcalendar-with-moment-timezone.min.js | 21 - .../rrule_configurator_spec.rb | 4 +- .../spec/requests/events_controller_spec.rb | 20 - .../event_summary_serializer_spec.rb | 182 --- .../spec/system/category_calendar_spec.rb | 18 +- .../discourse_calendar/upcoming_events.rb | 108 +- .../spec/system/post_event_spec.rb | 2 +- .../spec/system/upcoming_events_spec.rb | 284 +++-- .../category-events-calendar-test.js | 59 +- .../acceptance/sort-event-topics-test.js | 27 - .../acceptance/timezone-offset-test.js | 138 --- .../topic-calendar-holidays-test.js | 6 +- .../upcoming-events-calendar-test.js | 33 +- .../integration/components/dates-test.gjs | 5 +- pnpm-lock.yaml | 38 + spec/rails_helper.rb | 9 + 81 files changed, 2514 insertions(+), 2571 deletions(-) create mode 100644 plugins/discourse-calendar/app/serializers/discourse_post_event/basic_event_serializer.rb delete mode 100644 plugins/discourse-calendar/app/serializers/discourse_post_event/event_summary_serializer.rb create mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/components/category-calendar.gjs create mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/components/full-calendar.gjs create mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/components/post-calendar.gjs create mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/initializers/discourse-calendar.gjs delete mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/initializers/discourse-calendar.js delete mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/lib/add-recurrent-events.js create mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/lib/calendar-view-helper.js create mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/routes/discourse-post-event-upcoming-events-default-index.gjs create mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/routes/discourse-post-event-upcoming-events-default-mine.gjs create mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/routes/discourse-post-event-upcoming-events-index.gjs delete mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/routes/discourse-post-event-upcoming-events-index.js create mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/routes/upcoming-events-base-route.js create mode 100644 plugins/discourse-calendar/assets/javascripts/discourse/services/post-calendar.js delete mode 100644 plugins/discourse-calendar/assets/stylesheets/common/discourse-post-event-upcoming-events.scss create mode 100644 plugins/discourse-calendar/assets/stylesheets/common/full-calendar-ext.scss delete mode 100644 plugins/discourse-calendar/assets/stylesheets/common/upcoming-events-calendar.scss delete mode 100644 plugins/discourse-calendar/assets/stylesheets/desktop/discourse-calendar.scss delete mode 100644 plugins/discourse-calendar/assets/stylesheets/vendor/fullcalendar.min.css create mode 100644 plugins/discourse-calendar/db/migrate/20250819123417_remove_unusused_calendar_site_settings.rb delete mode 100644 plugins/discourse-calendar/public/javascripts/fullcalendar-with-moment-timezone.min.js delete mode 100644 plugins/discourse-calendar/spec/serializers/discourse_post_event/event_summary_serializer_spec.rb delete mode 100644 plugins/discourse-calendar/test/javascripts/acceptance/sort-event-topics-test.js delete mode 100644 plugins/discourse-calendar/test/javascripts/acceptance/timezone-offset-test.js diff --git a/app/assets/javascripts/discourse/app/components/modal/download-calendar.gjs b/app/assets/javascripts/discourse/app/components/modal/download-calendar.gjs index 8c97ee37a29..0ecbf8f566e 100644 --- a/app/assets/javascripts/discourse/app/components/modal/download-calendar.gjs +++ b/app/assets/javascripts/discourse/app/components/modal/download-calendar.gjs @@ -30,7 +30,7 @@ export default class DownloadCalendar extends Component { this.args.model.calendar.title, this.args.model.calendar.dates, { - recurrenceRule: this.args.model.calendar.recurrenceRule, + rrule: this.args.model.calendar.rrule, location: this.args.model.calendar.location, details: this.args.model.calendar.details, } @@ -40,7 +40,7 @@ export default class DownloadCalendar extends Component { this.args.model.calendar.title, this.args.model.calendar.dates, { - recurrenceRule: this.args.model.calendar.recurrenceRule, + rrule: this.args.model.calendar.rrule, location: this.args.model.calendar.location, details: this.args.model.calendar.details, } diff --git a/app/assets/javascripts/discourse/app/lib/download-calendar.js b/app/assets/javascripts/discourse/app/lib/download-calendar.js index b2f238b9c0a..ff7b8d0db2c 100644 --- a/app/assets/javascripts/discourse/app/lib/download-calendar.js +++ b/app/assets/javascripts/discourse/app/lib/download-calendar.js @@ -49,8 +49,8 @@ export function downloadGoogle(title, dates, options = {}) { )}` ); - if (options.recurrenceRule) { - link.searchParams.append("recur", `RRULE:${options.recurrenceRule}`); + if (options.rrule) { + link.searchParams.append("recur", `RRULE:${options.rrule}`); } if (options.location) { @@ -88,7 +88,7 @@ export function generateIcsData(title, dates, options = {}) { `DTSTAMP:${moment().utc().format("YMMDDTHHmmss")}Z\n` + `DTSTART:${startDate.utc().format("YMMDDTHHmmss")}Z\n` + `DTEND:${endDate.utc().format("YMMDDTHHmmss")}Z\n` + - (options.recurrenceRule ? `RRULE:${options.recurrenceRule}\n` : ``) + + (options.rrule ? `RRULE:${options.rrule}\n` : ``) + (options.location ? `LOCATION:${options.location}\n` : ``) + (options.details ? `DESCRIPTION:${options.details}\n` : ``) + `SUMMARY:${title}\n` + @@ -106,7 +106,7 @@ function _displayModal(title, dates, options = {}) { calendar: { title, dates, - recurrenceRule: options.recurrenceRule, + rrule: options.rrule, location: options.location, details: options.details, }, diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.gjs b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs index 61c8a8cee74..29f4b569efc 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.gjs +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs @@ -2457,7 +2457,7 @@ class PluginApi { * endsAt: "2021-10-12T16:00:00.000Z", * }, * ], - * { recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", location: "Paris", details: "Foo" } + * { rrule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", location: "Paris", details: "Foo" } * ); * ``` */ diff --git a/app/assets/javascripts/discourse/app/static/full-calendar-bundle.js b/app/assets/javascripts/discourse/app/static/full-calendar-bundle.js index 5c2269eb08e..56fce6bf0c4 100644 --- a/app/assets/javascripts/discourse/app/static/full-calendar-bundle.js +++ b/app/assets/javascripts/discourse/app/static/full-calendar-bundle.js @@ -2,3 +2,5 @@ export { Calendar } from "@fullcalendar/core"; export { default as DayGrid } from "@fullcalendar/daygrid"; export { default as TimeGrid } from "@fullcalendar/timegrid"; export { default as List } from "@fullcalendar/list"; +export { default as RRULE } from "@fullcalendar/rrule"; +export { default as MomentTimezone } from "@fullcalendar/moment-timezone"; diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json index 98cd080c4d6..d1b13943276 100644 --- a/app/assets/javascripts/discourse/package.json +++ b/app/assets/javascripts/discourse/package.json @@ -23,6 +23,8 @@ "@fullcalendar/core": "^6.1.18", "@fullcalendar/daygrid": "^6.1.18", "@fullcalendar/list": "^6.1.18", + "@fullcalendar/moment-timezone": "^6.1.19", + "@fullcalendar/rrule": "^6.1.18", "@fullcalendar/timegrid": "^6.1.18", "@glimmer/syntax": "0.93.1", "@highlightjs/cdn-assets": "11.11.1", @@ -58,7 +60,8 @@ "prosemirror-schema-list": "^1.5.1", "prosemirror-state": "^1.4.3", "prosemirror-transform": "^1.10.4", - "prosemirror-view": "^1.40.0" + "prosemirror-view": "^1.40.0", + "rrule": "^2.8.1" }, "devDependencies": { "@babel/core": "^7.28.3", diff --git a/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js b/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js index b9d94a7acb9..bb6a1968a86 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/download-calendar-test.js @@ -33,7 +33,7 @@ module("Unit | Utility | download-calendar", function (hooks) { }, ], { - recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", + rrule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR", location: "Paris", details: "Good soup", } @@ -74,7 +74,7 @@ END:VCALENDAR` endsAt: "2021-10-12T16:00:00.000Z", }, ], - { recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" } + { rrule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" } ); assert.strictEqual( data, @@ -121,7 +121,7 @@ END:VCALENDAR` endsAt: "2021-10-12T16:00:00.000Z", }, ], - { recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" } + { rrule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" } ); assert.true( window.open.calledWith( diff --git a/app/assets/stylesheets/common/float-kit/d-tooltip.scss b/app/assets/stylesheets/common/float-kit/d-tooltip.scss index fd3197876b8..25355c1c67d 100644 --- a/app/assets/stylesheets/common/float-kit/d-tooltip.scss +++ b/app/assets/stylesheets/common/float-kit/d-tooltip.scss @@ -69,7 +69,7 @@ &[data-placement^="top"] { .arrow { - bottom: -10px; + bottom: -11px; rotate: 180deg; } } @@ -82,21 +82,21 @@ &[data-placement^="bottom"] { .arrow { - top: -10px; + top: -11px; } } &[data-placement^="right"] { .arrow { rotate: -90deg; - left: -10px; + left: -11px; } } &[data-placement^="left"] { .arrow { rotate: 90deg; - right: -10px; + right: -11px; } } } diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md index 2d58186a20a..4ae1caebbb2 100644 --- a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md +++ b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md @@ -109,7 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.24.0] - 2024-01-08 -- Added `addAdminSidebarSectionLink` which is used to add a link to a specific admin sidebar section, as a replacement for the `admin-menu` plugin outlet. +- Added `addAdminSidebarSectionLink` which is used to add a link to a specific admin sidebar section, as a replacement for the `admin-menu` plugin outlet. ## [1.23.0] - 2024-01-03 @@ -154,7 +154,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added `recurrenceRule` option to `downloadCalendar`, this can be used to set recurring events in the calendar. Rule syntax can be found at https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10. +- Added `rrule` option to `downloadCalendar`, this can be used to set recurring events in the calendar. Rule syntax can be found at https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10. ## [1.15.0] - 2023-10-18 diff --git a/plugins/discourse-calendar/app/controllers/discourse_post_event/events_controller.rb b/plugins/discourse-calendar/app/controllers/discourse_post_event/events_controller.rb index a54b0dd3cd0..7c82e07d331 100644 --- a/plugins/discourse-calendar/app/controllers/discourse_post_event/events_controller.rb +++ b/plugins/discourse-calendar/app/controllers/discourse_post_event/events_controller.rb @@ -9,12 +9,12 @@ module DiscoursePostEvent ) # The detailed serializer is currently not used anywhere in the frontend, but available via API - serializer = params[:include_details] == "true" ? EventSerializer : EventSummarySerializer + serializer = params[:include_details] == "true" ? EventSerializer : BasicEventSerializer render json: ActiveModel::ArraySerializer.new( @events, - each_serializer: serializer, + each_serializer: BasicEventSerializer, scope: guardian, ).as_json end @@ -124,6 +124,8 @@ module DiscoursePostEvent :limit, :before, :attending_user, + :before, + :after, ) end end diff --git a/plugins/discourse-calendar/app/models/discourse_post_event/event.rb b/plugins/discourse-calendar/app/models/discourse_post_event/event.rb index d9d769cac9b..61ee47399b2 100644 --- a/plugins/discourse-calendar/app/models/discourse_post_event/event.rb +++ b/plugins/discourse-calendar/app/models/discourse_post_event/event.rb @@ -59,40 +59,19 @@ module DiscoursePostEvent return if !starts_at_changed && !ends_at_changed - event_dates.update_all(finished_at: Time.current) set_next_date end def set_next_date - next_dates = calculate_next_date - return if !next_dates + next_date_result = calculate_next_date - date_args = { starts_at: next_dates[:starts_at], ends_at: next_dates[:ends_at] } - if next_dates[:ends_at] && next_dates[:ends_at] < Time.current - date_args[:finished_at] = next_dates[:ends_at] - end - - existing_date = event_dates.find_by(starts_at: date_args[:starts_at]) - - if existing_date && existing_date.ends_at == date_args[:ends_at] && - existing_date.finished_at == date_args[:finished_at] - # exact same state in DB, this is a dupe call, return early - return - end - - if existing_date - existing_date.update!(date_args) - else - event_dates.create!(date_args) - end - - invitees.where.not(status: Invitee.statuses[:going]).update_all(status: nil, notified: false) - - if !next_dates[:rescheduled] - notify_invitees! - notify_missing_invitees! - end + return event_dates.update_all(finished_at: Time.current) if next_date_result.nil? + starts_at, ends_at = next_date_result + finish_previous_event_dates(starts_at) if dates_changed? + upsert_event_date(starts_at, ends_at) + reset_invitee_notifications + notify_if_new_event publish_update! end @@ -400,27 +379,71 @@ module DiscoursePostEvent end def calculate_next_date - if self.recurrence.blank? || original_starts_at > Time.current - return { starts_at: original_starts_at, ends_at: original_ends_at, rescheduled: false } + if recurrence.blank? || original_starts_at > Time.current + return original_starts_at, original_ends_at end - next_starts_at = - RRuleGenerator.generate( - starts_at: original_starts_at.in_time_zone(timezone), - timezone:, - recurrence:, - recurrence_until:, - ).first - return unless next_starts_at + next_starts_at = calculate_next_recurring_date + return nil unless next_starts_at - if original_ends_at - difference = original_ends_at - original_starts_at - next_ends_at = next_starts_at + difference.seconds + next_ends_at = original_ends_at ? next_starts_at + event_duration : nil + [next_starts_at, next_ends_at] + end + + private + + def dates_changed? + saved_change_to_original_starts_at || saved_change_to_original_ends_at + end + + def finish_previous_event_dates(current_starts_at) + existing_date = event_dates.find_by(starts_at: current_starts_at) + event_dates + .where.not(id: existing_date&.id) + .where(finished_at: nil) + .update_all(finished_at: Time.current) + end + + def upsert_event_date(starts_at, ends_at) + finished_at = ends_at && ends_at < Time.current ? ends_at : nil + + existing_date = event_dates.find_by(starts_at:) + + if existing_date + # Only update if something actually changed + unless existing_date.ends_at == ends_at && existing_date.finished_at == finished_at + existing_date.update!(ends_at:, finished_at:) + end else - next_ends_at = nil + event_dates.create!(starts_at:, ends_at:, finished_at:) end + end - { starts_at: next_starts_at, ends_at: next_ends_at, rescheduled: true } + def reset_invitee_notifications + invitees.where.not(status: Invitee.statuses[:going]).update_all(status: nil, notified: false) + end + + def notify_if_new_event + is_generating_future_recurrence = recurrence.present? && original_starts_at <= Time.current + + unless is_generating_future_recurrence + notify_invitees! + notify_missing_invitees! + end + end + + def calculate_next_recurring_date + RRuleGenerator.generate( + starts_at: original_starts_at.in_time_zone(timezone), + timezone: timezone, + recurrence: recurrence, + recurrence_until: recurrence_until, + dtstart: original_starts_at.in_time_zone(timezone), + ).first + end + + def event_duration + original_ends_at - original_starts_at end end end diff --git a/plugins/discourse-calendar/app/serializers/discourse_post_event/basic_event_serializer.rb b/plugins/discourse-calendar/app/serializers/discourse_post_event/basic_event_serializer.rb new file mode 100644 index 00000000000..c08f8cf7c67 --- /dev/null +++ b/plugins/discourse-calendar/app/serializers/discourse_post_event/basic_event_serializer.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module DiscoursePostEvent + class BasicEventSerializer < ApplicationSerializer + attributes :id, + :category_id, + :name, + :recurrence, + :recurrence_until, + :starts_at, + :ends_at, + :rrule, + :show_local_time, + :timezone, + :post + + def category_id + object.post.topic.category_id + end + + def post + { + id: object.post.id, + post_number: object.post.post_number, + url: object.post.url, + topic: { + id: object.post.topic.id, + title: object.post.topic.title, + }, + } + end + + def include_rrule? + object.recurring? + end + + def rrule + return nil unless include_rrule? + + # Use UTC for RRULE to avoid timezone compatibility issues with FullCalendar + RRuleGenerator.generate_string( + starts_at: object.original_starts_at, + timezone: "UTC", + recurrence: object.recurrence, + recurrence_until: object.recurrence_until, + dtstart: object.original_starts_at, + ) + end + + def starts_at + # For recurring events, use UTC to match RRULE + # For non-recurring events, use the event's timezone + if object.recurring? + object.original_starts_at + else + object.starts_at.in_time_zone(object.timezone) + end + end + + def ends_at + if object.ends_at + if object.recurring? + object.ends_at + else + object.ends_at.in_time_zone(object.timezone) + end + else + # Use consistent timezone as starts_at for calculation + base_starts_at = + ( + if object.recurring? + object.original_starts_at + else + object.starts_at.in_time_zone(object.timezone) + end + ) + (base_starts_at + 1.hour) + end + end + end +end diff --git a/plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb b/plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb index ac48921d607..eb50534619e 100644 --- a/plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb +++ b/plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb @@ -20,7 +20,6 @@ module DiscoursePostEvent attributes :post attributes :raw_invitees attributes :recurrence - attributes :recurrence_rule attributes :recurrence_until attributes :reminders attributes :sample_invitees @@ -36,6 +35,7 @@ module DiscoursePostEvent attributes :watching_invitee attributes :chat_enabled attributes :channel + attributes :rrule def channel ::Chat::ChannelSerializer.new(object.chat_channel, root: false, scope:) @@ -141,16 +141,22 @@ module DiscoursePostEvent object.url.present? end - def include_recurrence_rule? + def include_rrule? object.recurring? end - def recurrence_rule - RRuleConfigurator.rule( + def rrule + RRuleGenerator.generate_string( + starts_at: object.original_starts_at.in_time_zone(object.timezone), + timezone: object.timezone, recurrence: object.recurrence, - starts_at: object.starts_at.in_time_zone(object.timezone), recurrence_until: object.recurrence_until&.in_time_zone(object.timezone), + dtstart: object.original_starts_at.in_time_zone(object.timezone), ) end + + def ends_at + object.ends_at || object.starts_at + 1.hour + end end end diff --git a/plugins/discourse-calendar/app/serializers/discourse_post_event/event_summary_serializer.rb b/plugins/discourse-calendar/app/serializers/discourse_post_event/event_summary_serializer.rb deleted file mode 100644 index 86a9781b811..00000000000 --- a/plugins/discourse-calendar/app/serializers/discourse_post_event/event_summary_serializer.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module DiscoursePostEvent - class EventSummarySerializer < ApplicationSerializer - attributes :id - attributes :starts_at - attributes :ends_at - attributes :show_local_time - attributes :timezone - attributes :post - attributes :name - attributes :category_id - attributes :upcoming_dates - - # lightweight post object containing - # only needed info for client - def post - post_hash = { - id: object.post.id, - post_number: object.post.post_number, - url: object.post.url, - topic: { - id: object.post.topic.id, - title: object.post.topic.title, - }, - } - - if post_hash[:topic][:title].match?(/:[\w\-+]+:/) - post_hash[:topic][:title] = Emoji.gsub_emoji_to_unicode(post_hash[:topic][:title]) - end - - if JSON.parse(SiteSetting.map_events_to_color).size > 0 - post_hash[:topic][:category_slug] = object.post.topic&.category&.slug - post_hash[:topic][:tags] = object.post.topic.tags&.map(&:name) - end - - post_hash - end - - def category_id - object.post.topic.category_id - end - - def include_upcoming_dates? - object.recurring? - end - - def upcoming_dates - difference = object.original_ends_at ? object.original_ends_at - object.original_starts_at : 0 - - RRuleGenerator - .generate( - starts_at: object.original_starts_at.in_time_zone(object.timezone), - timezone: object.timezone, - max_years: 1, - recurrence: object.recurrence, - recurrence_until: object.recurrence_until, - ) - .map { |date| { starts_at: date, ends_at: date + difference.seconds } } - .take(31) - end - end -end diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/category-calendar.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/category-calendar.gjs new file mode 100644 index 00000000000..5e6cc27697a --- /dev/null +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/category-calendar.gjs @@ -0,0 +1,166 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import AsyncContent from "discourse/components/async-content"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { bind } from "discourse/lib/decorators"; +import getURL from "discourse/lib/get-url"; +import Category from "discourse/models/category"; +import { formatEventName } from "../helpers/format-event-name"; +import { isNotFullDayEvent } from "../lib/guess-best-date-format"; +import FullCalendar from "./full-calendar"; + +export default class CategoryCalendar extends Component { + @service currentUser; + @service router; + @service siteSettings; + @service store; + @service discoursePostEventApi; + + @bind + async loadEvents() { + try { + const params = { + post_id: this.categorySetting?.postId, + category_id: this.category.id, + include_subcategories: true, + }; + + if (this.siteSettings.include_expired_events_on_calendar) { + params.include_expired = true; + } + + return await this.discoursePostEventApi.events(params); + } catch (error) { + popupAjaxError(error); + } + } + + get tagsColorsMap() { + return JSON.parse(this.siteSettings.map_events_to_color); + } + + get shouldRender() { + if (this.siteSettings.login_required && !this.currentUser) { + return false; + } + + if (!this.router.currentRoute?.params?.category_slug_path_with_id) { + return false; + } + + if (!this.category) { + return false; + } + + if (!this.validCategory) { + return false; + } + + return true; + } + + get validCategory() { + if ( + !this.categorySetting && + !this.siteSettings.events_calendar_categories + ) { + return false; + } + + return ( + this.categorySetting?.categoryId === this.category.id.toString() || + this.siteSettings.events_calendar_categories + .split("|") + .filter(Boolean) + .includes(this.category.id.toString()) + ); + } + + get category() { + return Category.findBySlugPathWithID( + this.router.currentRoute.params.category_slug_path_with_id + ); + } + + get renderWeekends() { + return this.categorySetting?.weekends !== "false"; + } + + get categorySetting() { + const settings = this.siteSettings.calendar_categories + .split("|") + .filter(Boolean) + .map((stringSetting) => { + const data = {}; + stringSetting + .split(";") + .filter(Boolean) + .forEach((s) => { + const parts = s.split("="); + data[parts[0]] = parts[1]; + }); + return data; + }); + + return settings.findBy("categoryId", this.category.id.toString()); + } + + @action + formatedEvents(events = []) { + return events.map((event) => { + const { startsAt, endsAt, post, categoryId } = event; + + let backgroundColor; + + if (post.topic.tags) { + const tagColorEntry = this.tagsColorsMap.find( + (entry) => + entry.type === "tag" && post.topic.tags.includes(entry.slug) + ); + backgroundColor = tagColorEntry ? tagColorEntry.color : null; + } + + if (!backgroundColor) { + const categoryColorFromMap = this.tagsColorsMap.find( + (entry) => + entry.type === "category" && entry.slug === post.topic.category_slug + )?.color; + backgroundColor = + categoryColorFromMap || `#${Category.findById(categoryId)?.color}`; + } + + let classNames; + if (moment(endsAt || startsAt).isBefore(moment())) { + classNames = "fc-past-event"; + } + + return { + title: formatEventName(event, this.currentUser?.user_option?.timezone), + start: startsAt, + display: "list-item", + rrule: event.rrule, + end: endsAt || startsAt, + allDay: !isNotFullDayEvent(moment(startsAt), moment(endsAt)), + url: getURL(`/t/-/${post.topic.id}/${post.post_number}`), + backgroundColor, + classNames, + }; + }); + } + + +} diff --git a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/chat-channel.gjs b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/chat-channel.gjs index c1740da8e10..0d725a6d682 100644 --- a/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/chat-channel.gjs +++ b/plugins/discourse-calendar/assets/javascripts/discourse/components/discourse-post-event/chat-channel.gjs @@ -10,7 +10,11 @@ const DiscoursePostEventChatChannel =