Skip to content

Commit 3faa243

Browse files
committed
Add personal calendar feature with event management and localization support
1 parent 784866a commit 3faa243

File tree

9 files changed

+257
-2
lines changed

9 files changed

+257
-2
lines changed

app/models/better_together/event.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ def significant_changes_for_notifications
136136
changes_to_check.keys & significant_attrs
137137
end
138138

139+
def start_time
140+
starts_at
141+
end
142+
143+
def end_time
144+
ends_at
145+
end
146+
139147
# Check if event has location
140148
def location?
141149
location.present?

app/models/better_together/event_attendance.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ class EventAttendance < ApplicationRecord
1515
validates :event_id, uniqueness: { scope: :person_id }
1616
validate :event_must_be_scheduled
1717

18+
after_save :manage_calendar_entry
19+
after_destroy :remove_calendar_entry
20+
1821
private
1922

2023
def event_must_be_scheduled
@@ -24,5 +27,37 @@ def event_must_be_scheduled
2427

2528
errors.add(:event, 'must be scheduled to allow RSVPs')
2629
end
30+
31+
def manage_calendar_entry
32+
return unless saved_change_to_status? || saved_change_to_id?
33+
34+
if status == 'going'
35+
create_calendar_entry
36+
else
37+
remove_calendar_entry
38+
end
39+
end
40+
41+
def create_calendar_entry
42+
return if calendar_entry_exists?
43+
44+
person.primary_calendar.calendar_entries.create!(
45+
event: event,
46+
starts_at: event.starts_at,
47+
ends_at: event.ends_at,
48+
duration_minutes: event.duration_minutes
49+
)
50+
rescue ActiveRecord::RecordInvalid => e
51+
Rails.logger.warn "Failed to create calendar entry for attendance #{id}: #{e.message}"
52+
end
53+
54+
def remove_calendar_entry
55+
calendar_entry = person.primary_calendar.calendar_entries.find_by(event: event)
56+
calendar_entry&.destroy
57+
end
58+
59+
def calendar_entry_exists?
60+
person.primary_calendar.calendar_entries.exists?(event: event)
61+
end
2762
end
2863
end

app/models/better_together/person.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ def self.primary_community_delegation_attrs
4545
has_many :agreement_participants, class_name: 'BetterTogether::AgreementParticipant', dependent: :destroy
4646
has_many :agreements, through: :agreement_participants
4747

