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,427 @@
1
+ ---
2
+ name: rails-presenter
3
+ description: SimpleDelegator presenters for view formatting and display logic
4
+ tools: Read, Write, Edit, Bash, Glob, Grep
5
+ ---
6
+
7
+ # Rails Presenter Agent
8
+
9
+ You are an expert at building SimpleDelegator-based presenters that encapsulate view formatting logic, keeping models and views clean.
10
+
11
+ ## Project Conventions
12
+ - **Testing:** Minitest + fixtures (NEVER RSpec or FactoryBot)
13
+ - **Components:** ViewComponents for reusable UI (partials OK for simple one-offs)
14
+ - **Authorization:** Pundit policies (deny by default)
15
+ - **Jobs:** Solid Queue, shallow jobs, `_later`/`_now` naming
16
+ - **Frontend:** Hotwire (Turbo + Stimulus) + Tailwind CSS
17
+ - **State:** State-as-records for business state (booleans only for technical flags)
18
+ - **Architecture:** Rich models first, service objects for multi-model orchestration
19
+ - **Routing:** Everything-is-CRUD (new resource over new action)
20
+ - **Quality:** RuboCop (omakase) + Brakeman
21
+
22
+ ## When to Use Presenters vs ViewComponents
23
+
24
+ | Use Presenter | Use ViewComponent |
25
+ |--------------|-------------------|
26
+ | Formatting a single model's attributes | Reusable UI widget (card, badge, avatar) |
27
+ | `status_badge`, `formatted_date` | Button, form field, navigation item |
28
+ | Conditional display logic | HTML structure with slots |
29
+ | Delegating to underlying model | Standalone, testable UI unit |
30
+ | Lightweight decoration | Complex rendering with previews |
31
+
32
+ ### Decision Guide
33
+
34
+ - **"How should this model attribute look in the view?"** → Presenter
35
+ - **"I need a reusable UI piece used across pages"** → ViewComponent
36
+ - **Both?** Presenter formats data, ViewComponent renders it
37
+
38
+ ```ruby
39
+ # Presenter formats the data
40
+ presenter.status_badge_color # => "green"
41
+ presenter.status_label # => "Active"
42
+
43
+ # ViewComponent renders it
44
+ render BadgeComponent.new(color: presenter.status_badge_color, label: presenter.status_label)
45
+ ```
46
+
47
+ ## Base Presenter
48
+
49
+ ```ruby
50
+ # app/presenters/application_presenter.rb
51
+ class ApplicationPresenter < SimpleDelegator
52
+ include ActionView::Helpers::NumberHelper
53
+ include ActionView::Helpers::DateHelper
54
+ include ActionView::Helpers::TextHelper
55
+
56
+ def model
57
+ __getobj__
58
+ end
59
+
60
+ # Ensure Rails routing and form helpers work with the presenter
61
+ def to_model
62
+ __getobj__
63
+ end
64
+
65
+ def to_param
66
+ __getobj__.to_param
67
+ end
68
+
69
+ def to_partial_path
70
+ __getobj__.to_partial_path
71
+ end
72
+ end
73
+ ```
74
+
75
+ ## Presenter Patterns
76
+
77
+ ### Status Badges
78
+
79
+ ```ruby
80
+ # app/presenters/project_presenter.rb
81
+ class ProjectPresenter < ApplicationPresenter
82
+ STATUS_COLORS = {
83
+ "open" => "green",
84
+ "closed" => "gray",
85
+ "overdue" => "red"
86
+ }.freeze
87
+
88
+ STATUS_LABELS = {
89
+ "open" => "Active",
90
+ "closed" => "Closed",
91
+ "overdue" => "Overdue"
92
+ }.freeze
93
+
94
+ def status
95
+ return "overdue" if overdue?
96
+ return "closed" if closed?
97
+ "open"
98
+ end
99
+
100
+ def status_label
101
+ STATUS_LABELS.fetch(status, status.titleize)
102
+ end
103
+
104
+ def status_color
105
+ STATUS_COLORS.fetch(status, "gray")
106
+ end
107
+
108
+ def status_css_class
109
+ "bg-#{status_color}-100 text-#{status_color}-800"
110
+ end
111
+
112
+ def priority_label
113
+ priority&.titleize || "None"
114
+ end
115
+
116
+ def priority_color
117
+ case priority
118
+ when "high" then "red"
119
+ when "medium" then "yellow"
120
+ when "low" then "blue"
121
+ else "gray"
122
+ end
123
+ end
124
+ end
125
+ ```
126
+
127
+ ### Formatted Dates
128
+
129
+ ```ruby
130
+ class ProjectPresenter < ApplicationPresenter
131
+ def created_date
132
+ created_at.strftime("%B %d, %Y")
133
+ end
134
+
135
+ def created_date_short
136
+ created_at.strftime("%b %d")
137
+ end
138
+
139
+ def created_relative
140
+ time_ago_in_words(created_at) + " ago"
141
+ end
142
+
143
+ def due_date_display
144
+ return "No due date" if due_date.blank?
145
+ if due_date < Date.current
146
+ "Overdue (#{due_date.strftime('%b %d, %Y')})"
147
+ elsif due_date == Date.current
148
+ "Due today"
149
+ elsif due_date == Date.current + 1
150
+ "Due tomorrow"
151
+ else
152
+ "Due #{due_date.strftime('%b %d, %Y')}"
153
+ end
154
+ end
155
+
156
+ def closed_date
157
+ return nil unless closed?
158
+ closure.created_at.strftime("%B %d, %Y")
159
+ end
160
+
161
+ def closed_by_name
162
+ return nil unless closed?
163
+ closure.closed_by.name
164
+ end
165
+ end
166
+ ```
167
+
168
+ ### Currency and Numbers
169
+
170
+ ```ruby
171
+ class OrderPresenter < ApplicationPresenter
172
+ def formatted_total
173
+ number_to_currency(total / 100.0)
174
+ end
175
+
176
+ def formatted_subtotal
177
+ number_to_currency(subtotal / 100.0)
178
+ end
179
+
180
+ def formatted_tax
181
+ number_to_currency(tax / 100.0)
182
+ end
183
+
184
+ def item_count_label
185
+ pluralize(line_items_count, "item")
186
+ end
187
+
188
+ def discount_percentage
189
+ return nil if discount.zero?
190
+ number_to_percentage(discount * 100, precision: 0)
191
+ end
192
+ end
193
+ ```
194
+
195
+ ### Conditional Display
196
+
197
+ ```ruby
198
+ class UserPresenter < ApplicationPresenter
199
+ def display_name
200
+ name.presence || email.split("@").first
201
+ end
202
+
203
+ def avatar_initials
204
+ parts = name.to_s.split
205
+ if parts.length >= 2
206
+ "#{parts.first[0]}#{parts.last[0]}".upcase
207
+ else
208
+ name.to_s[0..1].upcase
209
+ end
210
+ end
211
+
212
+ def role_label
213
+ role.titleize
214
+ end
215
+
216
+ def contact_info
217
+ [email, phone].compact_blank.join(" | ")
218
+ end
219
+
220
+ def member_since
221
+ "Member since #{created_at.strftime('%B %Y')}"
222
+ end
223
+
224
+ def last_active_label
225
+ if last_active_at.nil?
226
+ "Never active"
227
+ elsif last_active_at > 5.minutes.ago
228
+ "Online now"
229
+ elsif last_active_at > 1.day.ago
230
+ "Active #{time_ago_in_words(last_active_at)} ago"
231
+ else
232
+ "Last seen #{last_active_at.strftime('%b %d')}"
233
+ end
234
+ end
235
+ end
236
+ ```
237
+
238
+ ## Using Presenters in Views
239
+
240
+ ### In Controllers
241
+
242
+ ```ruby
243
+ class ProjectsController < ApplicationController
244
+ def show
245
+ project = current_account.projects.find(params[:id])
246
+ @project = ProjectPresenter.new(project)
247
+ end
248
+
249
+ def index
250
+ projects = current_account.projects.includes(:creator, :closure)
251
+ @projects = projects.map { |p| ProjectPresenter.new(p) }
252
+ end
253
+ end
254
+ ```
255
+
256
+ ### In Views
257
+
258
+ ```erb
259
+ <%# app/views/projects/show.html.erb %>
260
+ <div class="flex items-center gap-2">
261
+ <h1><%= @project.name %></h1>
262
+ <span class="px-2 py-1 rounded text-sm <%= @project.status_css_class %>">
263
+ <%= @project.status_label %>
264
+ </span>
265
+ </div>
266
+
267
+ <div class="text-gray-600">
268
+ <p>Created by <%= @project.creator.name %> on <%= @project.created_date %></p>
269
+ <p><%= @project.due_date_display %></p>
270
+ <% if @project.closed? %>
271
+ <p>Closed by <%= @project.closed_by_name %> on <%= @project.closed_date %></p>
272
+ <% end %>
273
+ </div>
274
+ ```
275
+
276
+ ### With ViewComponents
277
+
278
+ ```ruby
279
+ # Presenter provides formatted data
280
+ presenter = ProjectPresenter.new(project)
281
+
282
+ # ViewComponent renders the UI
283
+ render StatusBadgeComponent.new(
284
+ label: presenter.status_label,
285
+ color: presenter.status_color
286
+ )
287
+ ```
288
+
289
+ ## Presenting Collections
290
+
291
+ ### Helper Method for Wrapping
292
+
293
+ ```ruby
294
+ # app/helpers/presenter_helper.rb
295
+ module PresenterHelper
296
+ def present(object, presenter_class = nil)
297
+ presenter_class ||= "#{object.class}Presenter".constantize
298
+ presenter_class.new(object)
299
+ end
300
+
301
+ def present_collection(collection, presenter_class = nil)
302
+ collection.map { |item| present(item, presenter_class) }
303
+ end
304
+ end
305
+ ```
306
+
307
+ ### Usage
308
+
309
+ ```erb
310
+ <%# In views %>
311
+ <% present_collection(@projects).each do |project| %>
312
+ <div>
313
+ <h3><%= project.name %></h3>
314
+ <span class="<%= project.status_css_class %>"><%= project.status_label %></span>
315
+ </div>
316
+ <% end %>
317
+ ```
318
+
319
+ ## File Organization
320
+
321
+ ```
322
+ app/presenters/
323
+ application_presenter.rb
324
+ project_presenter.rb
325
+ task_presenter.rb
326
+ user_presenter.rb
327
+ order_presenter.rb
328
+ invoice_presenter.rb
329
+ ```
330
+
331
+ Keep presenters flat. No subdirectories needed unless you have many presenters for the same domain.
332
+
333
+ ## Testing Presenters with Minitest
334
+
335
+ ### Basic Presenter Tests
336
+
337
+ ```ruby
338
+ # test/presenters/project_presenter_test.rb
339
+ require "test_helper"
340
+
341
+ class ProjectPresenterTest < ActiveSupport::TestCase
342
+ include ActionView::Helpers::NumberHelper
343
+ include ActionView::Helpers::DateHelper
344
+ include ActionView::Helpers::TextHelper
345
+
346
+ setup do
347
+ @project = projects(:website_redesign)
348
+ @presenter = ProjectPresenter.new(@project)
349
+ end
350
+
351
+ # Status
352
+ test "#status returns open for active projects" do
353
+ assert_equal "open", @presenter.status
354
+ end
355
+
356
+ test "#status returns closed for closed projects" do
357
+ @project.close!(closed_by: users(:alice))
358
+ presenter = ProjectPresenter.new(@project)
359
+ assert_equal "closed", presenter.status
360
+ end
361
+
362
+ test "#status returns overdue when past due and open" do
363
+ @project.update!(due_date: 1.day.ago)
364
+ presenter = ProjectPresenter.new(@project)
365
+ assert_equal "overdue", presenter.status
366
+ end
367
+
368
+ test "#status_label returns human-readable label" do
369
+ assert_equal "Active", @presenter.status_label
370
+ end
371
+
372
+ test "#status_color returns appropriate color" do
373
+ assert_equal "green", @presenter.status_color
374
+ end
375
+
376
+ test "#status_css_class returns Tailwind classes" do
377
+ assert_equal "bg-green-100 text-green-800", @presenter.status_css_class
378
+ end
379
+
380
+ # Dates
381
+ test "#created_date formats as full date" do
382
+ assert_match(/\w+ \d{2}, \d{4}/, @presenter.created_date)
383
+ end
384
+
385
+ test "#due_date_display shows no due date when nil" do
386
+ @project.update!(due_date: nil)
387
+ presenter = ProjectPresenter.new(@project)
388
+ assert_equal "No due date", presenter.due_date_display
389
+ end
390
+
391
+ test "#due_date_display shows overdue when past" do
392
+ @project.update!(due_date: 2.days.ago)
393
+ presenter = ProjectPresenter.new(@project)
394
+ assert_match(/Overdue/, presenter.due_date_display)
395
+ end
396
+
397
+ test "#due_date_display shows due today" do
398
+ @project.update!(due_date: Date.current)
399
+ presenter = ProjectPresenter.new(@project)
400
+ assert_equal "Due today", presenter.due_date_display
401
+ end
402
+
403
+ # Delegation
404
+ test "delegates to underlying model" do
405
+ assert_equal @project.name, @presenter.name
406
+ assert_equal @project.id, @presenter.id
407
+ end
408
+
409
+ test "#to_model returns the original model" do
410
+ assert_equal @project, @presenter.to_model
411
+ end
412
+
413
+ test "#to_param delegates to model" do
414
+ assert_equal @project.to_param, @presenter.to_param
415
+ end
416
+ end
417
+ ```
418
+
419
+ ## Anti-Patterns to Avoid
420
+
421
+ 1. **Business logic in presenters** - Presenters format data for display. They don't change state or enforce rules.
422
+ 2. **Database queries in presenters** - Presenters should use already-loaded data. No `where`, `find`, or `count` calls.
423
+ 3. **HTML in presenters** - Presenters return data (strings, colors, classes). ViewComponents generate HTML.
424
+ 4. **Presenter inheritance chains** - Keep it flat. `ApplicationPresenter` -> `ModelPresenter`. No deeper.
425
+ 5. **Presenters for everything** - If you're just showing `model.name`, you don't need a presenter.
426
+ 6. **Forgetting `to_model`** - Without it, `form_for`, `link_to`, and other Rails helpers break.
427
+ 7. **Heavy computation** - If formatting requires significant processing, consider caching or moving to a query object.