diff --git a/app/controllers/role_assignments_controller.rb b/app/controllers/role_assignments_controller.rb
new file mode 100644
index 00000000..b3b0d5c9
--- /dev/null
+++ b/app/controllers/role_assignments_controller.rb
@@ -0,0 +1,40 @@
+class RoleAssignmentsController < ApplicationController
+ before_action :set_role, only: [ :new, :create ]
+ before_action :set_assignment, only: [ :destroy ]
+
+ def new
+ @assignment = @role.role_assignments.new
+ @users = User.all
+ end
+
+ def create
+ @assignment = @role.role_assignments.new(assignment_params)
+
+ if @assignment.save
+ redirect_to role_url(@role), notice: "#{@assignment.user.name} assigned as #{@assignment.assignment_type.humanize}."
+ else
+ @users = User.all
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ role = @assignment.role
+ @assignment.destroy
+ redirect_to role_url(role), notice: "Assignment removed."
+ end
+
+ private
+
+ def set_role
+ @role = Role.find(params[:role_id])
+ end
+
+ def set_assignment
+ @assignment = RoleAssignment.find(params[:id])
+ end
+
+ def assignment_params
+ params.require(:role_assignment).permit(:user_id, :assignment_type, :starts_at, :ends_at)
+ end
+end
diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb
new file mode 100644
index 00000000..2581b8e8
--- /dev/null
+++ b/app/controllers/roles_controller.rb
@@ -0,0 +1,54 @@
+class RolesController < ApplicationController
+ before_action :set_role, only: [ :show, :edit, :update, :destroy ]
+
+ def index
+ @roles_by_group = Role.includes(:role_assignments, :users).group_by(&:group)
+ end
+
+ def show
+ @assignments = @role.role_assignments.active_assignments.includes(:user)
+ @tasks = @role.tasks.limit(20)
+ @time_entries = @role.time_entries.recent.limit(10)
+ @templates = @role.recurring_task_templates
+ end
+
+ def new
+ @role = Role.new
+ end
+
+ def create
+ @role = Role.new(role_params)
+
+ if @role.save
+ redirect_to role_url(@role), notice: "Role was successfully created."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @role.update(role_params)
+ redirect_to role_url(@role), notice: "Role was successfully updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @role.discard
+ redirect_to roles_url, notice: "Role was deleted."
+ end
+
+ private
+
+ def set_role
+ @role = Role.find(params[:id])
+ end
+
+ def role_params
+ params.require(:role).permit(:title, :duties, :description, :group, :role_type, :term_length_months)
+ end
+end
diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb
index 0deff2dc..90f507ce 100644
--- a/app/controllers/tasks_controller.rb
+++ b/app/controllers/tasks_controller.rb
@@ -170,7 +170,7 @@ def set_users
end
def task_params
- params.require(:task).permit(:title, :description, :status, :assigned_to_user_id, :due_date)
+ params.require(:task).permit(:title, :description, :status, :assigned_to_user_id, :due_date, :role_id)
end
def reorder_pending_tasks
diff --git a/app/controllers/time_entries_controller.rb b/app/controllers/time_entries_controller.rb
new file mode 100644
index 00000000..63e57acc
--- /dev/null
+++ b/app/controllers/time_entries_controller.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class TimeEntriesController < ApplicationController
+ before_action :authenticate_user!
+ before_action :set_role, only: [ :new, :create ]
+
+ def index
+ @time_entries = TimeEntry.for_user(current_user).recent.limit(50)
+ @monthly_by_role = TimeEntry.for_user(current_user)
+ .for_month(Date.current.year, Date.current.month)
+ .group(:role_id)
+ .sum(:hours)
+ @roles = Role.where(id: @monthly_by_role.keys)
+ end
+
+ def new
+ @time_entry = TimeEntry.new(role: @role, logged_on: Date.current, entry_type: "reconciliation")
+ @tasks = @role.tasks.where(status: %w[active backlog])
+ end
+
+ def create
+ @time_entry = TimeEntry.new(time_entry_params)
+ @time_entry.user = current_user
+ @time_entry.role = @role
+
+ if @time_entry.save
+ redirect_to role_url(@role), notice: "Time logged: #{@time_entry.hours} hours."
+ else
+ @tasks = @role.tasks.where(status: %w[active backlog])
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @time_entry = TimeEntry.find(params[:id])
+ @time_entry.destroy
+ redirect_to time_entries_url, notice: "Time entry removed."
+ end
+
+ private
+
+ def set_role
+ @role = Role.find(params[:role_id])
+ end
+
+ def time_entry_params
+ params.require(:time_entry).permit(:hours, :logged_on, :entry_type, :task_id, :note)
+ end
+end
diff --git a/app/jobs/generate_recurring_tasks_job.rb b/app/jobs/generate_recurring_tasks_job.rb
new file mode 100644
index 00000000..338b2994
--- /dev/null
+++ b/app/jobs/generate_recurring_tasks_job.rb
@@ -0,0 +1,14 @@
+class GenerateRecurringTasksJob < ApplicationJob
+ queue_as :default
+
+ def perform
+ RecurringTaskTemplate.find_each do |template|
+ next unless template.due_for_generation?
+
+ holder = template.role.current_holders.first
+ next unless holder
+
+ template.generate_task!(holder.user)
+ end
+ end
+end
diff --git a/app/models/recurring_task_template.rb b/app/models/recurring_task_template.rb
new file mode 100644
index 00000000..6e115ee1
--- /dev/null
+++ b/app/models/recurring_task_template.rb
@@ -0,0 +1,38 @@
+class RecurringTaskTemplate < ApplicationRecord
+ belongs_to :role
+
+ FREQUENCIES = %w[daily weekly biweekly monthly quarterly].freeze
+
+ validates :title, presence: true
+ validates :frequency, presence: true, inclusion: { in: FREQUENCIES }
+
+ def generate_task!(assigned_user)
+ task = Task.create!(
+ title: title,
+ description: description,
+ role: role,
+ user: assigned_user,
+ assigned_to_user: auto_assign_to_holder ? assigned_user : nil,
+ status: auto_assign_to_holder ? "active" : "backlog"
+ )
+ update!(last_generated_at: Date.current)
+ task
+ end
+
+ def due_for_generation?
+ return true if last_generated_at.nil?
+
+ case frequency
+ when "daily"
+ last_generated_at < Date.current
+ when "weekly"
+ last_generated_at < 1.week.ago.to_date
+ when "biweekly"
+ last_generated_at < 2.weeks.ago.to_date
+ when "monthly"
+ last_generated_at < 1.month.ago.to_date
+ when "quarterly"
+ last_generated_at < 3.months.ago.to_date
+ end
+ end
+end
diff --git a/app/models/role.rb b/app/models/role.rb
new file mode 100644
index 00000000..3ab96271
--- /dev/null
+++ b/app/models/role.rb
@@ -0,0 +1,40 @@
+class Role < ApplicationRecord
+ include Discardable
+ has_paper_trail
+
+ acts_as_tenant :community
+
+ GROUPS = %w[hoa_officers garden facilities community].freeze
+ ROLE_TYPES = %w[role committee].freeze
+
+ has_many :role_assignments, dependent: :destroy
+ has_many :users, through: :role_assignments
+ has_many :tasks, dependent: :nullify
+ has_many :time_entries, dependent: :destroy
+ has_many :recurring_task_templates, dependent: :destroy
+
+ validates :title, presence: true, uniqueness: { scope: :community_id }
+ validates :role_type, presence: true, inclusion: { in: ROLE_TYPES }
+ validates :group, inclusion: { in: GROUPS }, allow_blank: true
+
+ scope :roles, -> { where(role_type: "role") }
+ scope :committees, -> { where(role_type: "committee") }
+ scope :in_group, ->(group) { where(group: group) }
+ scope :vacant_roles, -> { where(vacant: true) }
+ scope :filled, -> { where(vacant: false) }
+ scope :ordered, -> { order(:group, :title) }
+
+ default_scope { ordered }
+
+ def current_holders
+ role_assignments.active_assignments.holders
+ end
+
+ def current_backup
+ role_assignments.active_assignments.backups.first
+ end
+
+ def update_vacancy!
+ update_column(:vacant, role_assignments.active_assignments.holders.none?)
+ end
+end
diff --git a/app/models/role_assignment.rb b/app/models/role_assignment.rb
new file mode 100644
index 00000000..75105946
--- /dev/null
+++ b/app/models/role_assignment.rb
@@ -0,0 +1,34 @@
+class RoleAssignment < ApplicationRecord
+ has_paper_trail
+
+ belongs_to :role
+ belongs_to :user
+
+ ASSIGNMENT_TYPES = %w[holder backup co_holder].freeze
+
+ validates :assignment_type, presence: true, inclusion: { in: ASSIGNMENT_TYPES }
+ validates :starts_at, presence: true
+
+ after_save :update_role_vacancy
+ after_destroy :update_role_vacancy
+
+ scope :active_assignments, -> { where(active: true) }
+ scope :holders, -> { where(assignment_type: "holder") }
+ scope :backups, -> { where(assignment_type: "backup") }
+ scope :co_holders, -> { where(assignment_type: "co_holder") }
+ scope :expiring_soon, -> { where(active: true).where("ends_at <= ?", 30.days.from_now.to_date) }
+
+ def expired?
+ ends_at.present? && ends_at < Date.current
+ end
+
+ def expiring_soon?
+ ends_at.present? && ends_at <= 30.days.from_now.to_date && ends_at >= Date.current
+ end
+
+ private
+
+ def update_role_vacancy
+ role.update_vacancy!
+ end
+end
diff --git a/app/models/task.rb b/app/models/task.rb
index 3f221080..2a94be03 100644
--- a/app/models/task.rb
+++ b/app/models/task.rb
@@ -5,6 +5,7 @@ class Task < ApplicationRecord
belongs_to :user
belongs_to :assigned_to_user, class_name: "User", optional: true
+ belongs_to :role, optional: true
validates :title, presence: true
validates :status, presence: true, inclusion: { in: %w[backlog active completed] }
diff --git a/app/models/time_entry.rb b/app/models/time_entry.rb
new file mode 100644
index 00000000..6c0293d1
--- /dev/null
+++ b/app/models/time_entry.rb
@@ -0,0 +1,22 @@
+class TimeEntry < ApplicationRecord
+ belongs_to :user
+ belongs_to :task, optional: true
+ belongs_to :role, optional: true
+
+ ENTRY_TYPES = %w[task reconciliation].freeze
+
+ validates :hours, presence: true, numericality: { greater_than: 0 }
+ validates :logged_on, presence: true
+ validates :entry_type, presence: true, inclusion: { in: ENTRY_TYPES }
+
+ scope :task_entries, -> { where(entry_type: "task") }
+ scope :reconciliation_entries, -> { where(entry_type: "reconciliation") }
+ scope :for_month, ->(year, month) {
+ start_date = Date.new(year, month, 1)
+ end_date = start_date.end_of_month
+ where(logged_on: start_date..end_date)
+ }
+ scope :for_user, ->(user) { where(user: user) }
+ scope :for_role, ->(role) { where(role: role) }
+ scope :recent, -> { order(logged_on: :desc) }
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index b99e0d64..65914993 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -47,6 +47,10 @@ class User < ApplicationRecord
has_many :meal_rsvps, dependent: :destroy
has_many :rsvped_meals, through: :meal_rsvps, source: :meal
+ # Roles
+ has_many :role_assignments, dependent: :destroy
+ has_many :roles, through: :role_assignments
+
# Notifications
has_many :push_subscriptions, dependent: :destroy
has_many :in_app_notifications, dependent: :destroy
diff --git a/app/models/workload_sentiment.rb b/app/models/workload_sentiment.rb
new file mode 100644
index 00000000..6aa2a20c
--- /dev/null
+++ b/app/models/workload_sentiment.rb
@@ -0,0 +1,13 @@
+class WorkloadSentiment < ApplicationRecord
+ belongs_to :user
+ belongs_to :role
+
+ SENTIMENTS = %w[too_much just_right too_little].freeze
+
+ validates :sentiment, presence: true, inclusion: { in: SENTIMENTS }
+ validates :month, presence: true
+ validates :user_id, uniqueness: { scope: [ :role_id, :month ] }
+
+ scope :for_month, ->(date) { where(month: date.beginning_of_month) }
+ scope :for_role, ->(role) { where(role: role) }
+end
diff --git a/app/views/role_assignments/_form.html.erb b/app/views/role_assignments/_form.html.erb
new file mode 100644
index 00000000..bbb20240
--- /dev/null
+++ b/app/views/role_assignments/_form.html.erb
@@ -0,0 +1,36 @@
+<%= form_with(model: [ role, assignment ]) do |form| %>
+ <% if assignment.errors.any? %>
+
+
+ <% assignment.errors.full_messages.each do |msg| %>
+ - <%= msg %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.label :user_id, "Person", class: "label" %>
+ <%= form.select :user_id, @users.map { |u| [u.name, u.id] }, { include_blank: "Select person" }, class: "select select-bordered w-full" %>
+
+
+
+ <%= form.label :assignment_type, "Role Type", class: "label" %>
+ <%= form.select :assignment_type, RoleAssignment::ASSIGNMENT_TYPES.map { |t| [t.humanize, t] }, {}, class: "select select-bordered w-full" %>
+
+
+
+ <%= form.label :starts_at, "Start Date", class: "label" %>
+ <%= form.date_field :starts_at, class: "input input-bordered w-full", value: assignment.starts_at || Date.current %>
+
+
+
+ <%= form.label :ends_at, "End Date (optional)", class: "label" %>
+ <%= form.date_field :ends_at, class: "input input-bordered w-full", value: assignment.ends_at || (role.term_length_months ? Date.current + role.term_length_months.months : nil) %>
+
+
+
+ <%= link_to "Cancel", role_path(role), class: "btn btn-ghost" %>
+ <%= form.submit "Assign", class: "btn btn-primary" %>
+
+<% end %>
diff --git a/app/views/role_assignments/new.html.erb b/app/views/role_assignments/new.html.erb
new file mode 100644
index 00000000..12274cde
--- /dev/null
+++ b/app/views/role_assignments/new.html.erb
@@ -0,0 +1,4 @@
+
+
Assign: <%= @role.title %>
+ <%= render "role_assignments/form", assignment: @assignment, role: @role %>
+
diff --git a/app/views/roles/_form.html.erb b/app/views/roles/_form.html.erb
new file mode 100644
index 00000000..68e627f2
--- /dev/null
+++ b/app/views/roles/_form.html.erb
@@ -0,0 +1,46 @@
+<%= form_with(model: role) do |form| %>
+ <% if role.errors.any? %>
+
+
+ <% role.errors.full_messages.each do |msg| %>
+ - <%= msg %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.label :title, class: "label" %>
+ <%= form.text_field :title, class: "input input-bordered w-full", required: true %>
+
+
+
+ <%= form.label :role_type, "Type", class: "label" %>
+ <%= form.select :role_type, Role::ROLE_TYPES.map { |t| [t.humanize, t] }, {}, class: "select select-bordered w-full" %>
+
+
+
+ <%= form.label :group, "Category", class: "label" %>
+ <%= form.select :group, Role::GROUPS.map { |g| [g.humanize.titleize, g] }, { include_blank: "None" }, class: "select select-bordered w-full" %>
+
+
+
+ <%= form.label :term_length_months, "Term Length (months)", class: "label" %>
+ <%= form.number_field :term_length_months, class: "input input-bordered w-full", min: 1 %>
+
+
+
+ <%= form.label :duties, "Duties (official responsibilities)", class: "label" %>
+ <%= form.text_area :duties, class: "textarea textarea-bordered w-full", rows: 6 %>
+
+
+
+ <%= form.label :description, "Role Guide (practical tips for the holder)", class: "label" %>
+ <%= form.text_area :description, class: "textarea textarea-bordered w-full", rows: 4 %>
+
+
+
+ <%= link_to "Cancel", roles_path, class: "btn btn-ghost" %>
+ <%= form.submit role.persisted? ? "Update" : "Create Role", class: "btn btn-primary" %>
+
+<% end %>
diff --git a/app/views/roles/_role_card.html.erb b/app/views/roles/_role_card.html.erb
new file mode 100644
index 00000000..608d63c8
--- /dev/null
+++ b/app/views/roles/_role_card.html.erb
@@ -0,0 +1,15 @@
+<%= link_to role_path(role), class: "card bg-base-100 border border-base-200 p-3 block hover:bg-base-200 transition" do %>
+
+
+
<%= role.title %>
+ <% if role.current_holders.any? %>
+
+ <%= role.current_holders.map { |a| a.user.name }.join(", ") %>
+
+ <% end %>
+
+ <% if role.vacant? %>
+
Vacant
+ <% end %>
+
+<% end %>
diff --git a/app/views/roles/edit.html.erb b/app/views/roles/edit.html.erb
new file mode 100644
index 00000000..151d910e
--- /dev/null
+++ b/app/views/roles/edit.html.erb
@@ -0,0 +1,4 @@
+
+
Edit <%= @role.title %>
+ <%= render "roles/form", role: @role %>
+
diff --git a/app/views/roles/index.html.erb b/app/views/roles/index.html.erb
new file mode 100644
index 00000000..c9772666
--- /dev/null
+++ b/app/views/roles/index.html.erb
@@ -0,0 +1,32 @@
+
+
+
Roles
+ <%= link_to "Add Role", new_role_path, class: "btn btn-primary btn-sm" %>
+
+
+ <% Role::GROUPS.each do |group| %>
+ <% roles = @roles_by_group[group] %>
+ <% if roles.present? %>
+
<%= group.humanize.titleize %>
+
+ <% roles.each do |role| %>
+ <%= render "roles/role_card", role: role %>
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+ <% committees = @roles_by_group[nil]&.select { |r| r.role_type == "committee" } %>
+ <% if committees.present? %>
+
Committees
+
+ <% committees.each do |role| %>
+ <%= render "roles/role_card", role: role %>
+ <% end %>
+
+ <% end %>
+
+
+ <%= link_to "Back to Tasks", tasks_path, class: "btn btn-ghost btn-sm" %>
+
+
diff --git a/app/views/roles/new.html.erb b/app/views/roles/new.html.erb
new file mode 100644
index 00000000..5e5e3e3c
--- /dev/null
+++ b/app/views/roles/new.html.erb
@@ -0,0 +1,4 @@
+
+
New Role
+ <%= render "roles/form", role: @role %>
+
diff --git a/app/views/roles/show.html.erb b/app/views/roles/show.html.erb
new file mode 100644
index 00000000..c3afb1d3
--- /dev/null
+++ b/app/views/roles/show.html.erb
@@ -0,0 +1,79 @@
+
+
+
<%= @role.title %>
+ <%= link_to "Edit", edit_role_path(@role), class: "btn btn-ghost btn-sm" %>
+
+
+ <% if @role.vacant? %>
+
+ This role is currently vacant.
+
+ <% end %>
+
+
+
Assigned
+ <% if @assignments.any? %>
+ <% @assignments.each do |assignment| %>
+
+
+ <%= assignment.user.name %>
+ <%= assignment.assignment_type.humanize %>
+
+
+ Ends: <%= assignment.ends_at&.strftime("%b %Y") || "No end date" %>
+
+
+ <% end %>
+ <% else %>
+
No one is currently assigned.
+ <% end %>
+ <%= link_to "Assign Someone", new_role_role_assignment_path(@role), class: "btn btn-sm btn-outline mt-2" %>
+
+
+ <% if @role.duties.present? %>
+
+
Duties
+
<%= simple_format(@role.duties) %>
+
+ <% end %>
+
+ <% if @role.description.present? %>
+
+
Role Guide
+
<%= simple_format(@role.description) %>
+
+ <% end %>
+
+
+
Time This Month
+ <% monthly_hours = @role.time_entries.for_month(Date.current.year, Date.current.month).sum(:hours) %>
+
<%= monthly_hours %> hours
+ <%= link_to "Log Time", new_role_time_entry_path(@role), class: "btn btn-sm btn-outline mt-2" %>
+
+
+ <% if @tasks.any? %>
+
+
Tasks
+
+ <% @tasks.each do |task| %>
+ <%= render "tasks/task_card_mobile", task: task %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+
Change History
+
+ <% @role.versions.reverse.first(20).each do |version| %>
+
+ <%= version.whodunnit || "System" %>
+ <%= version.event %> on <%= version.created_at.strftime("%b %d, %Y") %>
+
+ <% end %>
+
+
+
+ <%= link_to "Back to Roles", roles_path, class: "btn btn-ghost btn-sm" %>
+
diff --git a/app/views/tasks/_form.html.erb b/app/views/tasks/_form.html.erb
index 16cc00ab..638093da 100644
--- a/app/views/tasks/_form.html.erb
+++ b/app/views/tasks/_form.html.erb
@@ -24,9 +24,10 @@
<%= form.date_field :due_date, class: "input input-bordered w-full cursor-pointer", data: { date_field_target: "input", action: "click->date-field#openDatePicker" } %>
- <%# Only show user assignment on tasks page, not on dashboard %>
+ <%# Only show user assignment and role on tasks page, not on dashboard %>
<% if controller_name == 'tasks' %>
<%= render "tasks/user_select", form: form, task: task, users: @users %>
+ <%= render "tasks/role_select", form: form %>
<% end %>
<% if task.persisted? %>
diff --git a/app/views/tasks/_role_select.html.erb b/app/views/tasks/_role_select.html.erb
new file mode 100644
index 00000000..b9a180be
--- /dev/null
+++ b/app/views/tasks/_role_select.html.erb
@@ -0,0 +1,4 @@
+
+ <%= form.label :role_id, "Role (optional)", class: "label" %>
+ <%= form.select :role_id, Role.ordered.map { |r| [r.title, r.id] }, { include_blank: "No role" }, class: "select select-bordered w-full" %>
+
diff --git a/app/views/tasks/index.html.erb b/app/views/tasks/index.html.erb
index 6cf23f00..fad53d74 100644
--- a/app/views/tasks/index.html.erb
+++ b/app/views/tasks/index.html.erb
@@ -2,12 +2,19 @@
Tasks
-
+
+ <%= link_to roles_path, class: "btn btn-ghost btn-sm btn-square", title: "Community Roles" do %>
+
+ <% end %>
+
+
<%= turbo_frame_tag "tasks_content" do %>
diff --git a/app/views/time_entries/_form.html.erb b/app/views/time_entries/_form.html.erb
new file mode 100644
index 00000000..f3276f44
--- /dev/null
+++ b/app/views/time_entries/_form.html.erb
@@ -0,0 +1,43 @@
+<%= form_with(model: [ role, time_entry ]) do |form| %>
+ <% if time_entry.errors.any? %>
+
+
+ <% time_entry.errors.full_messages.each do |msg| %>
+ - <%= msg %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.label :entry_type, "Type", class: "label" %>
+ <%= form.select :entry_type, TimeEntry::ENTRY_TYPES.map { |t| [t.humanize, t] }, {}, class: "select select-bordered w-full" %>
+
+
+
+ <%= form.label :hours, class: "label" %>
+ <%= form.number_field :hours, class: "input input-bordered w-full", step: 0.25, min: 0.25, required: true %>
+
+
+
+ <%= form.label :logged_on, "Date", class: "label" %>
+ <%= form.date_field :logged_on, class: "input input-bordered w-full", required: true %>
+
+
+ <% if @tasks.any? %>
+
+ <%= form.label :task_id, "Task (optional)", class: "label" %>
+ <%= form.select :task_id, @tasks.map { |t| [t.title, t.id] }, { include_blank: "No specific task" }, class: "select select-bordered w-full" %>
+
+ <% end %>
+
+
+ <%= form.label :note, "Note (optional)", class: "label" %>
+ <%= form.text_field :note, class: "input input-bordered w-full", placeholder: "What did you work on?" %>
+
+
+
+ <%= link_to "Cancel", role_path(role), class: "btn btn-ghost" %>
+ <%= form.submit "Log Time", class: "btn btn-primary" %>
+
+<% end %>
diff --git a/app/views/time_entries/index.html.erb b/app/views/time_entries/index.html.erb
new file mode 100644
index 00000000..a58c65e9
--- /dev/null
+++ b/app/views/time_entries/index.html.erb
@@ -0,0 +1,45 @@
+
+
My Time
+
+ <% if @monthly_by_role.any? %>
+
+
This Month
+
+ <% @monthly_by_role.each do |role_id, hours| %>
+ <% role = @roles.find { |r| r.id == role_id } %>
+ <% next unless role %>
+
+ <%= role.title %>
+ <%= hours %> hrs
+
+ <% end %>
+
+
Total: <%= @monthly_by_role.values.sum %> hrs
+
+ <% end %>
+
+
Recent Entries
+
+ <% @time_entries.each do |entry| %>
+
+
+
+
<%= entry.role&.title || "Unlinked" %>
+
<%= entry.logged_on.strftime("%b %d") %>
+ <% if entry.note.present? %>
+
<%= entry.note %>
+ <% end %>
+
+
+ <%= entry.hours %> hrs
+ <%= button_to "x", time_entry_path(entry), method: :delete, class: "btn btn-ghost btn-xs", data: { turbo_confirm: "Delete this time entry?" } %>
+
+
+
+ <% end %>
+
+
+
+ <%= link_to "Back to Roles", roles_path, class: "btn btn-ghost btn-sm" %>
+
+
diff --git a/app/views/time_entries/new.html.erb b/app/views/time_entries/new.html.erb
new file mode 100644
index 00000000..f49edd71
--- /dev/null
+++ b/app/views/time_entries/new.html.erb
@@ -0,0 +1,4 @@
+
+
Log Time: <%= @role.title %>
+ <%= render "time_entries/form", time_entry: @time_entry, role: @role %>
+
diff --git a/config/routes.rb b/config/routes.rb
index 75934968..686a057d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -120,6 +120,12 @@
post :restore
end
end
+ resources :roles do
+ resources :role_assignments, only: [ :new, :create, :destroy ], shallow: true
+ resources :time_entries, only: [ :new, :create ], shallow: true
+ resources :recurring_task_templates, only: [ :new, :create, :edit, :update, :destroy ], shallow: true
+ end
+ resources :time_entries, only: [ :index, :destroy ]
resources :posts, only: [ :create, :update, :destroy ] do
resources :likes, only: [ :create, :destroy ]
resources :comments, only: [ :create, :destroy ] do
diff --git a/db/migrate/20260518020143_create_roles.rb b/db/migrate/20260518020143_create_roles.rb
new file mode 100644
index 00000000..85dac7d6
--- /dev/null
+++ b/db/migrate/20260518020143_create_roles.rb
@@ -0,0 +1,24 @@
+class CreateRoles < ActiveRecord::Migration[8.0]
+ def change
+ create_table :roles do |t|
+ t.references :community, null: false, foreign_key: true
+ t.string :title, null: false
+ t.text :duties
+ t.text :description
+ t.string :group
+ t.string :role_type, null: false, default: "role"
+ t.integer :term_length_months
+ t.boolean :vacant, default: true, null: false
+ t.references :created_by, foreign_key: { to_table: :users }
+ t.references :deleted_by, foreign_key: { to_table: :users }
+ t.datetime :discarded_at
+ t.timestamps
+ end
+
+ add_index :roles, :group
+ add_index :roles, :role_type
+ add_index :roles, :vacant
+ add_index :roles, :discarded_at
+ add_index :roles, [ :community_id, :title ], unique: true
+ end
+end
diff --git a/db/migrate/20260518020559_create_role_assignments.rb b/db/migrate/20260518020559_create_role_assignments.rb
new file mode 100644
index 00000000..3ba2fb19
--- /dev/null
+++ b/db/migrate/20260518020559_create_role_assignments.rb
@@ -0,0 +1,19 @@
+class CreateRoleAssignments < ActiveRecord::Migration[8.0]
+ def change
+ create_table :role_assignments do |t|
+ t.references :role, null: false, foreign_key: true
+ t.references :user, null: false, foreign_key: true
+ t.string :assignment_type, null: false, default: "holder"
+ t.date :starts_at, null: false
+ t.date :ends_at
+ t.boolean :active, default: true, null: false
+ t.timestamps
+ end
+
+ add_index :role_assignments, :assignment_type
+ add_index :role_assignments, :active
+ add_index :role_assignments, :ends_at
+ add_index :role_assignments, [ :role_id, :user_id, :active ], name: "idx_role_assignments_unique_active",
+ unique: true, where: "active = true AND assignment_type = 'holder'"
+ end
+end
diff --git a/db/migrate/20260518020905_create_time_entries.rb b/db/migrate/20260518020905_create_time_entries.rb
new file mode 100644
index 00000000..6fbcbfdf
--- /dev/null
+++ b/db/migrate/20260518020905_create_time_entries.rb
@@ -0,0 +1,19 @@
+class CreateTimeEntries < ActiveRecord::Migration[8.1]
+ def change
+ create_table :time_entries do |t|
+ t.references :user, null: false, foreign_key: true
+ t.references :task, foreign_key: true
+ t.references :role, foreign_key: true
+ t.decimal :hours, precision: 5, scale: 2, null: false
+ t.date :logged_on, null: false
+ t.string :entry_type, null: false
+ t.string :note
+ t.timestamps
+ end
+
+ add_index :time_entries, :entry_type
+ add_index :time_entries, :logged_on
+ add_index :time_entries, [ :user_id, :logged_on ]
+ add_index :time_entries, [ :role_id, :logged_on ]
+ end
+end
diff --git a/db/migrate/20260518021145_create_recurring_task_templates.rb b/db/migrate/20260518021145_create_recurring_task_templates.rb
new file mode 100644
index 00000000..b3c3ae65
--- /dev/null
+++ b/db/migrate/20260518021145_create_recurring_task_templates.rb
@@ -0,0 +1,15 @@
+class CreateRecurringTaskTemplates < ActiveRecord::Migration[8.1]
+ def change
+ create_table :recurring_task_templates do |t|
+ t.references :role, null: false, foreign_key: true
+ t.string :title, null: false
+ t.text :description
+ t.string :frequency, null: false
+ t.boolean :auto_assign_to_holder, default: true, null: false
+ t.date :last_generated_at
+ t.timestamps
+ end
+
+ add_index :recurring_task_templates, :frequency
+ end
+end
diff --git a/db/migrate/20260518021347_add_role_id_to_tasks.rb b/db/migrate/20260518021347_add_role_id_to_tasks.rb
new file mode 100644
index 00000000..9870abe3
--- /dev/null
+++ b/db/migrate/20260518021347_add_role_id_to_tasks.rb
@@ -0,0 +1,5 @@
+class AddRoleIdToTasks < ActiveRecord::Migration[8.1]
+ def change
+ add_reference :tasks, :role, foreign_key: true
+ end
+end
diff --git a/db/migrate/20260518022728_create_workload_sentiments.rb b/db/migrate/20260518022728_create_workload_sentiments.rb
new file mode 100644
index 00000000..fa6efeca
--- /dev/null
+++ b/db/migrate/20260518022728_create_workload_sentiments.rb
@@ -0,0 +1,13 @@
+class CreateWorkloadSentiments < ActiveRecord::Migration[8.0]
+ def change
+ create_table :workload_sentiments do |t|
+ t.references :user, null: false, foreign_key: true
+ t.references :role, null: false, foreign_key: true
+ t.string :sentiment, null: false
+ t.date :month, null: false
+ t.timestamps
+ end
+
+ add_index :workload_sentiments, [ :user_id, :role_id, :month ], unique: true, name: "idx_workload_sentiments_unique"
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a2d3590b..69fa77a6 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_05_17_014951) do
+ActiveRecord::Schema[8.1].define(version: 2026_05_18_022728) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -451,6 +451,60 @@
t.index ["user_id"], name: "index_push_subscriptions_on_user_id"
end
+ create_table "recurring_task_templates", force: :cascade do |t|
+ t.boolean "auto_assign_to_holder", default: true, null: false
+ t.datetime "created_at", null: false
+ t.text "description"
+ t.string "frequency", null: false
+ t.date "last_generated_at"
+ t.bigint "role_id", null: false
+ t.string "title", null: false
+ t.datetime "updated_at", null: false
+ t.index ["frequency"], name: "index_recurring_task_templates_on_frequency"
+ t.index ["role_id"], name: "index_recurring_task_templates_on_role_id"
+ end
+
+ create_table "role_assignments", force: :cascade do |t|
+ t.boolean "active", default: true, null: false
+ t.string "assignment_type", default: "holder", null: false
+ t.datetime "created_at", null: false
+ t.date "ends_at"
+ t.bigint "role_id", null: false
+ t.date "starts_at", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.index ["active"], name: "index_role_assignments_on_active"
+ t.index ["assignment_type"], name: "index_role_assignments_on_assignment_type"
+ t.index ["ends_at"], name: "index_role_assignments_on_ends_at"
+ t.index ["role_id", "user_id", "active"], name: "idx_role_assignments_unique_active", unique: true, where: "((active = true) AND ((assignment_type)::text = 'holder'::text))"
+ t.index ["role_id"], name: "index_role_assignments_on_role_id"
+ t.index ["user_id"], name: "index_role_assignments_on_user_id"
+ end
+
+ create_table "roles", force: :cascade do |t|
+ t.bigint "community_id", null: false
+ t.datetime "created_at", null: false
+ t.bigint "created_by_id"
+ t.bigint "deleted_by_id"
+ t.text "description"
+ t.datetime "discarded_at"
+ t.text "duties"
+ t.string "group"
+ t.string "role_type", default: "role", null: false
+ t.integer "term_length_months"
+ t.string "title", null: false
+ t.datetime "updated_at", null: false
+ t.boolean "vacant", default: true, null: false
+ t.index ["community_id", "title"], name: "index_roles_on_community_id_and_title", unique: true
+ t.index ["community_id"], name: "index_roles_on_community_id"
+ t.index ["created_by_id"], name: "index_roles_on_created_by_id"
+ t.index ["deleted_by_id"], name: "index_roles_on_deleted_by_id"
+ t.index ["discarded_at"], name: "index_roles_on_discarded_at"
+ t.index ["group"], name: "index_roles_on_group"
+ t.index ["role_type"], name: "index_roles_on_role_type"
+ t.index ["vacant"], name: "index_roles_on_vacant"
+ end
+
create_table "tasks", force: :cascade do |t|
t.integer "assigned_to_user_id"
t.bigint "community_id", null: false
@@ -461,6 +515,7 @@
t.datetime "discarded_at"
t.date "due_date"
t.integer "priority_order"
+ t.bigint "role_id"
t.string "status"
t.string "title"
t.datetime "updated_at", null: false
@@ -472,9 +527,29 @@
t.index ["discarded_at"], name: "index_tasks_on_discarded_at"
t.index ["due_date"], name: "index_tasks_on_due_date"
t.index ["priority_order"], name: "index_tasks_on_priority_order"
+ t.index ["role_id"], name: "index_tasks_on_role_id"
t.index ["user_id"], name: "index_tasks_on_user_id"
end
+ create_table "time_entries", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "entry_type", null: false
+ t.decimal "hours", precision: 5, scale: 2, null: false
+ t.date "logged_on", null: false
+ t.string "note"
+ t.bigint "role_id"
+ t.bigint "task_id"
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.index ["entry_type"], name: "index_time_entries_on_entry_type"
+ t.index ["logged_on"], name: "index_time_entries_on_logged_on"
+ t.index ["role_id", "logged_on"], name: "index_time_entries_on_role_id_and_logged_on"
+ t.index ["role_id"], name: "index_time_entries_on_role_id"
+ t.index ["task_id"], name: "index_time_entries_on_task_id"
+ t.index ["user_id", "logged_on"], name: "index_time_entries_on_user_id_and_logged_on"
+ t.index ["user_id"], name: "index_time_entries_on_user_id"
+ end
+
create_table "topic_comments", force: :cascade do |t|
t.text "content"
t.datetime "created_at", null: false
@@ -523,6 +598,18 @@
t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
end
+ create_table "workload_sentiments", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.date "month", null: false
+ t.bigint "role_id", null: false
+ t.string "sentiment", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.index ["role_id"], name: "index_workload_sentiments_on_role_id"
+ t.index ["user_id", "role_id", "month"], name: "idx_workload_sentiments_unique", unique: true
+ t.index ["user_id"], name: "index_workload_sentiments_on_user_id"
+ end
+
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "calendar_events", "communities"
@@ -582,14 +669,26 @@
add_foreign_key "posts", "users", column: "created_by_id"
add_foreign_key "posts", "users", column: "deleted_by_id"
add_foreign_key "push_subscriptions", "users"
+ add_foreign_key "recurring_task_templates", "roles"
+ add_foreign_key "role_assignments", "roles"
+ add_foreign_key "role_assignments", "users"
+ add_foreign_key "roles", "communities"
+ add_foreign_key "roles", "users", column: "created_by_id"
+ add_foreign_key "roles", "users", column: "deleted_by_id"
add_foreign_key "tasks", "communities"
+ add_foreign_key "tasks", "roles"
add_foreign_key "tasks", "users"
add_foreign_key "tasks", "users", column: "assigned_to_user_id"
add_foreign_key "tasks", "users", column: "created_by_id"
add_foreign_key "tasks", "users", column: "deleted_by_id"
+ add_foreign_key "time_entries", "roles"
+ add_foreign_key "time_entries", "tasks"
+ add_foreign_key "time_entries", "users"
add_foreign_key "topic_comments", "discussion_topics"
add_foreign_key "topic_comments", "users"
add_foreign_key "users", "communities"
add_foreign_key "users", "households"
add_foreign_key "users", "invitations"
+ add_foreign_key "workload_sentiments", "roles"
+ add_foreign_key "workload_sentiments", "users"
end
diff --git a/test/controllers/role_assignments_controller_test.rb b/test/controllers/role_assignments_controller_test.rb
new file mode 100644
index 00000000..9a56e34a
--- /dev/null
+++ b/test/controllers/role_assignments_controller_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+
+class RoleAssignmentsControllerTest < ActionDispatch::IntegrationTest
+ def setup
+ @user = users(:one)
+ sign_in_as(@user)
+ @role = roles(:facilitator)
+ @assignment = role_assignments(:maven_holder)
+ end
+
+ test "should get new" do
+ get new_role_role_assignment_url(@role)
+ assert_response :success
+ end
+
+ test "should create role assignment" do
+ assert_difference("RoleAssignment.count") do
+ post role_role_assignments_url(@role), params: { role_assignment: {
+ user_id: @user.id,
+ assignment_type: "holder",
+ starts_at: Date.current,
+ ends_at: 6.months.from_now.to_date
+ } }
+ end
+ assert_redirected_to role_url(@role)
+ end
+
+ test "should destroy role assignment" do
+ assert_difference("RoleAssignment.count", -1) do
+ delete role_assignment_url(@assignment)
+ end
+ assert_redirected_to role_url(@assignment.role)
+ end
+
+ private
+
+ def sign_in_as(user)
+ post login_url, params: { email: user.email, password: "password" }
+ assert_equal user.id, session[:user_id], "Failed to sign in as #{user.email}"
+ end
+end
diff --git a/test/controllers/roles_controller_test.rb b/test/controllers/roles_controller_test.rb
new file mode 100644
index 00000000..aab85f0e
--- /dev/null
+++ b/test/controllers/roles_controller_test.rb
@@ -0,0 +1,73 @@
+require "test_helper"
+
+class RolesControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @community = communities(:crow_woods)
+ ActsAsTenant.current_tenant = @community
+ @user = users(:one)
+ sign_in_user({ uid: @user.uid, name: @user.name, email: @user.email })
+ ActsAsTenant.current_tenant = @community
+ @role = roles(:garden_maven)
+ end
+
+ teardown do
+ ActsAsTenant.current_tenant = nil
+ end
+
+ test "should get index" do
+ get roles_url
+ assert_response :success
+ assert_select "h1", /Roles/
+ end
+
+ test "should get show" do
+ get role_url(@role)
+ assert_response :success
+ assert_select "h1", @role.title
+ end
+
+ test "should get new" do
+ get new_role_url
+ assert_response :success
+ end
+
+ test "should create role" do
+ assert_difference("Role.count") do
+ post roles_url, params: { role: {
+ title: "New Test Role",
+ role_type: "role",
+ group: "community",
+ duties: "Do the thing",
+ term_length_months: 6
+ } }
+ end
+ new_role = Role.unscoped.order(created_at: :desc).first
+ assert_redirected_to role_url(new_role)
+ end
+
+ test "should get edit" do
+ get edit_role_url(@role)
+ assert_response :success
+ end
+
+ test "should update role" do
+ patch role_url(@role), params: { role: { title: "Updated Title" } }
+ assert_redirected_to role_url(@role)
+ @role.reload
+ assert_equal "Updated Title", @role.title
+ end
+
+ test "should soft delete role" do
+ assert_difference("Role.count", -1) do
+ delete role_url(@role)
+ end
+ assert_redirected_to roles_url
+ assert Role.with_discarded.find(@role.id).discarded?
+ end
+
+ test "should require authentication" do
+ delete logout_url
+ get roles_url
+ assert_redirected_to login_url
+ end
+end
diff --git a/test/controllers/time_entries_controller_test.rb b/test/controllers/time_entries_controller_test.rb
new file mode 100644
index 00000000..55ac7ea7
--- /dev/null
+++ b/test/controllers/time_entries_controller_test.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class TimeEntriesControllerTest < ActionDispatch::IntegrationTest
+ def setup
+ @user = users(:one)
+ sign_in_as(@user)
+ @role = roles(:garden_maven)
+ @entry = time_entries(:maven_task_entry)
+ end
+
+ test "should get index" do
+ get time_entries_url
+ assert_response :success
+ end
+
+ test "should create task time entry" do
+ task = tasks(:assigned_task)
+ assert_difference("TimeEntry.count") do
+ post role_time_entries_url(@role), params: { time_entry: {
+ hours: 1.5,
+ logged_on: Date.current,
+ entry_type: "task",
+ task_id: task.id
+ } }
+ end
+ assert_redirected_to role_url(@role)
+ end
+
+ test "should create reconciliation entry" do
+ assert_difference("TimeEntry.count") do
+ post role_time_entries_url(@role), params: { time_entry: {
+ hours: 3.0,
+ logged_on: Date.current,
+ entry_type: "reconciliation",
+ note: "General maintenance conversations"
+ } }
+ end
+ assert_redirected_to role_url(@role)
+ end
+
+ test "should destroy time entry" do
+ assert_difference("TimeEntry.count", -1) do
+ delete time_entry_url(@entry)
+ end
+ assert_redirected_to time_entries_url
+ end
+
+ private
+
+ def sign_in_as(user)
+ post login_url, params: { email: user.email, password: "password" }
+ assert_equal user.id, session[:user_id], "Failed to sign in as #{user.email}"
+ end
+end
diff --git a/test/fixtures/recurring_task_templates.yml b/test/fixtures/recurring_task_templates.yml
new file mode 100644
index 00000000..978e61b4
--- /dev/null
+++ b/test/fixtures/recurring_task_templates.yml
@@ -0,0 +1,20 @@
+grounds_walk:
+ role: garden_maven
+ title: Grounds walk
+ description: "Walk the grounds to observe and respond to landscaping needs"
+ frequency: biweekly
+ auto_assign_to_holder: true
+
+garbage_day:
+ role: garden_maven
+ title: Take out garbage bins
+ description: "Take out all garbage, recycling, and yard waste bins on garbage day"
+ frequency: weekly
+ auto_assign_to_holder: true
+
+hot_tub_maintenance:
+ role: signage_committee
+ title: Hot tub filter check
+ description: "Check and clean hot tub filter cartridge"
+ frequency: monthly
+ auto_assign_to_holder: true
diff --git a/test/fixtures/role_assignments.yml b/test/fixtures/role_assignments.yml
new file mode 100644
index 00000000..cb5007f2
--- /dev/null
+++ b/test/fixtures/role_assignments.yml
@@ -0,0 +1,31 @@
+maven_holder:
+ role: garden_maven
+ user: one
+ assignment_type: holder
+ starts_at: <%= 2.months.ago.to_date %>
+ ends_at: <%= 4.months.from_now.to_date %>
+ active: true
+
+maven_backup:
+ role: garden_maven
+ user: two
+ assignment_type: backup
+ starts_at: <%= 2.months.ago.to_date %>
+ ends_at: <%= 4.months.from_now.to_date %>
+ active: true
+
+president_holder:
+ role: president
+ user: two
+ assignment_type: holder
+ starts_at: <%= 6.months.ago.to_date %>
+ ends_at: <%= 6.months.from_now.to_date %>
+ active: true
+
+signage_member_one:
+ role: signage_committee
+ user: one
+ assignment_type: holder
+ starts_at: <%= 1.month.ago.to_date %>
+ ends_at: <%= 2.months.from_now.to_date %>
+ active: true
diff --git a/test/fixtures/roles.yml b/test/fixtures/roles.yml
new file mode 100644
index 00000000..3c2c1317
--- /dev/null
+++ b/test/fixtures/roles.yml
@@ -0,0 +1,36 @@
+garden_maven:
+ community: crow_woods
+ title: Garden Health Maven
+ role_type: role
+ group: garden
+ term_length_months: 6
+ duties: "Manage watering, plant health, equipment maintenance, winterizing"
+ description: "Use the Common House spigot when possible. Hoses stored in common storage room."
+ vacant: false
+
+president:
+ community: crow_woods
+ title: HOA President
+ role_type: role
+ group: hoa_officers
+ term_length_months: 12
+ duties: "Draft agendas, oversee community guidelines review, run HOA meetings, handle crises"
+ vacant: false
+
+facilitator:
+ community: crow_woods
+ title: Facilitator
+ role_type: role
+ group: community
+ term_length_months: 6
+ duties: "Co-facilitate monthly meetings, create agenda, send agenda, follow up on action items"
+ vacant: true
+
+signage_committee:
+ community: crow_woods
+ title: Signage Committee
+ role_type: committee
+ group: community
+ term_length_months: 3
+ duties: "Design, source, and install community signage"
+ vacant: false
diff --git a/test/fixtures/tasks.yml b/test/fixtures/tasks.yml
index 8a824d3a..35da642c 100644
--- a/test/fixtures/tasks.yml
+++ b/test/fixtures/tasks.yml
@@ -22,6 +22,7 @@ assigned_task:
priority_order: 1
user: one
assigned_to_user: two
+ role: garden_maven
received_task:
community: crow_woods
diff --git a/test/fixtures/time_entries.yml b/test/fixtures/time_entries.yml
new file mode 100644
index 00000000..6819a13e
--- /dev/null
+++ b/test/fixtures/time_entries.yml
@@ -0,0 +1,24 @@
+maven_task_entry:
+ user: one
+ task: one
+ role: garden_maven
+ hours: 1.5
+ logged_on: <%= Date.current %>
+ entry_type: task
+ note: ""
+
+maven_reconciliation:
+ user: one
+ role: garden_maven
+ hours: 3.0
+ logged_on: <%= Date.current.beginning_of_month %>
+ entry_type: reconciliation
+ note: "Informal grounds checks and neighbor conversations"
+
+president_entry:
+ user: two
+ role: president
+ hours: 4.0
+ logged_on: <%= 1.week.ago.to_date %>
+ entry_type: reconciliation
+ note: "Agenda prep and crisis management calls"
diff --git a/test/fixtures/workload_sentiments.yml b/test/fixtures/workload_sentiments.yml
new file mode 100644
index 00000000..812745d3
--- /dev/null
+++ b/test/fixtures/workload_sentiments.yml
@@ -0,0 +1,5 @@
+maven_sentiment:
+ user: one
+ role: garden_maven
+ sentiment: just_right
+ month: <%= 1.month.ago.beginning_of_month %>
diff --git a/test/jobs/generate_recurring_tasks_job_test.rb b/test/jobs/generate_recurring_tasks_job_test.rb
new file mode 100644
index 00000000..93d666d0
--- /dev/null
+++ b/test/jobs/generate_recurring_tasks_job_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+
+class GenerateRecurringTasksJobTest < ActiveJob::TestCase
+ def setup
+ @template = recurring_task_templates(:grounds_walk)
+ @holder = role_assignments(:maven_holder)
+
+ # Prevent other templates from generating tasks during tests
+ RecurringTaskTemplate.update_all(last_generated_at: Date.current)
+ end
+
+ test "should generate task for due template" do
+ @template.update!(last_generated_at: 3.weeks.ago.to_date)
+
+ assert_difference("Task.count") do
+ GenerateRecurringTasksJob.perform_now
+ end
+
+ task = Task.unscoped.order(:created_at).last
+ assert_equal @template.title, task.title
+ assert_equal @template.role, task.role
+ assert_equal @holder.user, task.assigned_to_user
+ end
+
+ test "should not generate task for template not yet due" do
+ @template.update!(last_generated_at: Date.current)
+
+ assert_no_difference("Task.count") do
+ GenerateRecurringTasksJob.perform_now
+ end
+ end
+
+ test "should not generate task for role with no active holder" do
+ @template.update!(last_generated_at: 3.weeks.ago.to_date)
+ @holder.update!(active: false)
+
+ assert_no_difference("Task.count") do
+ GenerateRecurringTasksJob.perform_now
+ end
+ end
+end
diff --git a/test/models/recurring_task_template_test.rb b/test/models/recurring_task_template_test.rb
new file mode 100644
index 00000000..102e75ac
--- /dev/null
+++ b/test/models/recurring_task_template_test.rb
@@ -0,0 +1,65 @@
+require "test_helper"
+
+class RecurringTaskTemplateTest < ActiveSupport::TestCase
+ def setup
+ @role = roles(:garden_maven)
+ end
+
+ test "should require role" do
+ template = RecurringTaskTemplate.new(title: "Test", frequency: "weekly")
+ assert_not template.valid?
+ assert_includes template.errors[:role], "must exist"
+ end
+
+ test "should require title" do
+ template = RecurringTaskTemplate.new(role: @role, frequency: "weekly")
+ assert_not template.valid?
+ assert_includes template.errors[:title], "can't be blank"
+ end
+
+ test "should require frequency" do
+ template = RecurringTaskTemplate.new(role: @role, title: "Test")
+ assert_not template.valid?
+ assert_includes template.errors[:frequency], "can't be blank"
+ end
+
+ test "should validate frequency inclusion" do
+ template = RecurringTaskTemplate.new(role: @role, title: "Test", frequency: "hourly")
+ assert_not template.valid?
+ assert_includes template.errors[:frequency], "is not included in the list"
+ end
+
+ test "should allow valid template" do
+ template = RecurringTaskTemplate.new(
+ role: @role,
+ title: "Grounds walk",
+ description: "Walk the grounds to observe and respond to landscaping needs",
+ frequency: "biweekly",
+ auto_assign_to_holder: true
+ )
+ assert template.valid?
+ end
+
+ test "should generate a task" do
+ template = recurring_task_templates(:grounds_walk)
+ user = users(:one)
+
+ task = template.generate_task!(user)
+
+ assert task.persisted?
+ assert_equal template.title, task.title
+ assert_equal template.description, task.description
+ # TODO: assert_equal template.role, task.role — Task doesn't have role_id yet (Task 5)
+ assert_equal user, task.assigned_to_user
+ end
+
+ test "due_for_generation returns true when never generated" do
+ template = RecurringTaskTemplate.new(role: @role, title: "Test", frequency: "weekly", last_generated_at: nil)
+ assert template.due_for_generation?
+ end
+
+ test "due_for_generation returns false when recently generated" do
+ template = RecurringTaskTemplate.new(role: @role, title: "Test", frequency: "weekly", last_generated_at: Date.current)
+ assert_not template.due_for_generation?
+ end
+end
diff --git a/test/models/role_assignment_test.rb b/test/models/role_assignment_test.rb
new file mode 100644
index 00000000..423778a9
--- /dev/null
+++ b/test/models/role_assignment_test.rb
@@ -0,0 +1,89 @@
+require "test_helper"
+
+class RoleAssignmentTest < ActiveSupport::TestCase
+ def setup
+ @role = roles(:garden_maven)
+ @user = users(:one)
+ end
+
+ test "should require role" do
+ assignment = RoleAssignment.new(user: @user, assignment_type: "holder", starts_at: Date.current)
+ assert_not assignment.valid?
+ assert_includes assignment.errors[:role], "must exist"
+ end
+
+ test "should require user" do
+ assignment = RoleAssignment.new(role: @role, assignment_type: "holder", starts_at: Date.current)
+ assert_not assignment.valid?
+ assert_includes assignment.errors[:user], "must exist"
+ end
+
+ test "should require assignment_type" do
+ assignment = RoleAssignment.new(role: @role, user: @user, assignment_type: nil, starts_at: Date.current)
+ assert_not assignment.valid?
+ assert_includes assignment.errors[:assignment_type], "can't be blank"
+ end
+
+ test "should validate assignment_type inclusion" do
+ assignment = RoleAssignment.new(role: @role, user: @user, assignment_type: "invalid", starts_at: Date.current)
+ assert_not assignment.valid?
+ assert_includes assignment.errors[:assignment_type], "is not included in the list"
+ end
+
+ test "should require starts_at" do
+ assignment = RoleAssignment.new(role: @role, user: @user, assignment_type: "holder", starts_at: nil)
+ assert_not assignment.valid?
+ assert_includes assignment.errors[:starts_at], "can't be blank"
+ end
+
+ test "should allow valid assignment" do
+ assignment = RoleAssignment.new(
+ role: @role,
+ user: @user,
+ assignment_type: "holder",
+ starts_at: Date.current,
+ ends_at: 6.months.from_now.to_date,
+ active: true
+ )
+ assert assignment.valid?
+ end
+
+ test "should scope active assignments" do
+ active = role_assignments(:maven_holder)
+ assert_includes RoleAssignment.active_assignments, active
+ end
+
+ test "should scope by assignment type" do
+ holder = role_assignments(:maven_holder)
+ backup = role_assignments(:maven_backup)
+ assert_includes RoleAssignment.holders, holder
+ assert_includes RoleAssignment.backups, backup
+ end
+
+ test "should detect expiring soon" do
+ assignment = role_assignments(:maven_holder)
+ assignment.update!(ends_at: 20.days.from_now.to_date)
+ assert_includes RoleAssignment.expiring_soon, assignment
+ end
+
+ test "should update role vacancy on create" do
+ role = roles(:facilitator)
+ assert role.vacant?
+
+ RoleAssignment.create!(
+ role: role,
+ user: @user,
+ assignment_type: "holder",
+ starts_at: Date.current,
+ active: true
+ )
+
+ role.reload
+ assert_not role.vacant?
+ end
+
+ test "should have paper_trail" do
+ assignment = role_assignments(:maven_holder)
+ assert assignment.respond_to?(:versions)
+ end
+end
diff --git a/test/models/role_test.rb b/test/models/role_test.rb
new file mode 100644
index 00000000..1b9fa0b1
--- /dev/null
+++ b/test/models/role_test.rb
@@ -0,0 +1,61 @@
+require "test_helper"
+
+class RoleTest < ActiveSupport::TestCase
+ def setup
+ @community = communities(:crow_woods)
+ end
+
+ test "should require title" do
+ role = Role.new(title: nil, role_type: "role")
+ assert_not role.valid?
+ assert_includes role.errors[:title], "can't be blank"
+ end
+
+ test "should require role_type" do
+ role = Role.new(title: "Test Role", role_type: nil)
+ assert_not role.valid?
+ assert_includes role.errors[:role_type], "can't be blank"
+ end
+
+ test "should validate role_type inclusion" do
+ role = Role.new(title: "Test Role", role_type: "invalid")
+ assert_not role.valid?
+ assert_includes role.errors[:role_type], "is not included in the list"
+ end
+
+ test "should validate group inclusion" do
+ role = Role.new(title: "Test Role", role_type: "role", group: "invalid")
+ assert_not role.valid?
+ assert_includes role.errors[:group], "is not included in the list"
+ end
+
+ test "should allow valid role" do
+ role = Role.new(
+ title: "Compost Coordinator",
+ role_type: "role",
+ group: "garden",
+ term_length_months: 6,
+ duties: "Maintain landscaping health"
+ )
+ assert role.valid?
+ end
+
+ test "should default vacant to true" do
+ role = Role.new(title: "Test", role_type: "role")
+ assert role.vacant?
+ end
+
+ test "should scope by role_type" do
+ assert Role.roles.all? { |r| r.role_type == "role" }
+ assert Role.committees.all? { |r| r.role_type == "committee" }
+ end
+
+ test "should scope by group" do
+ assert Role.in_group("hoa_officers").all? { |r| r.group == "hoa_officers" }
+ end
+
+ test "should have paper_trail" do
+ role = roles(:garden_maven)
+ assert role.respond_to?(:versions)
+ end
+end
diff --git a/test/models/task_test.rb b/test/models/task_test.rb
index 2a0cca47..0255cf6a 100644
--- a/test/models/task_test.rb
+++ b/test/models/task_test.rb
@@ -110,6 +110,17 @@ def setup
assert_includes Task.prioritized, active_task
end
+ test "should optionally belong to a role" do
+ role = roles(:garden_maven)
+ task = Task.create!(title: "Winterize spigots", user: @user, role: role)
+ assert_equal role, task.role
+ end
+
+ test "should not require role" do
+ task = Task.new(title: "Unrelated task", user: @user)
+ assert task.valid?
+ end
+
test "should auto-activate tasks with assignment or due date" do
# Task with assignment should be active
assigned_task = Task.create!(
diff --git a/test/models/time_entry_test.rb b/test/models/time_entry_test.rb
new file mode 100644
index 00000000..ed741e9d
--- /dev/null
+++ b/test/models/time_entry_test.rb
@@ -0,0 +1,85 @@
+require "test_helper"
+
+class TimeEntryTest < ActiveSupport::TestCase
+ def setup
+ @user = users(:one)
+ @role = roles(:garden_maven)
+ @task = tasks(:one)
+ end
+
+ test "should require user" do
+ entry = TimeEntry.new(hours: 2.0, logged_on: Date.current, entry_type: "task")
+ assert_not entry.valid?
+ assert_includes entry.errors[:user], "must exist"
+ end
+
+ test "should require hours" do
+ entry = TimeEntry.new(user: @user, logged_on: Date.current, entry_type: "reconciliation", role: @role)
+ assert_not entry.valid?
+ assert_includes entry.errors[:hours], "can't be blank"
+ end
+
+ test "should require positive hours" do
+ entry = TimeEntry.new(user: @user, hours: -1, logged_on: Date.current, entry_type: "reconciliation", role: @role)
+ assert_not entry.valid?
+ assert_includes entry.errors[:hours], "must be greater than 0"
+ end
+
+ test "should require logged_on" do
+ entry = TimeEntry.new(user: @user, hours: 2.0, entry_type: "reconciliation", role: @role)
+ assert_not entry.valid?
+ assert_includes entry.errors[:logged_on], "can't be blank"
+ end
+
+ test "should require entry_type" do
+ entry = TimeEntry.new(user: @user, hours: 2.0, logged_on: Date.current, role: @role)
+ assert_not entry.valid?
+ assert_includes entry.errors[:entry_type], "can't be blank"
+ end
+
+ test "should validate entry_type inclusion" do
+ entry = TimeEntry.new(user: @user, hours: 2.0, logged_on: Date.current, entry_type: "invalid", role: @role)
+ assert_not entry.valid?
+ assert_includes entry.errors[:entry_type], "is not included in the list"
+ end
+
+ test "should allow valid task time entry" do
+ entry = TimeEntry.new(
+ user: @user,
+ task: @task,
+ role: @role,
+ hours: 1.5,
+ logged_on: Date.current,
+ entry_type: "task"
+ )
+ assert entry.valid?
+ end
+
+ test "should allow valid reconciliation entry" do
+ entry = TimeEntry.new(
+ user: @user,
+ role: @role,
+ hours: 3.0,
+ logged_on: Date.current,
+ entry_type: "reconciliation",
+ note: "Side conversations and informal maintenance"
+ )
+ assert entry.valid?
+ end
+
+ test "should scope by entry_type" do
+ assert TimeEntry.task_entries.all? { |e| e.entry_type == "task" }
+ assert TimeEntry.reconciliation_entries.all? { |e| e.entry_type == "reconciliation" }
+ end
+
+ test "should scope by month" do
+ entry = time_entries(:maven_task_entry)
+ results = TimeEntry.for_month(entry.logged_on.year, entry.logged_on.month)
+ assert_includes results, entry
+ end
+
+ test "should calculate total hours for a role" do
+ total = TimeEntry.where(role: @role).sum(:hours)
+ assert total > 0
+ end
+end
diff --git a/test/models/workload_sentiment_test.rb b/test/models/workload_sentiment_test.rb
new file mode 100644
index 00000000..ebf57adf
--- /dev/null
+++ b/test/models/workload_sentiment_test.rb
@@ -0,0 +1,48 @@
+require "test_helper"
+
+class WorkloadSentimentTest < ActiveSupport::TestCase
+ def setup
+ @user = users(:one)
+ @role = roles(:garden_maven)
+ end
+
+ test "should require user" do
+ sentiment = WorkloadSentiment.new(role: @role, sentiment: "just_right", month: Date.current.beginning_of_month)
+ assert_not sentiment.valid?
+ assert_includes sentiment.errors[:user], "must exist"
+ end
+
+ test "should require role" do
+ sentiment = WorkloadSentiment.new(user: @user, sentiment: "just_right", month: Date.current.beginning_of_month)
+ assert_not sentiment.valid?
+ assert_includes sentiment.errors[:role], "must exist"
+ end
+
+ test "should require sentiment" do
+ sentiment = WorkloadSentiment.new(user: @user, role: @role, month: Date.current.beginning_of_month)
+ assert_not sentiment.valid?
+ assert_includes sentiment.errors[:sentiment], "can't be blank"
+ end
+
+ test "should validate sentiment inclusion" do
+ sentiment = WorkloadSentiment.new(user: @user, role: @role, sentiment: "confused", month: Date.current.beginning_of_month)
+ assert_not sentiment.valid?
+ assert_includes sentiment.errors[:sentiment], "is not included in the list"
+ end
+
+ test "should allow valid sentiment" do
+ sentiment = WorkloadSentiment.new(
+ user: @user,
+ role: @role,
+ sentiment: "just_right",
+ month: Date.current.beginning_of_month
+ )
+ assert sentiment.valid?
+ end
+
+ test "should enforce one sentiment per user per role per month" do
+ WorkloadSentiment.create!(user: @user, role: @role, sentiment: "just_right", month: Date.current.beginning_of_month)
+ duplicate = WorkloadSentiment.new(user: @user, role: @role, sentiment: "too_much", month: Date.current.beginning_of_month)
+ assert_not duplicate.valid?
+ end
+end