48+
has_many :calendars, foreign_key: :creator_id, class_name: 'BetterTogether::Calendar', dependent: :destroy
49+
has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', dependent: :destroy
50+
4851
has_one :user_identification,
4952
lambda {
5053
where(
@@ -142,6 +145,17 @@ def primary_community_extra_attrs
142145
{ protected: true }
143146
end
144147

148+
def primary_calendar
149+
@primary_calendar ||= calendars.find_or_create_by(
150+
identifier: "#{identifier}-personal-calendar",
151+
community:
152+
) do |calendar|
153+
calendar.name = I18n.t('better_together.calendars.personal_calendar_name', name: name)
154+
calendar.privacy = 'private'
155+
calendar.protected = true
156+
end
157+
end
158+
145159
def after_record_created
146160
return unless community
147161

app/policies/better_together/calendar_policy.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,31 @@ def index?
88
end
99

1010
def show?
11-
user.present?
11+
user.present? && (can_view_calendar? || permitted_to?('manage_platform'))
1212
end
1313

1414
def update?
15-
user.present? && (record.creator == agent or permitted_to?('manage_platform'))
15+
user.present? && (record.creator == agent || permitted_to?('manage_platform'))
1616
end
1717

1818
def create?
1919
user.present? && permitted_to?('manage_platform')
2020
end
2121

22+
private
23+
24+
def can_view_calendar?
25+
return true if record.privacy_public?
26+
return true if record.privacy_community? && same_community?
27+
return true if record.creator == agent
28+
29+
false
30+
end
31+
32+
def same_community?
33+
agent&.community_id == record.community_id
34+
end
35+
2236
# Filtering and sorting for calendars according to permissions and context
2337
class Scope < ApplicationPolicy::Scope
2438
end
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<div class="calendar-section">
2+
<h6 class="mb-3">
3+
<i class="fas fa-calendar me-2" aria-hidden="true"></i>
4+
<%= t('better_together.people.calendar.title', default: 'Personal Calendar') %>
5+
</h6>
6+
7+
<% if person.primary_calendar.calendar_entries.any? %>
8+
<!-- Simple Calendar View -->
9+
<div class="calendar-container">
10+
<%= month_calendar(events: person.primary_calendar.events.includes(:string_translations)) do |date, events| %>
11+
<% events.each do |event| %>
12+
<div class="calendar-event">
13+
<%= link_to event, class: 'text-decoration-none' do %>
14+
<small class="d-block text-truncate" title="<%= event.name %>">
15+
<i class="fas fa-circle me-1" style="color: #007bff; font-size: 0.5rem;" aria-hidden="true"></i>
16+
<%= event.name %>
17+
</small>
18+
<% end %>
19+
</div>
20+
<% end %>
21+
<% end %>
22+
</div>
23+
24+
<!-- Upcoming Events List -->
25+
<div class="upcoming-events mt-4">
26+
<h6 class="mb-3">
27+
<i class="fas fa-clock me-2" aria-hidden="true"></i>
28+
<%= t('better_together.people.calendar.upcoming_events', default: 'Upcoming Events') %>
29+
</h6>
30+
31+
<% upcoming_events = person.primary_calendar.events.upcoming.includes(:string_translations).limit(5) %>
32+
<% if upcoming_events.any? %>
33+
<div class="list-group">
34+
<% upcoming_events.each do |event| %>
35+
<div class="list-group-item d-flex justify-content-between align-items-start">
36+
<div class="ms-2 me-auto">
37+
<%= link_to event, class: 'text-decoration-none' do %>
38+
<div class="fw-bold"><%= event.name %></div>
39+
<small class="text-muted">
40+
<i class="fas fa-calendar-alt me-1" aria-hidden="true"></i>
41+
<%= l(event.starts_at, format: :long) if event.starts_at %>
42+
</small>
43+
<% end %>
44+
</div>
45+
<span class="badge bg-primary rounded-pill">
46+
<%= privacy_display_value(event) %>
47+
</span>
48+
</div>
49+
<% end %>
50+
</div>
51+
<% else %>
52+
<p class="text-muted">
53+
<i class="fas fa-info-circle me-2" aria-hidden="true"></i>
54+
<%= t('better_together.people.calendar.no_upcoming_events', default: 'No upcoming events.') %>
55+
</p>
56+
<% end %>
57+
</div>
58+
59+
<!-- Past Events (Limited) -->
60+
<div class="past-events mt-4">
61+
<h6 class="mb-3">
62+
<i class="fas fa-history me-2" aria-hidden="true"></i>
63+
<%= t('better_together.people.calendar.recent_past_events', default: 'Recent Past Events') %>
64+
</h6>
65+
66+
<% past_events = person.primary_calendar.events.past.includes(:string_translations).order(starts_at: :desc).limit(3) %>
67+
<% if past_events.any? %>
68+
<div class="list-group">
69+
<% past_events.each do |event| %>
70+
<div class="list-group-item d-flex justify-content-between align-items-start">
71+
<div class="ms-2 me-auto">
72+
<%= link_to event, class: 'text-decoration-none text-muted' do %>
73+
<div class="fw-bold"><%= event.name %></div>
74+
<small class="text-muted">
75+
<i class="fas fa-calendar-alt me-1" aria-hidden="true"></i>
76+
<%= l(event.starts_at, format: :long) if event.starts_at %>
77+
</small>
78+
<% end %>
79+
</div>
80+
<span class="badge bg-secondary rounded-pill">
81+
<%= t('better_together.people.calendar.attended', default: 'Attended') %>
82+
</span>
83+
</div>
84+
<% end %>
85+
</div>
86+
<% else %>
87+
<p class="text-muted">
88+
<i class="fas fa-info-circle me-2" aria-hidden="true"></i>
89+
<%= t('better_together.people.calendar.no_past_events', default: 'No past events attended.') %>
90+
</p>
91+
<% end %>
92+
</div>
93+
<% else %>
94+
<!-- Empty State -->
95+
<div class="text-center py-5">
96+
<i class="fas fa-calendar-times fa-3x text-muted mb-3" aria-hidden="true"></i>
97+
<h6 class="text-muted">
98+
<%= t('better_together.people.calendar.no_events', default: 'No events in calendar') %>
99+
</h6>
100+
<p class="text-muted">
101+
<%= t('better_together.people.calendar.no_events_description',
102+
default: 'Events will appear here when you RSVP as "Going" to events.') %>
103+
</p>
104+
</div>
105+
<% end %>
106+
</div>
107+
108+
<style>
109+
.calendar-event {
110+
margin: 2px 0;
111+
}
112+
113+
.calendar-event small {
114+
font-size: 0.7rem;
115+
line-height: 1.2;
116+
}
117+
118+
.calendar-container .simple-calendar {
119+
font-size: 0.9rem;
120+
}
121+
122+
.calendar-container .simple-calendar td {
123+
height: 100px;
124+
vertical-align: top;
125+
padding: 5px;
126+
}
127+
128+
.calendar-day {
129+
font-weight: bold;
130+
margin-bottom: 3px;
131+
}
132+
</style>

app/views/better_together/people/show.html.erb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@
5353
<%= content_tag :div, id: 'profileTabs', class: 'nav nav-tabs card-header-tabs', role: 'tablist', aria_label: 'Profile Sections' do %>
5454
<%= link_to t('globals.tabs.about'), '#about', class: 'nav-link active', id: 'about-tab', data: { bs_toggle: 'tab', bs_target: '#about' }, role: 'tab', aria_controls: 'about', aria_selected: 'true', tabindex: '0' %>
5555
<%= link_to t('globals.tabs.contact'), '#contact', class: 'nav-link', id: 'contact-tab', data: { bs_toggle: 'tab', bs_target: '#contact', bs_parent: '#profileSections' }, role: 'tab', aria_controls: 'contact', aria_selected: 'true', tabindex: '-1' %>
56+
<% if current_person == @person || current_person&.permitted_to?('manage_platform') %>
57+
<%= link_to BetterTogether::Calendar.model_name.human,
58+
'#calendar',
59+
class: 'nav-link',
60+
id: 'calendar-tab',
61+
data: { bs_toggle: 'tab', bs_target: '#calendar' },
62+
role: 'tab',
63+
aria_controls: 'calendar',
64+
aria_selected: 'false',
65+
tabindex: '-1',
66+
title: "#{BetterTogether::Calendar.model_name.human} for #{@person.name}",
67+
aria_label: "#{BetterTogether::Calendar.model_name.human} for #{@person.name}" %>
68+
<% end %>
5669
<%= render partial: 'better_together/people/extra_person_tabs', locals: { person: @person } %>
5770
<% if @person.authored_pages.any? %>
5871
<%= link_to BetterTogether::Page.model_name.human.pluralize,
@@ -103,6 +116,15 @@
103116
</div>
104117
</section>
105118

119+
<!-- Person Calendar Section -->
120+
<% if current_person == @person || current_person&.permitted_to?('manage_platform') %>
121+
<section id="calendar" class="row collapse" aria-labelledby="calendar-tab" aria-expanded="false">
122+
<div class="col-md-12">
123+
<%= render partial: 'better_together/people/calendar_section', locals: { person: @person } %>
124+
</div>
125+
</section>
126+
<% end %>
127+
106128
<%= render partial: 'better_together/people/extra_person_tab_contents', locals: { person: @person } %>
107129
<% if @authored_pages.any? %>
108130
<div id="pages"

config/locales/en.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,7 @@ en:
625625
title: Buildings
626626
calendars:
627627
default_description: Default calendar for %<community_name>s
628+
personal_calendar_name: "%{name}'s Personal Calendar"
628629
calls_for_interest:
629630
back_to_calls_for_interest: Back to calls for interest
630631
hints:
@@ -1243,6 +1244,15 @@ en:
12431244
new_page: New page
12441245
people:
12451246
allow_messages_from_members: Allow messages from platform members
1247+
calendar:
1248+
attended: Attended
1249+
no_events: No events in calendar
1250+
no_events_description: Events will appear here when you RSVP as "Going" to events.
1251+
no_past_events: No past events attended.
1252+
no_upcoming_events: No upcoming events.
1253+
recent_past_events: Recent Past Events
1254+
title: Personal Calendar
1255+
upcoming_events: Upcoming Events
12461256
device_permissions:
12471257
camera: Camera
12481258
location: Location

config/locales/es.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,7 @@ es:
628628
title: Edificios
629629
calendars:
630630
default_description: Calendario predeterminado para %<community_name>s
631+
personal_calendar_name: "Calendario Personal de %{name}"
631632
calls_for_interest:
632633
back_to_calls_for_interest: Volver a convocatorias de interés
633634
hints:
@@ -1248,6 +1249,15 @@ es:
12481249
new_page: Nueva página
12491250
people:
12501251
allow_messages_from_members: Permitir mensajes de miembros de la plataforma
1252+
calendar:
1253+
attended: Asistido
1254+
no_events: No hay eventos en el calendario
1255+
no_events_description: Los eventos aparecerán aquí cuando confirmes asistencia como "Voy" a eventos.
1256+
no_past_events: No hay eventos pasados a los que hayas asistido.
1257+
no_upcoming_events: No hay eventos próximos.
1258+
recent_past_events: Eventos Pasados Recientes
1259+
title: Calendario Personal
1260+
upcoming_events: Eventos Próximos
12511261
device_permissions:
12521262
camera: Cámara
12531263
location: Ubicación

config/locales/fr.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,7 @@ fr:
632632
title: Bâtiments
633633
calendars:
634634
default_description: Default calendar for %<community_name>s
635+
personal_calendar_name: "Calendrier Personnel de %{name}"
635636
calls_for_interest:
636637
back_to_calls_for_interest: Retour aux appels à intérêt
637638
hints:
@@ -1255,6 +1256,15 @@ fr:
12551256
new_page: Nouvelle page
12561257
people:
12571258
allow_messages_from_members: Autoriser les messages des membres de la plateforme
1259+
calendar:
1260+
attended: Assisté
1261+
no_events: Aucun événement dans le calendrier
1262+
no_events_description: Les événements apparaîtront ici lorsque vous confirmerez votre participation comme "J'y vais" aux événements.
1263+
no_past_events: Aucun événement passé auquel vous avez assisté.
1264+
no_upcoming_events: Aucun événement à venir.
1265+
recent_past_events: Événements Passés Récents
1266+
title: Calendrier Personnel
1267+
upcoming_events: Événements à Venir
12581268
device_permissions:
12591269
camera: Caméra
12601270
location: Localisation

0 commit comments

Comments
 (0)