source_monitor 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/agents/rails-concern.md +464 -0
  3. data/.claude/agents/rails-controller.md +424 -0
  4. data/.claude/agents/rails-hotwire.md +446 -0
  5. data/.claude/agents/rails-implement.md +374 -0
  6. data/.claude/agents/rails-job.md +334 -0
  7. data/.claude/agents/rails-lint.md +294 -0
  8. data/.claude/agents/rails-mailer.md +371 -0
  9. data/.claude/agents/rails-migration.md +449 -0
  10. data/.claude/agents/rails-model.md +420 -0
  11. data/.claude/agents/rails-policy.md +443 -0
  12. data/.claude/agents/rails-presenter.md +427 -0
  13. data/.claude/agents/rails-query.md +412 -0
  14. data/.claude/agents/rails-review.md +490 -0
  15. data/.claude/agents/rails-service.md +458 -0
  16. data/.claude/agents/rails-state-records.md +465 -0
  17. data/.claude/agents/rails-tdd.md +314 -0
  18. data/.claude/agents/rails-test.md +441 -0
  19. data/.claude/agents/rails-view-component.md +418 -0
  20. data/.claude/hooks/block-secrets.sh +52 -0
  21. data/.claude/settings.json +85 -0
  22. data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
  23. data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
  24. data/.claude/skills/active-storage-setup/SKILL.md +311 -0
  25. data/.claude/skills/api-versioning/SKILL.md +294 -0
  26. data/.claude/skills/authentication-flow/SKILL.md +335 -0
  27. data/.claude/skills/authentication-flow/reference/current.md +248 -0
  28. data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
  29. data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
  30. data/.claude/skills/authorization-pundit/SKILL.md +462 -0
  31. data/.claude/skills/caching-strategies/SKILL.md +350 -0
  32. data/.claude/skills/database-migrations/SKILL.md +354 -0
  33. data/.claude/skills/form-object-patterns/SKILL.md +399 -0
  34. data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
  35. data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
  36. data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
  37. data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
  38. data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
  39. data/.claude/skills/i18n-patterns/SKILL.md +320 -0
  40. data/.claude/skills/install/SKILL.md +367 -0
  41. data/.claude/skills/performance-optimization/SKILL.md +311 -0
  42. data/.claude/skills/rails-architecture/SKILL.md +259 -0
  43. data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
  44. data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
  45. data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
  46. data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
  47. data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
  48. data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
  49. data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
  50. data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
  51. data/.claude/skills/rails-concern/SKILL.md +399 -0
  52. data/.claude/skills/rails-controller/SKILL.md +336 -0
  53. data/.claude/skills/rails-model-generator/SKILL.md +321 -0
  54. data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
  55. data/.claude/skills/rails-presenter/SKILL.md +274 -0
  56. data/.claude/skills/rails-query-object/SKILL.md +289 -0
  57. data/.claude/skills/rails-service-object/SKILL.md +349 -0
  58. data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
  59. data/.claude/skills/tdd-cycle/SKILL.md +359 -0
  60. data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
  61. data/.gitignore +1 -0
  62. data/.rubocop.yml +2 -0
  63. data/.ruby-version +1 -1
  64. data/.vbw-planning/.notification-log.jsonl +192 -0
  65. data/.vbw-planning/.session-log.jsonl +871 -0
  66. data/.vbw-planning/PROJECT.md +51 -0
  67. data/.vbw-planning/REQUIREMENTS.md +50 -0
  68. data/.vbw-planning/SHIPPED.md +28 -0
  69. data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
  70. data/.vbw-planning/codebase/CONCERNS.md +99 -0
  71. data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
  72. data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
  73. data/.vbw-planning/codebase/INDEX.md +86 -0
  74. data/.vbw-planning/codebase/META.md +42 -0
  75. data/.vbw-planning/codebase/PATTERNS.md +262 -0
  76. data/.vbw-planning/codebase/STACK.md +101 -0
  77. data/.vbw-planning/codebase/STRUCTURE.md +324 -0
  78. data/.vbw-planning/codebase/TESTING.md +154 -0
  79. data/.vbw-planning/config.json +12 -0
  80. data/.vbw-planning/discovery.json +24 -0
  81. data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
  82. data/.vbw-planning/milestones/default/STATE.md +83 -0
  83. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
  84. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
  85. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
  86. data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
  87. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
  88. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
  89. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
  90. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
  91. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
  92. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
  93. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
  94. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
  95. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
  96. data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
  97. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
  98. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
  99. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
  100. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
  101. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
  102. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
  103. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
  104. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
  105. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
  106. data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
  107. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
  108. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
  109. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
  110. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
  111. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
  112. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
  113. data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
  114. data/CHANGELOG.md +28 -0
  115. data/CLAUDE.md +179 -0
  116. data/Gemfile +8 -0
  117. data/Gemfile.lock +114 -101
  118. data/Rakefile +2 -0
  119. data/app/assets/builds/source_monitor/application.css +2076 -0
  120. data/app/assets/builds/source_monitor/application.js +2758 -0
  121. data/app/assets/builds/source_monitor/application.js.map +7 -0
  122. data/app/controllers/source_monitor/application_controller.rb +2 -0
  123. data/app/controllers/source_monitor/health_controller.rb +2 -0
  124. data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
  125. data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
  126. data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
  127. data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
  128. data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
  129. data/app/controllers/source_monitor/items_controller.rb +2 -0
  130. data/app/controllers/source_monitor/sources_controller.rb +0 -14
  131. data/app/helpers/source_monitor/application_helper.rb +4 -112
  132. data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
  133. data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
  134. data/app/jobs/source_monitor/application_job.rb +2 -0
  135. data/app/models/source_monitor/application_record.rb +2 -0
  136. data/app/models/source_monitor/log_entry.rb +0 -2
  137. data/config/coverage_baseline.json +217 -1862
  138. data/config/routes.rb +2 -0
  139. data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
  140. data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
  141. data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
  142. data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
  143. data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
  144. data/lib/source_monitor/assets/bundler.rb +2 -0
  145. data/lib/source_monitor/assets.rb +2 -0
  146. data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
  147. data/lib/source_monitor/configuration/events.rb +60 -0
  148. data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
  149. data/lib/source_monitor/configuration/health_settings.rb +27 -0
  150. data/lib/source_monitor/configuration/http_settings.rb +43 -0
  151. data/lib/source_monitor/configuration/model_definition.rb +108 -0
  152. data/lib/source_monitor/configuration/models.rb +36 -0
  153. data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
  154. data/lib/source_monitor/configuration/retention_settings.rb +45 -0
  155. data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
  156. data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
  157. data/lib/source_monitor/configuration/validation_definition.rb +32 -0
  158. data/lib/source_monitor/configuration.rb +12 -579
  159. data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
  160. data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
  161. data/lib/source_monitor/dashboard/queries.rb +2 -195
  162. data/lib/source_monitor/engine.rb +2 -0
  163. data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
  164. data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
  165. data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
  166. data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
  167. data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
  168. data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
  169. data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
  170. data/lib/source_monitor/items/item_creator.rb +28 -455
  171. data/lib/source_monitor/setup/bundle_installer.rb +2 -0
  172. data/lib/source_monitor/setup/cli.rb +2 -0
  173. data/lib/source_monitor/setup/dependency_checker.rb +2 -0
  174. data/lib/source_monitor/setup/detectors.rb +2 -0
  175. data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
  176. data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
  177. data/lib/source_monitor/setup/install_generator.rb +2 -0
  178. data/lib/source_monitor/setup/migration_installer.rb +2 -0
  179. data/lib/source_monitor/setup/node_installer.rb +2 -0
  180. data/lib/source_monitor/setup/prompter.rb +2 -0
  181. data/lib/source_monitor/setup/requirements.rb +2 -0
  182. data/lib/source_monitor/setup/shell_runner.rb +2 -0
  183. data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
  184. data/lib/source_monitor/setup/verification/printer.rb +2 -0
  185. data/lib/source_monitor/setup/verification/result.rb +2 -0
  186. data/lib/source_monitor/setup/verification/runner.rb +2 -0
  187. data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
  188. data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
  189. data/lib/source_monitor/setup/workflow.rb +2 -0
  190. data/lib/source_monitor/version.rb +3 -1
  191. data/lib/source_monitor.rb +140 -58
  192. data/lib/tasks/source_monitor_assets.rake +2 -0
  193. data/lib/tasks/source_monitor_setup.rake +2 -0
  194. data/lib/tasks/source_monitor_tasks.rake +2 -0
  195. data/source_monitor.gemspec +3 -1
  196. metadata +144 -4
