munawaba 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +160 -0
  5. data/SECURITY.md +15 -0
  6. data/app/assets/munawaba/INTER-LICENSE.txt +92 -0
  7. data/app/assets/munawaba/JETBRAINS-MONO-LICENSE.txt +93 -0
  8. data/app/assets/munawaba/dashboard.css +272 -0
  9. data/app/assets/munawaba/dashboard.js +60 -0
  10. data/app/assets/munawaba/inter.woff2 +0 -0
  11. data/app/assets/munawaba/jetbrains-mono.woff2 +0 -0
  12. data/app/assets/munawaba/logo.png +0 -0
  13. data/app/controllers/munawaba/application_controller.rb +149 -0
  14. data/app/controllers/munawaba/audit_events_controller.rb +31 -0
  15. data/app/controllers/munawaba/calendars_controller.rb +47 -0
  16. data/app/controllers/munawaba/frontends_controller.rb +28 -0
  17. data/app/controllers/munawaba/notification_deliveries_controller.rb +41 -0
  18. data/app/controllers/munawaba/overrides_controller.rb +52 -0
  19. data/app/controllers/munawaba/overviews_controller.rb +22 -0
  20. data/app/controllers/munawaba/people_controller.rb +73 -0
  21. data/app/controllers/munawaba/rotations_controller.rb +61 -0
  22. data/app/controllers/munawaba/schedules_controller.rb +115 -0
  23. data/app/controllers/munawaba/shifts_controller.rb +16 -0
  24. data/app/controllers/munawaba/slack_integrations_controller.rb +46 -0
  25. data/app/controllers/munawaba/themes_controller.rb +19 -0
  26. data/app/helpers/munawaba/application_helper.rb +208 -0
  27. data/app/jobs/munawaba/application_job.rb +7 -0
  28. data/app/jobs/munawaba/deliver_notification_job.rb +13 -0
  29. data/app/jobs/munawaba/dispatch_due_notifications_job.rb +7 -0
  30. data/app/jobs/munawaba/maintain_projection_job.rb +25 -0
  31. data/app/jobs/munawaba/maintenance_job.rb +28 -0
  32. data/app/models/munawaba/application_record.rb +7 -0
  33. data/app/models/munawaba/audit_event.rb +16 -0
  34. data/app/models/munawaba/notification_delivery.rb +75 -0
  35. data/app/models/munawaba/person.rb +23 -0
  36. data/app/models/munawaba/schedule.rb +85 -0
  37. data/app/models/munawaba/schedule_membership.rb +13 -0
  38. data/app/models/munawaba/shift.rb +51 -0
  39. data/app/models/munawaba/shift_override.rb +31 -0
  40. data/app/services/munawaba/audit/recorder.rb +17 -0
  41. data/app/services/munawaba/canonical.rb +43 -0
  42. data/app/services/munawaba/commands.rb +741 -0
  43. data/app/services/munawaba/conflicts/finder.rb +59 -0
  44. data/app/services/munawaba/integrations/planner_effects.rb +27 -0
  45. data/app/services/munawaba/integrations.rb +93 -0
  46. data/app/services/munawaba/notifications/context/v1.rb +65 -0
  47. data/app/services/munawaba/notifications/deliver.rb +94 -0
  48. data/app/services/munawaba/notifications/dispatcher.rb +50 -0
  49. data/app/services/munawaba/notifications/planner.rb +154 -0
  50. data/app/services/munawaba/notifications/reclaim_expired_leases.rb +27 -0
  51. data/app/services/munawaba/notifications/retry_failed.rb +51 -0
  52. data/app/services/munawaba/notifications/validity.rb +90 -0
  53. data/app/services/munawaba/notifications/wake_dispatcher.rb +17 -0
  54. data/app/services/munawaba/preview.rb +5 -0
  55. data/app/services/munawaba/result.rb +7 -0
  56. data/app/services/munawaba/shifts/normalize_future_timing.rb +172 -0
  57. data/app/services/munawaba/shifts/project.rb +50 -0
  58. data/app/services/munawaba/shifts/projection_plan.rb +89 -0
  59. data/app/services/munawaba/slack/client.rb +105 -0
  60. data/app/services/munawaba/slack/renderer.rb +48 -0
  61. data/app/services/munawaba/slack/webhook_validator.rb +35 -0
  62. data/app/services/munawaba/timing/boundary_calculator.rb +92 -0
  63. data/app/services/munawaba/timing/persisted_interval_verifier.rb +26 -0
  64. data/app/views/layouts/munawaba/application.html.erb +61 -0
  65. data/app/views/munawaba/audit_events/index.html.erb +85 -0
  66. data/app/views/munawaba/calendars/_shift.html.erb +17 -0
  67. data/app/views/munawaba/calendars/show.html.erb +81 -0
  68. data/app/views/munawaba/notification_deliveries/index.html.erb +65 -0
  69. data/app/views/munawaba/overrides/new.html.erb +19 -0
  70. data/app/views/munawaba/overviews/show.html.erb +76 -0
  71. data/app/views/munawaba/people/_form.html.erb +25 -0
  72. data/app/views/munawaba/people/edit.html.erb +2 -0
  73. data/app/views/munawaba/people/index.html.erb +49 -0
  74. data/app/views/munawaba/people/new.html.erb +2 -0
  75. data/app/views/munawaba/people/show.html.erb +34 -0
  76. data/app/views/munawaba/rotations/edit.html.erb +39 -0
  77. data/app/views/munawaba/schedules/_form.html.erb +49 -0
  78. data/app/views/munawaba/schedules/_resume_form.html.erb +24 -0
  79. data/app/views/munawaba/schedules/edit.html.erb +2 -0
  80. data/app/views/munawaba/schedules/index.html.erb +46 -0
  81. data/app/views/munawaba/schedules/new.html.erb +2 -0
  82. data/app/views/munawaba/schedules/resume.html.erb +4 -0
  83. data/app/views/munawaba/schedules/show.html.erb +86 -0
  84. data/app/views/munawaba/shared/_empty.html.erb +6 -0
  85. data/app/views/munawaba/shared/_errors.html.erb +23 -0
  86. data/app/views/munawaba/shared/_navigation.html.erb +8 -0
  87. data/app/views/munawaba/shared/_projection.html.erb +20 -0
  88. data/app/views/munawaba/shared/_proposal_details.html.erb +108 -0
  89. data/app/views/munawaba/shared/_shifts.html.erb +50 -0
  90. data/app/views/munawaba/shared/proposal.html.erb +5 -0
  91. data/app/views/munawaba/shifts/show.html.erb +65 -0
  92. data/app/views/munawaba/slack_integrations/edit.html.erb +48 -0
  93. data/config/routes.rb +45 -0
  94. data/db/migrate/20260905000001_create_munawaba_people.rb +33 -0
  95. data/db/migrate/20260905000002_create_munawaba_schedules.rb +73 -0
  96. data/db/migrate/20260905000003_create_munawaba_schedule_memberships.rb +25 -0
  97. data/db/migrate/20260905000004_create_munawaba_shifts.rb +51 -0
  98. data/db/migrate/20260905000005_create_munawaba_shift_overrides.rb +33 -0
  99. data/db/migrate/20260905000006_create_munawaba_notification_deliveries.rb +83 -0
  100. data/db/migrate/20260905000007_create_munawaba_audit_events.rb +36 -0
  101. data/db/migrate/20260905000008_simplify_munawaba_activity.rb +16 -0
  102. data/lib/generators/munawaba/install_generator.rb +34 -0
  103. data/lib/generators/munawaba/templates/munawaba.rb +23 -0
  104. data/lib/munawaba/configuration.rb +91 -0
  105. data/lib/munawaba/defaults.rb +20 -0
  106. data/lib/munawaba/engine.rb +16 -0
  107. data/lib/munawaba/version.rb +5 -0
  108. data/lib/munawaba.rb +33 -0
  109. metadata +287 -0
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tzinfo"
4
+
5
+ module Munawaba
6
+ module Timing
7
+ # Resolve each handoff from the original local rule so short months and DST
8
+ # adjustments do not shift later handoffs.
9
+ class BoundaryCalculator
10
+ Boundary = Struct.new(:index, :nominal_local_date, :nominal_local_seconds,
11
+ :resolved_at, :resolution, :adjustment_seconds, :utc_offset_seconds, keyword_init: true)
12
+
13
+ def self.timezone(iana) = TZInfo::Timezone.get(iana)
14
+
15
+ def initialize(schedule, provider: Timing::BoundaryCalculator.method(:timezone))
16
+ @schedule = schedule
17
+ @zone = provider.call(schedule.time_zone)
18
+ end
19
+
20
+ def boundary(index)
21
+ raise ArgumentError, "Boundary index must be nonnegative" unless index.is_a?(Integer) && index >= 0
22
+
23
+ anchor = @schedule.anchor_local_date
24
+ date = case @schedule.cadence
25
+ when "one_week" then anchor + (7 * index)
26
+ when "two_weeks" then anchor + (14 * index)
27
+ when "calendar_month" then anchor >> index
28
+ else raise ArgumentError, "Unknown cadence"
29
+ end
30
+ seconds = @schedule.anchor_local_seconds
31
+ local = Time.utc(date.year, date.month, date.day) + seconds
32
+ periods = @zone.periods_for_local(local)
33
+ adjustment = 0
34
+ if periods.any?
35
+ candidates = periods.map { |period| [local - period.utc_total_offset, period.utc_total_offset] }
36
+ resolved, offset = candidates.min_by(&:first)
37
+ resolution = periods.length > 1 ? "ambiguous_earlier" : "exact"
38
+ else
39
+ # A transition's local gap is [UTC + old offset, UTC + new offset).
40
+ transition = @zone.transitions_up_to(local + (3 * 86_400), local - (3 * 86_400)).find do |item|
41
+ old_offset = item.previous_offset.utc_total_offset
42
+ new_offset = item.offset.utc_total_offset
43
+ instant = item.at.to_time.utc
44
+ new_offset > old_offset && local >= instant + old_offset && local < instant + new_offset
45
+ end
46
+ raise ArgumentError, "Timezone provider did not resolve the local gap" unless transition
47
+
48
+ offset = transition.offset.utc_total_offset
49
+ adjustment = offset - transition.previous_offset.utc_total_offset
50
+ resolved = local + adjustment - offset
51
+ resolution = "gap_forward"
52
+ end
53
+ Boundary.new(index: index, nominal_local_date: date, nominal_local_seconds: seconds,
54
+ resolved_at: resolved.utc.freeze, resolution: resolution.freeze,
55
+ adjustment_seconds: adjustment, utc_offset_seconds: offset).freeze
56
+ end
57
+ alias call boundary
58
+
59
+ def slot_at(instant)
60
+ instant = instant.utc
61
+ raise ArgumentError, "Instant precedes the nominal anchor" if instant < boundary(0).resolved_at
62
+
63
+ date = instant.to_date
64
+ anchor = @schedule.anchor_local_date
65
+ index = case @schedule.cadence
66
+ when "one_week" then ((date - anchor).to_i / 7)
67
+ when "two_weeks" then ((date - anchor).to_i / 14)
68
+ when "calendar_month" then ((date.year - anchor.year) * 12) + date.month - anchor.month
69
+ end
70
+ index = [index, 0].max
71
+ index -= 1 while index.positive? && boundary(index).resolved_at > instant
72
+ index += 1 while boundary(index + 1).resolved_at <= instant
73
+ raise ArgumentError,
74
+ "Non-monotonic canonical boundaries" unless boundary(index).resolved_at < boundary(index + 1).resolved_at
75
+
76
+ index
77
+ end
78
+
79
+ def selectable_boundaries(now:, limit: Munawaba.config.calendar_future_limit)
80
+ first = now < boundary(0).resolved_at ? 0 : slot_at(now)
81
+ first += 1 if boundary(first).resolved_at < now
82
+ last_at = now + limit
83
+ values = []
84
+ while (item = boundary(first)).resolved_at <= last_at
85
+ values << item
86
+ first += 1
87
+ end
88
+ values.freeze
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ module Timing
5
+ class PersistedIntervalVerifier
6
+ def self.valid?(shift:, now:, provider: Timing::BoundaryCalculator.method(:timezone))
7
+ return false if shift.canceled_at || shift.coverage_revision != shift.schedule.coverage_revision
8
+
9
+ # Started shifts keep their saved times. Check future shifts with the same
10
+ # boundary and start rules used when preparing a scheduling change.
11
+ if shift.starts_at <= now
12
+ calculator = BoundaryCalculator.new(shift.schedule, provider: provider)
13
+ changed = calculator.boundary(shift.boundary_index + 1).resolved_at != shift.ends_at
14
+ ActiveSupport::Notifications.instrument("timing.frozen_interval_rule_change.munawaba",
15
+ schedule_id: shift.schedule_id, shift_id: shift.id) if changed
16
+ return !changed
17
+ end
18
+ prepared = Shifts::NormalizeFutureTiming.prepare(schedule: shift.schedule, now: now, provider: provider)
19
+ virtual = prepared.slots.find { |slot| slot[:id] == shift.id }
20
+ virtual && virtual[:starts_at] == shift.starts_at && virtual[:ends_at] == shift.ends_at && !prepared.clipping
21
+ rescue ArgumentError, TZInfo::InvalidTimezoneIdentifier
22
+ false
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,61 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="dark light">
7
+ <meta name="turbo-visit-control" content="reload">
8
+ <title><%= content_for?(:title) ? "#{yield(:title)} · Munawaba" : "Munawaba" %></title>
9
+ <%= csrf_meta_tags %>
10
+ <%= csp_meta_tag %>
11
+ <link rel="icon" type="image/png" href="<%= frontend_asset_path(version: Munawaba::VERSION, id: "logo.png") %>">
12
+ <%= stylesheet_link_tag frontend_asset_path(version: Munawaba::VERSION, id: "dashboard.css"), nonce: content_security_policy_nonce %>
13
+ <%= javascript_include_tag frontend_asset_path(version: Munawaba::VERSION, id: "dashboard.js"), defer: true, nonce: content_security_policy_nonce %>
14
+ </head>
15
+ <body>
16
+ <div class="mn-app" data-mn-theme="<%= munawaba_theme %>" data-turbo="false">
17
+ <a href="#mn-main" class="mn-skip-link">Skip to content</a>
18
+ <aside class="mn-sidebar" aria-label="Munawaba navigation">
19
+ <%= link_to root_path, class: "mn-brand" do %>
20
+ <%= image_tag frontend_asset_path(version: Munawaba::VERSION, id: "logo.png"), alt: "", class: "mn-brand-logo", width: 18, height: 18 %>
21
+ <span class="mn-brand-name">munawaba</span>
22
+ <span class="mn-version">v<%= Munawaba::VERSION %></span>
23
+ <% end %>
24
+ <div class="mn-desktop-navigation">
25
+ <%= render "munawaba/shared/navigation" %>
26
+ </div>
27
+ <details class="mn-menu" data-mn-menu>
28
+ <summary>Menu <span aria-hidden="true">☰</span></summary>
29
+ <%= render "munawaba/shared/navigation" %>
30
+ </details>
31
+ <div class="mn-sidebar-footer">
32
+ <span>On-call rotations</span>
33
+ </div>
34
+ </aside>
35
+ <div class="mn-workspace">
36
+ <header class="mn-topbar">
37
+ <nav class="mn-breadcrumbs" aria-label="Breadcrumb">
38
+ <%= link_to "Munawaba", root_path %>
39
+ <span class="mn-breadcrumb-separator" aria-hidden="true">/</span>
40
+ <span aria-current="page"><%= content_for?(:title) ? yield(:title) : "Overview" %></span>
41
+ </nav>
42
+ <% theme_action = munawaba_theme == "dark" ? "Light theme" : "Dark theme" %>
43
+ <%= form_with url: theme_path, method: :patch, local: true, class: "mn-theme-form" do %>
44
+ <%= hidden_field_tag :theme, munawaba_theme == "dark" ? "light" : "dark" %>
45
+ <%= button_tag type: "submit", class: "mn-theme-button", title: theme_action, aria: { label: theme_action } do %>
46
+ <span aria-hidden="true"><%= munawaba_theme == "dark" ? "◐" : "◑" %></span>
47
+ <span class="mn-sr-only"><%= theme_action %></span>
48
+ <% end %>
49
+ <% end %>
50
+ </header>
51
+ <main id="mn-main" class="mn-main" tabindex="-1">
52
+ <% if flash[:notice].present? %>
53
+ <div class="mn-notice" role="status"><%= flash[:notice] %></div>
54
+ <% end %>
55
+ <%= render "munawaba/shared/errors", record: (@person || @schedule || @record) %>
56
+ <%= yield %>
57
+ </main>
58
+ </div>
59
+ </div>
60
+ </body>
61
+ </html>
@@ -0,0 +1,85 @@
1
+ <%= page_title "Activity", "Recent changes to people, schedules, and Slack settings. Times: #{Munawaba.config.organization_time_zone}." %>
2
+
3
+ <%= form_with url: activity_path, method: :get, local: true, class: "mn-filter-bar", id: "mn-form" do %>
4
+ <div>
5
+ <%= label_tag :schedule_id, "Schedule" %>
6
+ <%= select_tag :schedule_id, options_from_collection_for_select(@schedules, :id, :name, params[:schedule_id]), include_blank: "All schedules" %>
7
+ </div>
8
+ <div>
9
+ <%= label_tag :person_id, "Person" %>
10
+ <%= select_tag :person_id, options_from_collection_for_select(@people, :id, :name, params[:person_id]), include_blank: "All people" %>
11
+ </div>
12
+ <div>
13
+ <%= label_tag :event_type, "Event" %>
14
+ <%= text_field_tag :event_type, params[:event_type], placeholder: "rotation.changed" %>
15
+ </div>
16
+ <div>
17
+ <%= label_tag :shift_id, "Shift ID" %>
18
+ <%= number_field_tag :shift_id, params[:shift_id], min: 1 %>
19
+ </div>
20
+ <div>
21
+ <%= label_tag :actor_type, "Actor type" %>
22
+ <%= text_field_tag :actor_type, params[:actor_type] %>
23
+ </div>
24
+ <div>
25
+ <%= label_tag :actor_id, "Actor ID" %>
26
+ <%= text_field_tag :actor_id, params[:actor_id] %>
27
+ </div>
28
+ <div>
29
+ <%= label_tag :from, "From date" %>
30
+ <%= date_field_tag :from, params[:from] %>
31
+ </div>
32
+ <div>
33
+ <%= label_tag :until, "Through date" %>
34
+ <%= date_field_tag :until, params[:until] %>
35
+ </div>
36
+ <%= submit_tag "Filter", class: "mn-button" %>
37
+ <%= link_to "Clear", activity_path %>
38
+ <% end %>
39
+
40
+ <% if @events.empty? %>
41
+ <%= render "munawaba/shared/empty", title: "No activity found", description: "Changes matching these filters will appear here." %>
42
+ <% else %>
43
+ <div class="mn-table-scroll" role="region" aria-label="Activity" tabindex="0">
44
+ <table class="mn-table">
45
+ <thead>
46
+ <tr>
47
+ <th scope="col">When</th>
48
+ <th scope="col">Event</th>
49
+ <th scope="col">Actor</th>
50
+ <th scope="col">Details</th>
51
+ </tr>
52
+ </thead>
53
+ <tbody>
54
+ <% @events.each do |event| %>
55
+ <tr>
56
+ <td class="mn-mono"><%= local_time(event.occurred_at) %></td>
57
+ <td>
58
+ <strong><%= event.event_type.tr("._", " ").humanize %></strong>
59
+ <span class="mn-cell-sub mn-mono"><%= event.event_type %></span>
60
+ </td>
61
+ <td><%= event.actor_name.presence || event.actor_id.presence || "—" %></td>
62
+ <td>
63
+ <details class="mn-event-details">
64
+ <summary>View details</summary>
65
+ <dl class="mn-detail-list">
66
+ <dt>Operation</dt>
67
+ <dd class="mn-mono"><%= event.operation_id %></dd>
68
+ <% event.metadata.each do |key, value| %>
69
+ <dt><%= key.humanize %></dt>
70
+ <dd><%= value.is_a?(Hash) || value.is_a?(Array) ? JSON.pretty_generate(value) : value %></dd>
71
+ <% end %>
72
+ </dl>
73
+ </details>
74
+ </td>
75
+ </tr>
76
+ <% end %>
77
+ </tbody>
78
+ </table>
79
+ </div>
80
+ <% if @more %>
81
+ <div class="mn-pagination">
82
+ <%= link_to "Older activity →", activity_path(request.query_parameters.merge(before: cursor_for(@events.last, :occurred_at))), class: "mn-button" %>
83
+ </div>
84
+ <% end %>
85
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <%= link_to shift_path(shift), class: "mn-calendar-shift" do %>
2
+ <strong><%= shift.schedule.name %></strong>
3
+ <span><%= shift.effective_person.name %></span>
4
+ <span class="mn-mono"><%= local_time(shift.starts_at, @time_zone, format: "%b %-d %H:%M") %> → <%= local_time(shift.ends_at, @time_zone, format: "%b %-d %H:%M") %></span>
5
+ <% if shift.starts_at <= Time.current && shift.ends_at > Time.current %>
6
+ <span>● Current shift</span>
7
+ <% end %>
8
+ <% if shift.effective_person_id != shift.base_person_id %>
9
+ <span>Override</span>
10
+ <% end %>
11
+ <% shift_dst_adjustments(shift).each do |adjustment| %>
12
+ <span class="mn-dst-cue"><%= adjustment %></span>
13
+ <% end %>
14
+ <% if conflicts[shift.id].present? %>
15
+ <span class="mn-conflict">Conflict: <%= conflicts[shift.id].map { |row| row["name"] }.uniq.join(", ") %></span>
16
+ <% end %>
17
+ <% end %>
@@ -0,0 +1,81 @@
1
+ <div class="mn-page-heading">
2
+ <%= page_title "Calendar", "Coverage · #{@time_zone}" %>
3
+ <div class="mn-segment">
4
+ <%= link_to "Agenda", calendar_link(@from, "agenda"), aria: { current: ("page" if @view == "agenda") } %>
5
+ <%= link_to "Month", calendar_link(@from, "month"), aria: { current: ("page" if @view == "month") } %>
6
+ </div>
7
+ </div>
8
+
9
+ <%= form_with url: calendar_path, method: :get, local: true, class: "mn-filter-bar", id: "mn-form" do %>
10
+ <%= hidden_field_tag :view, @view %>
11
+ <div>
12
+ <%= label_tag :schedule_id, "Schedule" %>
13
+ <%= select_tag :schedule_id, options_from_collection_for_select(@schedules, :id, :name, params[:schedule_id]), include_blank: "All schedules" %>
14
+ </div>
15
+ <div>
16
+ <%= label_tag :person_id, "Person" %>
17
+ <%= select_tag :person_id, options_from_collection_for_select(@people, :id, :name, params[:person_id]), include_blank: "All people" %>
18
+ </div>
19
+ <div>
20
+ <%= label_tag :date, "From date" %>
21
+ <%= date_field_tag :date, @from %>
22
+ </div>
23
+ <%= submit_tag "Apply", class: "mn-button" %>
24
+ <%= link_to "Clear", calendar_path %>
25
+ <% end %>
26
+
27
+ <div class="mn-section-heading">
28
+ <h2><%= @view == "month" ? @from.strftime("%B %Y") : "#{@from.strftime('%b %-d')} – #{(@until - 1).strftime('%b %-d, %Y')}" %></h2>
29
+ <div class="mn-actions">
30
+ <%= link_to "← Previous", calendar_link(@previous_date), class: "mn-button" if @previous_date %>
31
+ <%= link_to "Today", calendar_path(request.query_parameters.except("date", "until").merge(date: Time.current.in_time_zone(@time_zone).to_date)), class: "mn-button" %>
32
+ <%= link_to "Next →", calendar_link(@next_date), class: "mn-button" if @next_date %>
33
+ </div>
34
+ </div>
35
+
36
+ <% if @view == "month" && @errors.blank? %>
37
+ <%
38
+ conflicts = conflicts_for(@shifts)
39
+ shift_cards = {}
40
+ %>
41
+ <div class="mn-calendar-scroll" role="region" aria-label="Month calendar, <%= @time_zone %>" tabindex="0">
42
+ <div class="mn-month-grid">
43
+ <% calendar_days.first(7).each do |day| %>
44
+ <div class="mn-weekday"><%= day.strftime("%A") %></div>
45
+ <% end %>
46
+ <% calendar_days.each do |day| %>
47
+ <section class="mn-day <%= 'mn-day-outside' if day.month != @from.month %>">
48
+ <h3><time datetime="<%= day.iso8601 %>"><%= day.day %></time><%= " · Today" if day == Time.current.in_time_zone(@time_zone).to_date %></h3>
49
+ <% day_shifts = shifts_on(day) %>
50
+ <% day_shifts.first(3).each do |shift| %>
51
+ <%= shift_cards[shift.id] ||= render("munawaba/calendars/shift", shift: shift, conflicts: conflicts) %>
52
+ <% end %>
53
+ <% if day_shifts.size > 3 %>
54
+ <details class="mn-day-more">
55
+ <summary><%= day_shifts.size - 3 %> more shifts</summary>
56
+ <% day_shifts.drop(3).each do |shift| %>
57
+ <%= shift_cards[shift.id] ||= render("munawaba/calendars/shift", shift: shift, conflicts: conflicts) %>
58
+ <% end %>
59
+ </details>
60
+ <% end %>
61
+ </section>
62
+ <% end %>
63
+ </div>
64
+ </div>
65
+ <% elsif @errors.blank? %>
66
+ <%
67
+ conflicts = conflicts_for(@shifts)
68
+ weeks = @shifts.group_by do |shift|
69
+ [shift.starts_at.in_time_zone(@time_zone).to_date, @from].max.beginning_of_week(Munawaba.config.week_starts_on)
70
+ end
71
+ %>
72
+ <% if weeks.empty? %>
73
+ <%= render "munawaba/shared/shifts", shifts: [], zone: @time_zone %>
74
+ <% end %>
75
+ <% weeks.each do |week, shifts| %>
76
+ <div class="mn-section-heading">
77
+ <h2>Week of <%= week.strftime("%b %-d, %Y") %></h2>
78
+ </div>
79
+ <%= render "munawaba/shared/shifts", shifts: shifts, zone: @time_zone, conflicts: conflicts %>
80
+ <% end %>
81
+ <% end %>
@@ -0,0 +1,65 @@
1
+ <%= page_title "Slack deliveries", "Times: #{Munawaba.config.organization_time_zone}" %>
2
+ <div class="mn-notice">An unknown outcome means Slack may have received the message. Retrying can send it twice.</div>
3
+
4
+ <%= form_with url: notification_deliveries_path, method: :get, local: true, class: "mn-filter-bar", id: "mn-form" do %>
5
+ <div>
6
+ <%= label_tag :schedule_id, "Schedule" %>
7
+ <%= select_tag :schedule_id, options_from_collection_for_select(@schedules, :id, :name, params[:schedule_id]), include_blank: "All schedules" %>
8
+ </div>
9
+ <div>
10
+ <%= label_tag :status, "Status" %>
11
+ <%= select_tag :status, options_for_select(%w[pending enqueued processing delivered failed canceled stale].map { |status| [status.humanize, status] }, params[:status]), include_blank: "All statuses" %>
12
+ </div>
13
+ <%= submit_tag "Filter", class: "mn-button" %>
14
+ <%= link_to "Clear", notification_deliveries_path %>
15
+ <% end %>
16
+
17
+ <% if @deliveries.empty? %>
18
+ <%= render "munawaba/shared/empty", title: "No deliveries found", description: "Try another filter, or check the schedule's Slack settings." %>
19
+ <% else %>
20
+ <div class="mn-table-scroll" role="region" aria-label="Notification deliveries" tabindex="0">
21
+ <table class="mn-table">
22
+ <thead>
23
+ <tr>
24
+ <th scope="col">Created / schedule</th>
25
+ <th scope="col">Kind</th>
26
+ <th scope="col">Status</th>
27
+ <th scope="col">Attempts</th>
28
+ <th scope="col">Outcome / action</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody>
32
+ <% @deliveries.each do |delivery| %>
33
+ <tr>
34
+ <td class="mn-mono">
35
+ <%= local_time(delivery.created_at) %>
36
+ <span class="mn-cell-sub"><%= link_to delivery.schedule.name, edit_schedule_slack_integration_path(delivery.schedule) %></span>
37
+ </td>
38
+ <td><%= delivery.kind.humanize %></td>
39
+ <td><%= state_badge(delivery.status) %></td>
40
+ <td class="mn-mono"><%= delivery.attempt_count %></td>
41
+ <td>
42
+ <% if delivery.last_error_code == "delivery_outcome_unknown" %>
43
+ <p class="mn-conflict">Unknown outcome · may have reached Slack</p>
44
+ <% end %>
45
+ <span class="mn-mono"><%= delivery.last_error_code.presence || (delivery.delivered_at ? "Delivered #{local_time(delivery.delivered_at)}" : "—") %></span>
46
+ <% if delivery.status == "failed" %>
47
+ <%= form_with url: retry_notification_delivery_path(delivery), local: true do %>
48
+ <% if delivery.last_error_code == "delivery_outcome_unknown" %>
49
+ <label class="mn-checkbox"><%= check_box_tag :acknowledge_duplicate, "1", false, required: true %><span>I accept the risk of a duplicate message.</span></label>
50
+ <% end %>
51
+ <%= submit_tag "Retry failed delivery", class: "mn-button" %>
52
+ <% end %>
53
+ <% end %>
54
+ </td>
55
+ </tr>
56
+ <% end %>
57
+ </tbody>
58
+ </table>
59
+ </div>
60
+ <% if @more %>
61
+ <div class="mn-pagination">
62
+ <%= link_to "Older deliveries →", notification_deliveries_path(request.query_parameters.merge(before: cursor_for(@deliveries.last, :created_at))), class: "mn-button" %>
63
+ </div>
64
+ <% end %>
65
+ <% end %>
@@ -0,0 +1,19 @@
1
+ <%= page_title "Override whole shift", "#{@shift.schedule.name} · #{local_time(@shift.starts_at, @shift.schedule.time_zone)} – #{local_time(@shift.ends_at, @shift.schedule.time_zone)} · #{@shift.schedule.time_zone}" %>
2
+
3
+ <%= form_with url: preview_shift_override_path(@shift), local: true, class: "mn-card mn-form", id: "mn-form" do %>
4
+ <%= hidden_field_tag :operation, "apply" %>
5
+ <%= hidden_field_tag :lock_version, @shift.lock_version %>
6
+ <div class="mn-field">
7
+ <%= label_tag :person_id, "Replacement person (required)" %>
8
+ <%= select_tag :person_id, options_from_collection_for_select(@people, :id, :name, params[:person_id]), required: true, include_blank: "Choose an active person" %>
9
+ <p class="mn-hint">Any active person can cover this shift, including someone outside the roster.</p>
10
+ </div>
11
+ <div class="mn-field">
12
+ <%= label_tag :reason, "Reason (optional)" %>
13
+ <%= text_area_tag :reason, params[:reason], maxlength: 1000, rows: 4, placeholder: "Add context for this change" %>
14
+ </div>
15
+ <div class="mn-form-actions">
16
+ <%= submit_tag "Preview override", class: "mn-button mn-button-primary" %>
17
+ <%= link_to "Cancel", shift_path(@shift), class: "mn-button" %>
18
+ </div>
19
+ <% end %>
@@ -0,0 +1,76 @@
1
+ <div class="mn-overview">
2
+ <div class="mn-page-heading">
3
+ <%= page_title "Overview" %>
4
+ <%= link_to "New schedule", new_schedule_path, class: "mn-button mn-button-primary" %>
5
+ </div>
6
+
7
+ <% if @schedules.empty? %>
8
+ <%= render "munawaba/shared/empty", title: "Your first handoff starts here", description: "Register your people, create a schedule, and set the rotation order.", action: link_to("Add a person", new_person_path, class: "mn-button mn-button-primary") %>
9
+ <% else %>
10
+ <div class="mn-stats">
11
+ <div>
12
+ <span>Schedules</span>
13
+ <strong><%= @schedules.size %></strong>
14
+ </div>
15
+ <div>
16
+ <span>Active</span>
17
+ <strong><%= @schedules.count { |schedule| effective_schedule_state(schedule) == "active" } %></strong>
18
+ </div>
19
+ <div>
20
+ <span>Scheduled</span>
21
+ <strong><%= @schedules.count { |schedule| effective_schedule_state(schedule) == "scheduled" } %></strong>
22
+ </div>
23
+ <div>
24
+ <span>Without a roster</span>
25
+ <strong><%= @schedules.count { |schedule| @roster_counts[schedule.id].to_i.zero? } %></strong>
26
+ </div>
27
+ </div>
28
+ <div class="mn-section-heading">
29
+ <h2 id="mn-overview-schedules">All schedules</h2>
30
+ <span class="mn-muted">Times use each schedule’s timezone</span>
31
+ </div>
32
+ <div class="mn-table-scroll" role="region" aria-labelledby="mn-overview-schedules" tabindex="0">
33
+ <table class="mn-table mn-overview-table">
34
+ <thead>
35
+ <tr>
36
+ <th scope="col">Schedule</th>
37
+ <th scope="col">Cadence</th>
38
+ <th scope="col">State</th>
39
+ <th scope="col">On call now</th>
40
+ <th scope="col">Next</th>
41
+ <th scope="col">Next handoff</th>
42
+ <th scope="col">Scheduled through</th>
43
+ </tr>
44
+ </thead>
45
+ <tbody>
46
+ <% @schedules.each do |schedule| %>
47
+ <%
48
+ display_state = effective_schedule_state(schedule)
49
+ shifts = display_state == "paused" ? [] : @shifts.fetch(schedule.id, [])
50
+ current = shifts.find { |shift| shift.starts_at <= @now }
51
+ upcoming = shifts.find { |shift| shift.starts_at > @now }
52
+ %>
53
+ <tr>
54
+ <td class="mn-overview-schedule">
55
+ <%= link_to schedule.name, schedule_path(schedule), class: "mn-primary-text" %>
56
+ <span class="mn-cell-sub mn-mono"><%= schedule.time_zone %></span>
57
+ <% if (warning = lifecycle_maintenance_warning(schedule)) %>
58
+ <span class="mn-conflict mn-maintenance-warning"><%= warning %></span>
59
+ <% end %>
60
+ <% if @roster_counts[schedule.id].to_i.zero? %>
61
+ <span class="mn-conflict">Add people to complete this roster.</span>
62
+ <% end %>
63
+ </td>
64
+ <td><%= cadence_name(schedule) %></td>
65
+ <td><%= state_badge(display_state) %></td>
66
+ <td class="mn-overview-person"><span class="<%= current ? "mn-primary-text" : "mn-muted" %>"><%= current&.effective_person&.name || "No current coverage" %></span></td>
67
+ <td class="mn-overview-person"><%= upcoming&.effective_person&.name || "—" %></td>
68
+ <td class="mn-mono"><%= local_time(current&.ends_at || upcoming&.starts_at, schedule.time_zone) %></td>
69
+ <td class="mn-mono"><%= local_time(display_state == "paused" ? nil : @projected_through[schedule.id], schedule.time_zone) %></td>
70
+ </tr>
71
+ <% end %>
72
+ </tbody>
73
+ </table>
74
+ </div>
75
+ <% end %>
76
+ </div>
@@ -0,0 +1,25 @@
1
+ <%= form_with model: @person, url: @person.persisted? ? person_path(@person) : people_path, local: true, class: "mn-form mn-card", id: "mn-form" do |form| %>
2
+ <% if @person.persisted? %>
3
+ <%= form.hidden_field :lock_version %>
4
+ <% end %>
5
+ <div class="mn-field">
6
+ <%= form.label :name, "Display name (required)" %>
7
+ <%= form.text_field :name, required: true, maxlength: 120, autocomplete: "name", autofocus: @errors.blank? %>
8
+ <%= model_errors(@person, :name) %>
9
+ </div>
10
+ <div class="mn-field">
11
+ <%= form.label :email, "Organization email (required)" %>
12
+ <%= form.email_field :email, required: true, maxlength: 320, autocomplete: "email" %>
13
+ <%= model_errors(@person, :email) %>
14
+ </div>
15
+ <div class="mn-field">
16
+ <%= form.label :slack_member_id, "Slack member ID (optional)" %>
17
+ <%= form.text_field :slack_member_id, maxlength: 32, placeholder: "U012ABC34", spellcheck: false, autocomplete: "off", aria: { describedby: "mn-slack-id-hint" } %>
18
+ <p id="mn-slack-id-hint" class="mn-hint">For Slack mentions. Without an ID, messages use the person’s display name.</p>
19
+ <%= model_errors(@person, :slack_member_id) %>
20
+ </div>
21
+ <div class="mn-form-actions">
22
+ <%= form.submit @person.persisted? ? "Save person" : "Add person", class: "mn-button mn-button-primary" %>
23
+ <%= link_to "Cancel", @person.persisted? ? person_path(@person) : people_path, class: "mn-button" %>
24
+ </div>
25
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <%= page_title "Edit #{@person.name}" %>
2
+ <%= render "form" %>
@@ -0,0 +1,49 @@
1
+ <div class="mn-page-heading">
2
+ <%= page_title "People", "Register people once. Include them in any number of schedules." %>
3
+ <%= link_to "Add person", new_person_path, class: "mn-button mn-button-primary" %>
4
+ </div>
5
+
6
+ <%= form_with url: people_path, method: :get, local: true, class: "mn-filter-bar" do %>
7
+ <div>
8
+ <%= label_tag :q, "Search people" %>
9
+ <%= search_field_tag :q, params[:q], placeholder: "Name or email" %>
10
+ </div>
11
+ <div>
12
+ <%= label_tag :state, "Status" %>
13
+ <%= select_tag :state, options_for_select([["All people", ""], ["Active", "active"], ["Inactive", "inactive"]], params[:state]) %>
14
+ </div>
15
+ <%= submit_tag "Filter", class: "mn-button" %>
16
+ <%= link_to "Clear", people_path %>
17
+ <% end %>
18
+
19
+ <% if @people.empty? %>
20
+ <%= render "munawaba/shared/empty", title: "No people found", description: "Add a person to start building your rotations." %>
21
+ <% else %>
22
+ <div class="mn-table-scroll" role="region" aria-label="People" tabindex="0">
23
+ <table class="mn-table">
24
+ <thead>
25
+ <tr>
26
+ <th scope="col">Person</th>
27
+ <th scope="col">Email</th>
28
+ <th scope="col">Schedules</th>
29
+ <th scope="col">Status</th>
30
+ </tr>
31
+ </thead>
32
+ <tbody>
33
+ <% @people.each do |person| %>
34
+ <tr>
35
+ <td><%= link_to person.name, person_path(person), class: "mn-primary-text" %></td>
36
+ <td><%= person.email %></td>
37
+ <td class="mn-mono"><%= @membership_counts[person.id].to_i %></td>
38
+ <td><%= state_badge(person.active? ? "active" : "inactive") %></td>
39
+ </tr>
40
+ <% end %>
41
+ </tbody>
42
+ </table>
43
+ </div>
44
+ <% if @more %>
45
+ <div class="mn-pagination">
46
+ <%= link_to "More people →", people_path(request.query_parameters.merge(after: name_cursor_for(@people.last))), class: "mn-button" %>
47
+ </div>
48
+ <% end %>
49
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <%= page_title "Add person" %>
2
+ <%= render "form" %>
@@ -0,0 +1,34 @@
1
+ <div class="mn-page-heading">
2
+ <%= page_title @person.name, @person.email %>
3
+ <div class="mn-actions">
4
+ <%= state_badge(@person.active? ? "active" : "inactive") %>
5
+ <%= link_to "Edit person", edit_person_path(@person), class: "mn-button" %>
6
+ </div>
7
+ </div>
8
+
9
+ <div class="mn-card mn-compact-card">
10
+ <dl class="mn-detail-list">
11
+ <dt>Slack member ID</dt>
12
+ <dd class="mn-mono"><%= @person.slack_member_id.presence || "Not provided" %></dd>
13
+ <dt>Schedules</dt>
14
+ <dd><%= safe_join(@memberships.map { |membership| link_to membership.schedule.name, schedule_path(membership.schedule) }, ", ").presence || "No memberships" %></dd>
15
+ </dl>
16
+ <div class="mn-actions">
17
+ <%= link_to "View activity", activity_path(person_id: @person.id) %>
18
+ <%= link_to "View calendar", calendar_path(person_id: @person.id) %>
19
+ <% if @person.active? %>
20
+ <%= link_to "Deactivate person", deactivation_person_path(@person), class: "mn-danger-link" %>
21
+ <% else %>
22
+ <%= button_to "Reactivate person", reactivate_person_path(@person), method: :patch, params: { lock_version: @person.lock_version }, class: "mn-button" %>
23
+ <% end %>
24
+ </div>
25
+ <% unless @person.active? %>
26
+ <p class="mn-hint">Reactivation makes this person selectable again. Former memberships are not restored.</p>
27
+ <% end %>
28
+ </div>
29
+
30
+ <div class="mn-section-heading">
31
+ <h2>Current and upcoming assignments</h2>
32
+ <span class="mn-muted"><%= Munawaba.config.organization_time_zone %></span>
33
+ </div>
34
+ <%= render "munawaba/shared/shifts", shifts: @shifts, zone: Munawaba.config.organization_time_zone %>