From 54dc1636be98796fdca33918e5844d451aceff4d Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:03:59 -0700 Subject: [PATCH 01/11] Add Role model with migration, tests, and fixtures Foundation for Roles & Time Tracking feature. Includes validations for title, role_type, and group; scopes for filtering; Discardable soft-delete support; and PaperTrail versioning. --- app/models/role.rb | 29 +++++++++++ db/migrate/20260518020143_create_roles.rb | 24 +++++++++ db/schema.rb | 29 ++++++++++- test/fixtures/roles.yml | 36 +++++++++++++ test/models/role_test.rb | 61 +++++++++++++++++++++++ 5 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 app/models/role.rb create mode 100644 db/migrate/20260518020143_create_roles.rb create mode 100644 test/fixtures/roles.yml create mode 100644 test/models/role_test.rb diff --git a/app/models/role.rb b/app/models/role.rb new file mode 100644 index 00000000..ac72f42e --- /dev/null +++ b/app/models/role.rb @@ -0,0 +1,29 @@ +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 + + # TODO: Uncomment as models are created in subsequent tasks + # 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 } +end 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/schema.rb b/db/schema.rb index a2d3590b..cea798a6 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_020143) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -451,6 +451,30 @@ t.index ["user_id"], name: "index_push_subscriptions_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 @@ -582,6 +606,9 @@ 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 "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", "users" add_foreign_key "tasks", "users", column: "assigned_to_user_id" 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/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 From e5ac649b117764236e9f380e41e4466ca0c9d3e7 Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:07:15 -0700 Subject: [PATCH 02/11] Add RoleAssignment model with migration, validations, and scopes Implements the join model between roles and users with assignment_type (holder/backup/co_holder), date tracking, and active status. Includes a callback to keep role vacancy status in sync and a partial unique index to prevent duplicate active holders. --- app/models/role.rb | 16 +++- app/models/role_assignment.rb | 34 +++++++ app/models/user.rb | 4 + .../20260518020559_create_role_assignments.rb | 19 ++++ db/schema.rb | 21 ++++- test/fixtures/role_assignments.yml | 31 +++++++ test/models/role_assignment_test.rb | 89 +++++++++++++++++++ 7 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 app/models/role_assignment.rb create mode 100644 db/migrate/20260518020559_create_role_assignments.rb create mode 100644 test/fixtures/role_assignments.yml create mode 100644 test/models/role_assignment_test.rb diff --git a/app/models/role.rb b/app/models/role.rb index ac72f42e..4cda8acb 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -7,9 +7,9 @@ class Role < ApplicationRecord 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 # TODO: Uncomment as models are created in subsequent tasks - # 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 @@ -26,4 +26,16 @@ class Role < ApplicationRecord 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/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/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/schema.rb b/db/schema.rb index cea798a6..7b6961be 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_18_020143) do +ActiveRecord::Schema[8.1].define(version: 2026_05_18_020559) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -451,6 +451,23 @@ t.index ["user_id"], name: "index_push_subscriptions_on_user_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 @@ -606,6 +623,8 @@ 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 "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" 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/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 From 7d1777ad5208b8ab435ddb68dd39edf6e7796c65 Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:10:02 -0700 Subject: [PATCH 03/11] Add TimeEntry model with validations, scopes, and migration Create time_entries table for tracking hours spent on roles and tasks. Supports task and reconciliation entry types with month-based scoping. Uncomment has_many :time_entries in Role model. --- app/models/role.rb | 2 +- app/models/time_entry.rb | 22 +++++ .../20260518020905_create_time_entries.rb | 19 +++++ db/schema.rb | 24 +++++- test/fixtures/time_entries.yml | 24 ++++++ test/models/time_entry_test.rb | 85 +++++++++++++++++++ 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 app/models/time_entry.rb create mode 100644 db/migrate/20260518020905_create_time_entries.rb create mode 100644 test/fixtures/time_entries.yml create mode 100644 test/models/time_entry_test.rb diff --git a/app/models/role.rb b/app/models/role.rb index 4cda8acb..8c15e88d 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -11,7 +11,7 @@ class Role < ApplicationRecord has_many :users, through: :role_assignments # TODO: Uncomment as models are created in subsequent tasks # has_many :tasks, dependent: :nullify - # has_many :time_entries, dependent: :destroy + has_many :time_entries, dependent: :destroy # has_many :recurring_task_templates, dependent: :destroy validates :title, presence: true, uniqueness: { scope: :community_id } 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/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/schema.rb b/db/schema.rb index 7b6961be..de08e677 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_18_020559) do +ActiveRecord::Schema[8.1].define(version: 2026_05_18_020905) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -516,6 +516,25 @@ 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 @@ -633,6 +652,9 @@ 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" 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/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 From 1f8f24f4a4cda52d9027cb9df841ea9bb3e1003e Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:12:59 -0700 Subject: [PATCH 04/11] Add RecurringTaskTemplate model for scheduled task generation Create model with validations (title, frequency, role), generate_task! method, and due_for_generation? logic. Frequencies: daily, weekly, biweekly, monthly, quarterly. Role association on generated tasks deferred to Task 5 when role_id column is added to tasks table. --- app/models/recurring_task_template.rb | 38 +++++++++++ app/models/role.rb | 2 +- ...8021145_create_recurring_task_templates.rb | 15 +++++ db/schema.rb | 16 ++++- test/fixtures/recurring_task_templates.yml | 20 ++++++ test/models/recurring_task_template_test.rb | 65 +++++++++++++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 app/models/recurring_task_template.rb create mode 100644 db/migrate/20260518021145_create_recurring_task_templates.rb create mode 100644 test/fixtures/recurring_task_templates.yml create mode 100644 test/models/recurring_task_template_test.rb diff --git a/app/models/recurring_task_template.rb b/app/models/recurring_task_template.rb new file mode 100644 index 00000000..0b467030 --- /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, + # TODO: Add role: role once Task has role_id column (Task 5) + 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 index 8c15e88d..34d3b5a0 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -12,7 +12,7 @@ class Role < ApplicationRecord # TODO: Uncomment as models are created in subsequent tasks # has_many :tasks, dependent: :nullify has_many :time_entries, dependent: :destroy - # has_many :recurring_task_templates, 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 } 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/schema.rb b/db/schema.rb index de08e677..81ad29a0 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_18_020905) do +ActiveRecord::Schema[8.1].define(version: 2026_05_18_021145) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -451,6 +451,19 @@ 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 @@ -642,6 +655,7 @@ 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" 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/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 From 47d39ffa1f0dbb8b506dee05aa833e6f7dd0eb55 Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:14:54 -0700 Subject: [PATCH 05/11] Link tasks to roles with optional belongs_to association Add role_id foreign key to tasks table, enable Role has_many :tasks, permit role_id in tasks controller, and fix RecurringTaskTemplate to include role when generating tasks. --- app/controllers/tasks_controller.rb | 2 +- app/models/recurring_task_template.rb | 2 +- app/models/role.rb | 3 +-- app/models/task.rb | 1 + db/migrate/20260518021347_add_role_id_to_tasks.rb | 5 +++++ db/schema.rb | 5 ++++- test/fixtures/tasks.yml | 1 + test/models/task_test.rb | 11 +++++++++++ 8 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20260518021347_add_role_id_to_tasks.rb 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/models/recurring_task_template.rb b/app/models/recurring_task_template.rb index 0b467030..6e115ee1 100644 --- a/app/models/recurring_task_template.rb +++ b/app/models/recurring_task_template.rb @@ -10,7 +10,7 @@ def generate_task!(assigned_user) task = Task.create!( title: title, description: description, - # TODO: Add role: role once Task has role_id column (Task 5) + role: role, user: assigned_user, assigned_to_user: auto_assign_to_holder ? assigned_user : nil, status: auto_assign_to_holder ? "active" : "backlog" diff --git a/app/models/role.rb b/app/models/role.rb index 34d3b5a0..3ab96271 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -9,8 +9,7 @@ class Role < ApplicationRecord has_many :role_assignments, dependent: :destroy has_many :users, through: :role_assignments - # TODO: Uncomment as models are created in subsequent tasks - # has_many :tasks, dependent: :nullify + has_many :tasks, dependent: :nullify has_many :time_entries, dependent: :destroy has_many :recurring_task_templates, dependent: :destroy 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/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/schema.rb b/db/schema.rb index 81ad29a0..406c47b6 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_18_021145) do +ActiveRecord::Schema[8.1].define(version: 2026_05_18_021347) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -515,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 @@ -526,6 +527,7 @@ 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 @@ -662,6 +664,7 @@ 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" 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/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!( From feaf427bcf64b895989860f93450d8dc85958e6a Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:19:11 -0700 Subject: [PATCH 06/11] Add roles controller, views, and routes Implements full CRUD for community roles with DaisyUI views, soft-delete support, and nested routes for future role_assignments, time_entries, and recurring_task_templates controllers. --- app/controllers/roles_controller.rb | 54 ++++++++++++++++ app/views/roles/_form.html.erb | 46 +++++++++++++ app/views/roles/_role_card.html.erb | 15 +++++ app/views/roles/edit.html.erb | 4 ++ app/views/roles/index.html.erb | 32 +++++++++ app/views/roles/new.html.erb | 4 ++ app/views/roles/show.html.erb | 79 +++++++++++++++++++++++ config/routes.rb | 6 ++ test/controllers/roles_controller_test.rb | 73 +++++++++++++++++++++ 9 files changed, 313 insertions(+) create mode 100644 app/controllers/roles_controller.rb create mode 100644 app/views/roles/_form.html.erb create mode 100644 app/views/roles/_role_card.html.erb create mode 100644 app/views/roles/edit.html.erb create mode 100644 app/views/roles/index.html.erb create mode 100644 app/views/roles/new.html.erb create mode 100644 app/views/roles/show.html.erb create mode 100644 test/controllers/roles_controller_test.rb 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/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/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/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 From ff62c8ec5948f8c491e85dd4d1158a48f97cee1c Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:21:25 -0700 Subject: [PATCH 07/11] Add RoleAssignmentsController with new, create, and destroy actions Implements nested resource controller for assigning users to roles with holder/backup/co_holder types and optional term dates. --- .../role_assignments_controller.rb | 40 ++++++++++++++++++ app/views/role_assignments/_form.html.erb | 36 ++++++++++++++++ app/views/role_assignments/new.html.erb | 4 ++ .../role_assignments_controller_test.rb | 41 +++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 app/controllers/role_assignments_controller.rb create mode 100644 app/views/role_assignments/_form.html.erb create mode 100644 app/views/role_assignments/new.html.erb create mode 100644 test/controllers/role_assignments_controller_test.rb 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/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/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 From 937d6f1706bea61ce592a3f56b97d87982c148bc Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:24:28 -0700 Subject: [PATCH 08/11] Add TimeEntries controller with index, new, create, and destroy actions Implements time logging for roles with task and reconciliation entry types. Includes views for listing entries with monthly summaries and a form for creating new entries. --- app/controllers/time_entries_controller.rb | 49 ++++++++++++++++ app/views/time_entries/_form.html.erb | 43 ++++++++++++++ app/views/time_entries/index.html.erb | 45 +++++++++++++++ app/views/time_entries/new.html.erb | 4 ++ .../time_entries_controller_test.rb | 56 +++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 app/controllers/time_entries_controller.rb create mode 100644 app/views/time_entries/_form.html.erb create mode 100644 app/views/time_entries/index.html.erb create mode 100644 app/views/time_entries/new.html.erb create mode 100644 test/controllers/time_entries_controller_test.rb 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/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/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 From adf633101b66844790b10f3611914c9ea282bbbb Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:26:13 -0700 Subject: [PATCH 09/11] Add roles entry point to tasks tab header Add a Community Roles icon link in the tasks page header alongside the Add button, and include a role select dropdown in the task form so tasks can be optionally associated with a role. --- app/views/tasks/_form.html.erb | 3 ++- app/views/tasks/_role_select.html.erb | 4 ++++ app/views/tasks/index.html.erb | 19 +++++++++++++------ 3 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 app/views/tasks/_role_select.html.erb 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 %> From be15da80b2ca004d47ff3e823b7d43e1f00d5c64 Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:28:25 -0700 Subject: [PATCH 10/11] Add WorkloadSentiment model for tracking role workload feedback Allows users to record monthly sentiment (too_much, just_right, too_little) per role, with uniqueness constraint ensuring one response per user/role/month. --- app/models/workload_sentiment.rb | 13 +++++ ...260518022728_create_workload_sentiments.rb | 13 +++++ db/schema.rb | 16 ++++++- test/fixtures/workload_sentiments.yml | 5 ++ test/models/workload_sentiment_test.rb | 48 +++++++++++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 app/models/workload_sentiment.rb create mode 100644 db/migrate/20260518022728_create_workload_sentiments.rb create mode 100644 test/fixtures/workload_sentiments.yml create mode 100644 test/models/workload_sentiment_test.rb 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/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 406c47b6..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_18_021347) 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" @@ -598,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" @@ -677,4 +689,6 @@ 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/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/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 From d3bfa321332c359125280c6df8998bcb2e90b2d8 Mon Sep 17 00:00:00 2001 From: Ian Cole Date: Sun, 17 May 2026 19:30:07 -0700 Subject: [PATCH 11/11] Add recurring task generation job Job iterates over RecurringTaskTemplate records, checks if each is due for generation based on its frequency, and creates a Task assigned to the role's current active holder. --- app/jobs/generate_recurring_tasks_job.rb | 14 +++++++ .../jobs/generate_recurring_tasks_job_test.rb | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 app/jobs/generate_recurring_tasks_job.rb create mode 100644 test/jobs/generate_recurring_tasks_job_test.rb 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/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