@@ -0,0 +1,399 @@
1
+ ---
2
+ name: form-object-patterns
3
+ description: Creates form objects for complex form handling with TDD. Use when building multi-model forms, search forms, wizard forms, or when user mentions form objects, complex forms, virtual models, or non-persisted forms.
4
+ allowed-tools: Read, Write, Edit, Bash, Glob, Grep
5
+ ---
6
+
7
+ # Form Object Patterns for Rails 8
8
+
9
+ ## Overview
10
+
11
+ Form objects encapsulate complex form logic:
12
+ - Multi-model forms (user + profile + address)
13
+ - Search/filter forms (non-persisted)
14
+ - Wizard/multi-step forms
15
+ - Virtual attributes with validation
16
+ - Decoupled from ActiveRecord models
17
+
18
+ ## When to Use Form Objects
19
+
20
+ | Scenario | Use Form Object? |
21
+ |----------|-----------------|
22
+ | Single model CRUD | No (use model) |
23
+ | Multi-model creation | Yes |
24
+ | Complex validations across models | Yes |
25
+ | Search/filter forms | Yes |
26
+ | Wizard/multi-step forms | Yes |
27
+ | API params transformation | Yes |
28
+ | Contact forms (no persistence) | Yes |
29
+
30
+ ## TDD Workflow
31
+
32
+ ```
33
+ Form Object Progress:
34
+ - [ ] Step 1: Define form requirements
35
+ - [ ] Step 2: Write form object test (RED)
36
+ - [ ] Step 3: Run test (fails)
37
+ - [ ] Step 4: Create form object
38
+ - [ ] Step 5: Run test (GREEN)
39
+ - [ ] Step 6: Wire up controller
40
+ - [ ] Step 7: Create view form
41
+ ```
42
+
43
+ ## Base Form Class
44
+
45
+ ```ruby
46
+ # app/forms/application_form.rb
47
+ class ApplicationForm
48
+ include ActiveModel::Model
49
+ include ActiveModel::Attributes
50
+ include ActiveModel::Validations
51
+
52
+ def self.model_name
53
+ ActiveModel::Name.new(self, nil, name.chomp("Form"))
54
+ end
55
+
56
+ def persisted?
57
+ false
58
+ end
59
+
60
+ def save
61
+ return false unless valid?
62
+ persist!
63
+ true
64
+ rescue ActiveRecord::RecordInvalid => e
65
+ errors.add(:base, e.message)
66
+ false
67
+ end
68
+
69
+ private
70
+
71
+ def persist!
72
+ raise NotImplementedError
73
+ end
74
+ end
75
+ ```
76
+
77
+ ## Pattern 1: Multi-Model Registration Form
78
+
79
+ ### Test First (RED)
80
+
81
+ ```ruby
82
+ # test/forms/registration_form_test.rb
83
+ require "test_helper"
84
+
85
+ class RegistrationFormTest < ActiveSupport::TestCase
86
+ test "validates presence of email" do
87
+ form = RegistrationForm.new(email: "")
88
+ assert_not form.valid?
89
+ assert_includes form.errors[:email], "can't be blank"
90
+ end
91
+
92
+ test "validates presence of password" do
93
+ form = RegistrationForm.new(password: "")
94
+ assert_not form.valid?
95
+ assert_includes form.errors[:password], "can't be blank"
96
+ end
97
+
98
+ test "validates password minimum length" do
99
+ form = RegistrationForm.new(password: "short")
100
+ assert_not form.valid?
101
+ assert form.errors[:password].any? { |e| e.include?("too short") }
102
+ end
103
+
104
+ test "#save with valid params returns true" do
105
+ form = RegistrationForm.new(
106
+ email: "user@example.com",
107
+ password: "password123",
108
+ password_confirmation: "password123",
109
+ company_name: "Acme Inc"
110
+ )
111
+
112
+ assert form.save
113
+ end
114
+
115
+ test "#save creates a user" do
116
+ form = RegistrationForm.new(
117
+ email: "new-user@example.com",
118
+ password: "password123",
119
+ password_confirmation: "password123",
120
+ company_name: "Acme Inc"
121
+ )
122
+
123
+ assert_difference("User.count", 1) { form.save }
124
+ end
125
+
126
+ test "#save creates an account" do
127
+ form = RegistrationForm.new(
128
+ email: "new-account@example.com",
129
+ password: "password123",
130
+ password_confirmation: "password123",
131
+ company_name: "Acme Inc"
132
+ )
133
+
134
+ assert_difference("Account.count", 1) { form.save }
135
+ end
136
+
137
+ test "#save associates user with account" do
138
+ form = RegistrationForm.new(
139
+ email: "assoc@example.com",
140
+ password: "password123",
141
+ password_confirmation: "password123",
142
+ company_name: "Acme Inc"
143
+ )
144
+ form.save
145
+ assert_equal form.user.account, form.account
146
+ end
147
+
148
+ test "#save with invalid params returns false" do
149
+ form = RegistrationForm.new(email: "", password: "short")
150
+ assert_not form.save
151
+ end
152
+
153
+ test "#save with invalid params does not create records" do
154
+ form = RegistrationForm.new(email: "", password: "short")
155
+ assert_no_difference("User.count") { form.save }
156
+ end
157
+
158
+ test "#save with duplicate email returns false" do
159
+ existing = users(:one)
160
+ form = RegistrationForm.new(
161
+ email: existing.email_address,
162
+ password: "password123",
163
+ password_confirmation: "password123",
164
+ company_name: "Acme Inc"
165
+ )
166
+
167
+ assert_not form.save
168
+ assert_includes form.errors[:email], "has already been taken"
169
+ end
170
+ end
171
+ ```
172
+
173
+ ### Implementation (GREEN)
174
+
175
+ ```ruby
176
+ # app/forms/registration_form.rb
177
+ class RegistrationForm < ApplicationForm
178
+ attribute :email, :string
179
+ attribute :password, :string
180
+ attribute :password_confirmation, :string
181
+ attribute :company_name, :string
182
+ attribute :phone, :string
183
+
184
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
185
+ validates :password, presence: true, length: { minimum: 8 }
186
+ validates :password_confirmation, presence: true
187
+ validates :company_name, presence: true
188
+ validate :passwords_match
189
+ validate :email_unique
190
+
191
+ attr_reader :user, :account
192
+
193
+ private
194
+
195
+ def persist!
196
+ ActiveRecord::Base.transaction do
197
+ @account = Account.create!(name: company_name)
198
+ @user = User.create!(
199
+ email_address: email,
200
+ password: password,
201
+ account: @account,
202
+ phone: phone
203
+ )
204
+ end
205
+ end
206
+
207
+ def passwords_match
208
+ return if password == password_confirmation
209
+ errors.add(:password_confirmation, "doesn't match password")
210
+ end
211
+
212
+ def email_unique
213
+ return unless User.exists?(email_address: email&.downcase)
214
+ errors.add(:email, "has already been taken")
215
+ end
216
+ end
217
+ ```
218
+
219
+ ## Pattern 2: Search/Filter Form
220
+
221
+ ### Test First
222
+
223
+ ```ruby
224
+ # test/forms/event_search_form_test.rb
225
+ require "test_helper"
226
+
227
+ class EventSearchFormTest < ActiveSupport::TestCase
228
+ setup do
229
+ @account = accounts(:one)
230
+ end
231
+
232
+ test "#results returns all account events without filters" do
233
+ form = EventSearchForm.new(account: @account, params: {})
234
+ results = form.results
235
+
236
+ results.each do |event|
237
+ assert_equal @account.id, event.account_id
238
+ end
239
+ end
240
+
241
+ test "#results excludes other account events" do
242
+ form = EventSearchForm.new(account: @account, params: {})
243
+ other_event = events(:other_account)
244
+
245
+ assert_not_includes form.results, other_event
246
+ end
247
+
248
+ test "#results filters by event_type" do
249
+ form = EventSearchForm.new(account: @account, params: { event_type: "wedding" })
250
+ form.results.each do |event|
251
+ assert_equal "wedding", event.event_type
252
+ end
253
+ end
254
+
255
+ test "#any_filters? returns true with filters" do
256
+ form = EventSearchForm.new(account: @account, params: { query: "test" })
257
+ assert form.any_filters?
258
+ end
259
+
260
+ test "#any_filters? returns false without filters" do
261
+ form = EventSearchForm.new(account: @account, params: {})
262
+ assert_not form.any_filters?
263
+ end
264
+ end
265
+ ```
266
+
267
+ ### Implementation
268
+
269
+ ```ruby
270
+ # app/forms/event_search_form.rb
271
+ class EventSearchForm < ApplicationForm
272
+ attribute :query, :string
273
+ attribute :event_type, :string
274
+ attribute :status, :string
275
+ attribute :start_date, :date
276
+ attribute :end_date, :date
277
+
278
+ attr_reader :account
279
+
280
+ def initialize(account:, params: {})
281
+ @account = account
282
+ super(params)
283
+ end
284
+
285
+ def results
286
+ scope = account.events
287
+ scope = apply_search(scope)
288
+ scope = apply_type_filter(scope)
289
+ scope = apply_status_filter(scope)
290
+ scope = apply_date_filter(scope)
291
+ scope.order(event_date: :desc)
292
+ end
293
+
294
+ def any_filters?
295
+ [query, event_type, status, start_date, end_date].any?(&:present?)
296
+ end
297
+
298
+ private
299
+
300
+ def apply_search(scope)
301
+ return scope if query.blank?
302
+ scope.where("name LIKE :q OR description LIKE :q", q: "%#{sanitize_like(query)}%")
303
+ end
304
+
305
+ def apply_type_filter(scope)
306
+ return scope if event_type.blank?
307
+ scope.where(event_type: event_type)
308
+ end
309
+
310
+ def apply_status_filter(scope)
311
+ return scope if status.blank?
312
+ scope.where(status: status)
313
+ end
314
+
315
+ def apply_date_filter(scope)
316
+ scope = scope.where("event_date >= ?", start_date) if start_date.present?
317
+ scope = scope.where("event_date <= ?", end_date) if end_date.present?
318
+ scope
319
+ end
320
+
321
+ def sanitize_like(term)
322
+ term.gsub(/[%_]/) { |x| "\\#{x}" }
323
+ end
324
+ end
325
+ ```
326
+
327
+ ## Pattern 3: Wizard/Multi-Step Form
328
+
329
+ ```ruby
330
+ # app/forms/wizard/base_form.rb
331
+ module Wizard
332
+ class BaseForm < ApplicationForm
333
+ def self.steps
334
+ raise NotImplementedError
335
+ end
336
+
337
+ def current_step
338
+ raise NotImplementedError
339
+ end
340
+
341
+ def first_step?
342
+ current_step == self.class.steps.first
343
+ end
344
+
345
+ def last_step?
346
+ current_step == self.class.steps.last
347
+ end
348
+
349
+ def progress_percentage
350
+ steps = self.class.steps
351
+ ((steps.index(current_step) + 1).to_f / steps.size * 100).round
352
+ end
353
+ end
354
+ end
355
+ ```
356
+
357
+ ## Controller Integration
358
+
359
+ ```ruby
360
+ class RegistrationsController < ApplicationController
361
+ allow_unauthenticated_access
362
+
363
+ def new
364
+ @form = RegistrationForm.new
365
+ end
366
+
367
+ def create
368
+ @form = RegistrationForm.new(registration_params)
369
+
370
+ if @form.save
371
+ start_new_session_for(@form.user)
372
+ redirect_to dashboard_path, notice: t(".success")
373
+ else
374
+ render :new, status: :unprocessable_entity
375
+ end
376
+ end
377
+
378
+ private
379
+
380
+ def registration_params
381
+ params.require(:registration).permit(
382
+ :email, :password, :password_confirmation,
383
+ :company_name, :phone
384
+ )
385
+ end
386
+ end
387
+ ```
388
+
389
+ ## Checklist
390
+
391
+ - [ ] Test written first (RED)
392
+ - [ ] Extends `ApplicationForm` or includes `ActiveModel::Model`
393
+ - [ ] Attributes declared with types
394
+ - [ ] Validations defined
395
+ - [ ] `#save` method with transaction (if multi-model)
396
+ - [ ] Controller uses form object
397
+ - [ ] View uses `form_with model: @form`
398
+ - [ ] Error handling in place
399
+ - [ ] All tests GREEN
@@ -0,0 +1,247 @@
1
+ ---
2
+ name: hotwire-patterns
3
+ description: Implements Hotwire patterns with Turbo Frames, Turbo Streams, and Stimulus controllers. Use when building interactive UIs, real-time updates, form handling, partial page updates, or when user mentions Turbo, Stimulus, or Hotwire.
4
+ allowed-tools: Read, Write, Edit, Bash, Glob, Grep
5
+ ---
6
+
7
+ # Hotwire Patterns for Rails 8
8
+
9
+ ## Overview
10
+
11
+ Hotwire = HTML Over The Wire. Build modern web apps without writing much JavaScript.
12
+
13
+ | Component | Purpose | Use Case |
14
+ |-----------|---------|----------|
15
+ | **Turbo Drive** | SPA-like navigation | Automatic, no code needed |
16
+ | **Turbo Frames** | Partial page updates | Inline editing, tabbed content |
17
+ | **Turbo Streams** | Real-time DOM updates | Live updates, flash messages |
18
+ | **Stimulus** | JavaScript sprinkles | Toggles, forms, interactions |
19
+
20
+ ## When to Use Each Pattern
21
+
22
+ | Scenario | Pattern |
23
+ |----------|---------|
24
+ | Inline edit | Turbo Frame |
25
+ | Form submission with multiple updates | Turbo Stream |
26
+ | Real-time feed | Turbo Stream + ActionCable |
27
+ | Toggle visibility | Stimulus |
28
+ | Form validation | Stimulus |
29
+ | Infinite scroll | Turbo Frame + lazy loading |
30
+ | Modal dialogs | Turbo Frame |
31
+ | Flash messages | Turbo Stream |
32
+
33
+ ## References
34
+
35
+ - See [turbo-frames.md](reference/turbo-frames.md) for frame patterns
36
+ - See [turbo-streams.md](reference/turbo-streams.md) for stream patterns
37
+ - See [stimulus.md](reference/stimulus.md) for controller patterns
38
+ - See [tailwind-integration.md](reference/tailwind-integration.md) for styling
39
+
40
+ ## Turbo Frames
41
+
42
+ ### Basic Frame
43
+
44
+ ```erb
45
+ <%# app/views/posts/index.html.erb %>
46
+ <%= turbo_frame_tag "posts" do %>
47
+ <%= render @posts %>
48
+ <%= link_to "Load More", posts_path(page: 2) %>
49
+ <% end %>
50
+ ```
51
+
52
+ ### Inline Editing
53
+
54
+ ```erb
55
+ <%# _post.html.erb %>
56
+ <%= turbo_frame_tag dom_id(post) do %>
57
+ <article>
58
+ <h2><%= post.title %></h2>
59
+ <%= link_to "Edit", edit_post_path(post) %>
60
+ </article>
61
+ <% end %>
62
+
63
+ <%# edit.html.erb %>
64
+ <%= turbo_frame_tag dom_id(@post) do %>
65
+ <%= form_with model: @post do |f| %>
66
+ <%= f.text_field :title %>
67
+ <%= f.submit "Save" %>
68
+ <%= link_to "Cancel", @post %>
69
+ <% end %>
70
+ <% end %>
71
+ ```
72
+
73
+ ### Lazy Loading
74
+
75
+ ```erb
76
+ <%= turbo_frame_tag "comments", src: post_comments_path(@post), loading: :lazy do %>
77
+ <p>Loading comments...</p>
78
+ <% end %>
79
+ ```
80
+
81
+ ## Turbo Streams
82
+
83
+ ### From Controller
84
+
85
+ ```erb
86
+ <%# app/views/posts/create.turbo_stream.erb %>
87
+ <%= turbo_stream.prepend "posts", @post %>
88
+ <%= turbo_stream.update "flash", partial: "shared/flash" %>
89
+ ```
90
+
91
+ ### Stream Actions
92
+
93
+ ```ruby
94
+ turbo_stream.append "posts", @post # Add to end
95
+ turbo_stream.prepend "posts", @post # Add to start
96
+ turbo_stream.replace dom_id(@post), @post # Replace element
97
+ turbo_stream.update dom_id(@post), @post # Replace inner HTML
98
+ turbo_stream.remove dom_id(@post) # Remove element
99
+ ```
100
+
101
+ ### Flash Messages with Streams
102
+
103
+ ```ruby
104
+ # app/controllers/application_controller.rb
105
+ class ApplicationController < ActionController::Base
106
+ after_action :flash_to_turbo_stream, if: -> { request.format.turbo_stream? }
107
+
108
+ private
109
+
110
+ def flash_to_turbo_stream
111
+ flash.each do |type, message|
112
+ flash.now[type] = message
113
+ end
114
+ end
115
+ end
116
+ ```
117
+
118
+ ## Stimulus Controllers
119
+
120
+ ### Basic Controller
121
+
122
+ ```javascript
123
+ // app/javascript/controllers/toggle_controller.js
124
+ import { Controller } from "@hotwired/stimulus"
125
+
126
+ export default class extends Controller {
127
+ static targets = ["content"]
128
+
129
+ toggle() {
130
+ this.contentTarget.classList.toggle("hidden")
131
+ }
132
+ }
133
+ ```
134
+
135
+ ```erb
136
+ <div data-controller="toggle">
137
+ <button data-action="toggle#toggle">Toggle</button>
138
+ <div data-toggle-target="content">Hidden content</div>
139
+ </div>
140
+ ```
141
+
142
+ ### Form Controller
143
+
144
+ ```javascript
145
+ // app/javascript/controllers/form_controller.js
146
+ import { Controller } from "@hotwired/stimulus"
147
+
148
+ export default class extends Controller {
149
+ static targets = ["submit"]
150
+
151
+ enableSubmit() {
152
+ this.submitTarget.disabled = false
153
+ }
154
+
155
+ disableSubmit() {
156
+ this.submitTarget.disabled = true
157
+ }
158
+ }
159
+ ```
160
+
161
+ ## Testing Hotwire
162
+
163
+ ### Turbo Stream Response Tests
164
+
165
+ ```ruby
166
+ # test/controllers/posts_controller_test.rb
167
+ require "test_helper"
168
+
169
+ class PostsControllerTest < ActionDispatch::IntegrationTest
170
+ setup do
171
+ sign_in users(:one)
172
+ end
173
+
174
+ test "create returns turbo stream response" do
175
+ post posts_path,
176
+ params: { post: { title: "Test" } },
177
+ headers: { "Accept" => "text/vnd.turbo-stream.html" }
178
+
179
+ assert_response :success
180
+ assert_equal "text/vnd.turbo-stream.html", response.media_type
181
+ assert_includes response.body, "turbo-stream"
182
+ end
183
+
184
+ test "create with HTML format redirects" do
185
+ post posts_path, params: { post: { title: "Test" } }
186
+
187
+ assert_redirected_to post_path(Post.last)
188
+ end
189
+ end
190
+ ```
191
+
192
+ ### System Tests (with JavaScript)
193
+
194
+ ```ruby
195
+ # test/system/posts_test.rb
196
+ require "application_system_test_case"
197
+
198
+ class PostsSystemTest < ApplicationSystemTestCase
199
+ setup do
200
+ @user = users(:one)
201
+ sign_in @user
202
+ end
203
+
204
+ test "updates post inline with Turbo Frame" do
205
+ post = posts(:one)
206
+
207
+ visit posts_path
208
+ within("#post_#{post.id}") do
209
+ click_link "Edit"
210
+ fill_in "Title", with: "Updated"
211
+ click_button "Save"
212
+ end
213
+
214
+ assert_text "Updated"
215
+ assert_no_text post.title
216
+ end
217
+
218
+ test "adds comment with Turbo Stream" do
219
+ post = posts(:one)
220
+
221
+ visit post_path(post)
222
+ fill_in "Comment", with: "Great post!"
223
+ click_button "Add Comment"
224
+
225
+ within("#comments") do
226
+ assert_text "Great post!"
227
+ end
228
+ end
229
+ end
230
+ ```
231
+
232
+ ## Debugging Tips
233
+
234
+ 1. **Frame not updating?** Check frame IDs match exactly
235
+ 2. **Stream not working?** Verify `Accept` header includes turbo-stream
236
+ 3. **Stimulus not firing?** Check controller name matches file name
237
+ 4. **Events not working?** Use `data-action="event->controller#method"`
238
+
239
+ ## Checklist
240
+
241
+ - [ ] Identify update scope (full page vs partial)
242
+ - [ ] Choose pattern (Frame vs Stream vs Stimulus)
243
+ - [ ] Implement server response
244
+ - [ ] Add client-side markup
245
+ - [ ] Test with and without JavaScript
246
+ - [ ] Write system test for interactive behavior
247
+ - [ ] All tests GREEN