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.
- checksums.yaml +4 -4
- data/.claude/agents/rails-concern.md +464 -0
- data/.claude/agents/rails-controller.md +424 -0
- data/.claude/agents/rails-hotwire.md +446 -0
- data/.claude/agents/rails-implement.md +374 -0
- data/.claude/agents/rails-job.md +334 -0
- data/.claude/agents/rails-lint.md +294 -0
- data/.claude/agents/rails-mailer.md +371 -0
- data/.claude/agents/rails-migration.md +449 -0
- data/.claude/agents/rails-model.md +420 -0
- data/.claude/agents/rails-policy.md +443 -0
- data/.claude/agents/rails-presenter.md +427 -0
- data/.claude/agents/rails-query.md +412 -0
- data/.claude/agents/rails-review.md +490 -0
- data/.claude/agents/rails-service.md +458 -0
- data/.claude/agents/rails-state-records.md +465 -0
- data/.claude/agents/rails-tdd.md +314 -0
- data/.claude/agents/rails-test.md +441 -0
- data/.claude/agents/rails-view-component.md +418 -0
- data/.claude/hooks/block-secrets.sh +52 -0
- data/.claude/settings.json +85 -0
- data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
- data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
- data/.claude/skills/active-storage-setup/SKILL.md +311 -0
- data/.claude/skills/api-versioning/SKILL.md +294 -0
- data/.claude/skills/authentication-flow/SKILL.md +335 -0
- data/.claude/skills/authentication-flow/reference/current.md +248 -0
- data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
- data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
- data/.claude/skills/authorization-pundit/SKILL.md +462 -0
- data/.claude/skills/caching-strategies/SKILL.md +350 -0
- data/.claude/skills/database-migrations/SKILL.md +354 -0
- data/.claude/skills/form-object-patterns/SKILL.md +399 -0
- data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
- data/.claude/skills/i18n-patterns/SKILL.md +320 -0
- data/.claude/skills/install/SKILL.md +367 -0
- data/.claude/skills/performance-optimization/SKILL.md +311 -0
- data/.claude/skills/rails-architecture/SKILL.md +259 -0
- data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
- data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
- data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
- data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
- data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
- data/.claude/skills/rails-concern/SKILL.md +399 -0
- data/.claude/skills/rails-controller/SKILL.md +336 -0
- data/.claude/skills/rails-model-generator/SKILL.md +321 -0
- data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
- data/.claude/skills/rails-presenter/SKILL.md +274 -0
- data/.claude/skills/rails-query-object/SKILL.md +289 -0
- data/.claude/skills/rails-service-object/SKILL.md +349 -0
- data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
- data/.claude/skills/tdd-cycle/SKILL.md +359 -0
- data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +2 -0
- data/.ruby-version +1 -1
- data/.vbw-planning/.notification-log.jsonl +192 -0
- data/.vbw-planning/.session-log.jsonl +871 -0
- data/.vbw-planning/PROJECT.md +51 -0
- data/.vbw-planning/REQUIREMENTS.md +50 -0
- data/.vbw-planning/SHIPPED.md +28 -0
- data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
- data/.vbw-planning/codebase/CONCERNS.md +99 -0
- data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
- data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
- data/.vbw-planning/codebase/INDEX.md +86 -0
- data/.vbw-planning/codebase/META.md +42 -0
- data/.vbw-planning/codebase/PATTERNS.md +262 -0
- data/.vbw-planning/codebase/STACK.md +101 -0
- data/.vbw-planning/codebase/STRUCTURE.md +324 -0
- data/.vbw-planning/codebase/TESTING.md +154 -0
- data/.vbw-planning/config.json +12 -0
- data/.vbw-planning/discovery.json +24 -0
- data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
- data/.vbw-planning/milestones/default/STATE.md +83 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +179 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +114 -101
- data/Rakefile +2 -0
- data/app/assets/builds/source_monitor/application.css +2076 -0
- data/app/assets/builds/source_monitor/application.js +2758 -0
- data/app/assets/builds/source_monitor/application.js.map +7 -0
- data/app/controllers/source_monitor/application_controller.rb +2 -0
- data/app/controllers/source_monitor/health_controller.rb +2 -0
- data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
- data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
- data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
- data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
- data/app/controllers/source_monitor/items_controller.rb +2 -0
- data/app/controllers/source_monitor/sources_controller.rb +0 -14
- data/app/helpers/source_monitor/application_helper.rb +4 -112
- data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
- data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
- data/app/jobs/source_monitor/application_job.rb +2 -0
- data/app/models/source_monitor/application_record.rb +2 -0
- data/app/models/source_monitor/log_entry.rb +0 -2
- data/config/coverage_baseline.json +217 -1862
- data/config/routes.rb +2 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
- data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
- data/lib/source_monitor/assets/bundler.rb +2 -0
- data/lib/source_monitor/assets.rb +2 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
- data/lib/source_monitor/configuration/events.rb +60 -0
- data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
- data/lib/source_monitor/configuration/health_settings.rb +27 -0
- data/lib/source_monitor/configuration/http_settings.rb +43 -0
- data/lib/source_monitor/configuration/model_definition.rb +108 -0
- data/lib/source_monitor/configuration/models.rb +36 -0
- data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
- data/lib/source_monitor/configuration/retention_settings.rb +45 -0
- data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
- data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
- data/lib/source_monitor/configuration/validation_definition.rb +32 -0
- data/lib/source_monitor/configuration.rb +12 -579
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
- data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
- data/lib/source_monitor/dashboard/queries.rb +2 -195
- data/lib/source_monitor/engine.rb +2 -0
- data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
- data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
- data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
- data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
- data/lib/source_monitor/items/item_creator.rb +28 -455
- data/lib/source_monitor/setup/bundle_installer.rb +2 -0
- data/lib/source_monitor/setup/cli.rb +2 -0
- data/lib/source_monitor/setup/dependency_checker.rb +2 -0
- data/lib/source_monitor/setup/detectors.rb +2 -0
- data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
- data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
- data/lib/source_monitor/setup/install_generator.rb +2 -0
- data/lib/source_monitor/setup/migration_installer.rb +2 -0
- data/lib/source_monitor/setup/node_installer.rb +2 -0
- data/lib/source_monitor/setup/prompter.rb +2 -0
- data/lib/source_monitor/setup/requirements.rb +2 -0
- data/lib/source_monitor/setup/shell_runner.rb +2 -0
- data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/printer.rb +2 -0
- data/lib/source_monitor/setup/verification/result.rb +2 -0
- data/lib/source_monitor/setup/verification/runner.rb +2 -0
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
- data/lib/source_monitor/setup/workflow.rb +2 -0
- data/lib/source_monitor/version.rb +3 -1
- data/lib/source_monitor.rb +140 -58
- data/lib/tasks/source_monitor_assets.rake +2 -0
- data/lib/tasks/source_monitor_setup.rake +2 -0
- data/lib/tasks/source_monitor_tasks.rake +2 -0
- data/source_monitor.gemspec +3 -1
- metadata +144 -4
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-presenter
|
|
3
|
+
description: Creates presenter objects for view formatting using SimpleDelegator pattern with TDD. Use when extracting view logic from models, formatting data for display, creating badges/labels, or when user mentions presenters, view models, formatting, or display helpers.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Rails Presenter Generator (TDD)
|
|
8
|
+
|
|
9
|
+
Creates presenters that wrap models for view-specific formatting with tests first.
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
1. Write failing test in `test/presenters/`
|
|
14
|
+
2. Run test to confirm RED
|
|
15
|
+
3. Implement presenter extending `BasePresenter`
|
|
16
|
+
4. Run test to confirm GREEN
|
|
17
|
+
|
|
18
|
+
## Project Conventions
|
|
19
|
+
|
|
20
|
+
Presenters in this project:
|
|
21
|
+
- Extend `BasePresenter < SimpleDelegator`
|
|
22
|
+
- Include ActionView helpers for formatting
|
|
23
|
+
- Delegate model methods via SimpleDelegator
|
|
24
|
+
- Return HTML-safe strings for badges/formatted output
|
|
25
|
+
- Use I18n for all user-facing text
|
|
26
|
+
|
|
27
|
+
## BasePresenter (Already Exists)
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# app/presenters/base_presenter.rb
|
|
31
|
+
class BasePresenter < SimpleDelegator
|
|
32
|
+
include ActionView::Helpers::NumberHelper
|
|
33
|
+
include ActionView::Helpers::DateHelper
|
|
34
|
+
include ActionView::Helpers::UrlHelper
|
|
35
|
+
include ActionView::Helpers::TagHelper
|
|
36
|
+
include ActionView::Helpers::TextHelper
|
|
37
|
+
|
|
38
|
+
def initialize(model, view_context = nil)
|
|
39
|
+
super(model)
|
|
40
|
+
@view_context = view_context
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def model
|
|
44
|
+
__getobj__
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
alias_method :object, :model
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## TDD Workflow
|
|
52
|
+
|
|
53
|
+
### Step 1: Create Presenter Test (RED)
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# test/presenters/event_presenter_test.rb
|
|
57
|
+
require "test_helper"
|
|
58
|
+
|
|
59
|
+
class EventPresenterTest < ActiveSupport::TestCase
|
|
60
|
+
setup do
|
|
61
|
+
@event = events(:one)
|
|
62
|
+
@presenter = EventPresenter.new(@event)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
test "delegates to the model" do
|
|
66
|
+
assert_equal @event.name, @presenter.name
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
test "responds to model methods" do
|
|
70
|
+
assert_respond_to @presenter, :name
|
|
71
|
+
assert_respond_to @presenter, :status
|
|
72
|
+
assert_respond_to @presenter, :created_at
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
test "exposes the underlying model" do
|
|
76
|
+
assert_equal @event, @presenter.model
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
test "#display_name returns the formatted name" do
|
|
80
|
+
assert_equal @event.name, @presenter.display_name
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
test "#formatted_date returns formatted date when present" do
|
|
84
|
+
@event.update(event_date: Date.new(2026, 7, 15))
|
|
85
|
+
result = @presenter.formatted_date
|
|
86
|
+
assert_includes result, "2026"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
test "#formatted_date returns placeholder when nil" do
|
|
90
|
+
@event.update(event_date: nil)
|
|
91
|
+
result = @presenter.formatted_date
|
|
92
|
+
assert_includes result, "text-slate-400"
|
|
93
|
+
assert_includes result, "italic"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
test "#status_badge returns HTML-safe string" do
|
|
97
|
+
assert_predicate @presenter.status_badge, :html_safe?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
test "#status_badge includes status text" do
|
|
101
|
+
assert_includes @presenter.status_badge, @event.status.humanize
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
test "#status_badge uses correct color for active" do
|
|
105
|
+
@event.update(status: :active)
|
|
106
|
+
presenter = EventPresenter.new(@event)
|
|
107
|
+
assert_includes presenter.status_badge, "bg-green-100"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
test "#status_badge uses correct color for inactive" do
|
|
111
|
+
@event.update(status: :inactive)
|
|
112
|
+
presenter = EventPresenter.new(@event)
|
|
113
|
+
assert_includes presenter.status_badge, "bg-red-100"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
test "#formatted_amount formats cents as currency" do
|
|
117
|
+
@event.update(amount_cents: 15000)
|
|
118
|
+
assert_equal "150,00 EUR", @presenter.formatted_amount
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Step 2: Run Test (Confirm RED)
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
bin/rails test test/presenters/event_presenter_test.rb
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Step 3: Implement Presenter (GREEN)
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
# app/presenters/event_presenter.rb
|
|
133
|
+
class EventPresenter < BasePresenter
|
|
134
|
+
STATUS_COLORS = {
|
|
135
|
+
active: "bg-green-100 text-green-800",
|
|
136
|
+
inactive: "bg-red-100 text-red-800",
|
|
137
|
+
pending: "bg-yellow-100 text-yellow-800"
|
|
138
|
+
}.freeze
|
|
139
|
+
|
|
140
|
+
DEFAULT_COLOR = "bg-slate-100 text-slate-800"
|
|
141
|
+
|
|
142
|
+
def display_name
|
|
143
|
+
name
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def formatted_date
|
|
147
|
+
return not_specified_span if event_date.nil?
|
|
148
|
+
I18n.l(event_date, format: :long)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def status_badge
|
|
152
|
+
tag.span(
|
|
153
|
+
status_text,
|
|
154
|
+
class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium #{status_color}"
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def formatted_amount
|
|
159
|
+
return "0,00 EUR" if amount_cents.nil? || amount_cents.zero?
|
|
160
|
+
number_to_currency(
|
|
161
|
+
amount_cents / 100.0,
|
|
162
|
+
unit: "EUR",
|
|
163
|
+
separator: ",",
|
|
164
|
+
delimiter: " ",
|
|
165
|
+
format: "%n %u"
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def status_text
|
|
172
|
+
I18n.t("activerecord.attributes.event.statuses.#{status}", default: status.to_s.humanize)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def status_color
|
|
176
|
+
STATUS_COLORS.fetch(status.to_sym, DEFAULT_COLOR)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def not_specified_span
|
|
180
|
+
tag.span(
|
|
181
|
+
I18n.t("presenters.common.not_specified"),
|
|
182
|
+
class: "text-slate-400 italic"
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Step 4: Run Test (Confirm GREEN)
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
bin/rails test test/presenters/event_presenter_test.rb
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Common Presenter Methods
|
|
195
|
+
|
|
196
|
+
### Date Formatting
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
def formatted_event_date
|
|
200
|
+
return not_specified_span if event_date.nil?
|
|
201
|
+
I18n.l(event_date, format: :long)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def short_date
|
|
205
|
+
return "\u2014" if event_date.nil?
|
|
206
|
+
event_date.strftime("%d/%m/%Y")
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Currency Formatting
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
def formatted_budget
|
|
214
|
+
return not_specified_span if budget_cents.nil?
|
|
215
|
+
number_to_currency(
|
|
216
|
+
budget_cents / 100.0,
|
|
217
|
+
unit: "EUR",
|
|
218
|
+
separator: ",",
|
|
219
|
+
delimiter: " ",
|
|
220
|
+
format: "%n %u",
|
|
221
|
+
precision: 0
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Badge/Tag Generation
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
def type_badge
|
|
230
|
+
tag.span(
|
|
231
|
+
display_type,
|
|
232
|
+
class: "inline-flex items-center px-2 py-1 rounded text-xs font-medium #{type_color}"
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Contact Links
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
def display_email
|
|
241
|
+
return not_specified_span if email.blank?
|
|
242
|
+
mail_to(email, email, class: "text-blue-600 hover:underline")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def display_phone
|
|
246
|
+
return not_specified_span if phone.blank?
|
|
247
|
+
link_to(phone, "tel:#{phone}", class: "text-blue-600 hover:underline")
|
|
248
|
+
end
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Usage in Controllers
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
# Single resource
|
|
255
|
+
@event = EventPresenter.new(@event)
|
|
256
|
+
|
|
257
|
+
# Collection
|
|
258
|
+
@events = events.map { |e| EventPresenter.new(e) }
|
|
259
|
+
|
|
260
|
+
# With view context (for route helpers)
|
|
261
|
+
@event = EventPresenter.new(@event, view_context)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Checklist
|
|
265
|
+
|
|
266
|
+
- [ ] Test written first (RED)
|
|
267
|
+
- [ ] Extends `BasePresenter`
|
|
268
|
+
- [ ] Delegation tested
|
|
269
|
+
- [ ] HTML output is `html_safe`
|
|
270
|
+
- [ ] Uses I18n for all text
|
|
271
|
+
- [ ] Currency stored in cents, displayed formatted
|
|
272
|
+
- [ ] Color mappings use constants (Open/Closed)
|
|
273
|
+
- [ ] `not_specified_span` for nil values
|
|
274
|
+
- [ ] All tests GREEN
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-query-object
|
|
3
|
+
description: Creates query objects for complex database queries following TDD. Use when encapsulating complex queries, aggregating statistics, building reports, or when user mentions queries, stats, dashboards, or data aggregation.
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Rails Query Object Generator (TDD)
|
|
8
|
+
|
|
9
|
+
Creates query objects that encapsulate complex database queries with tests first.
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
1. Write failing test in `test/queries/`
|
|
14
|
+
2. Run test to confirm RED
|
|
15
|
+
3. Implement query object in `app/queries/`
|
|
16
|
+
4. Run test to confirm GREEN
|
|
17
|
+
|
|
18
|
+
## When to Use Query Objects vs Scopes
|
|
19
|
+
|
|
20
|
+
| Scenario | Use |
|
|
21
|
+
|----------|-----|
|
|
22
|
+
| Simple WHERE clause | **Scope** on the model |
|
|
23
|
+
| Single-condition filter | **Scope** on the model |
|
|
24
|
+
| Multi-table joins with conditions | **Query object** |
|
|
25
|
+
| Dashboard aggregations | **Query object** |
|
|
26
|
+
| Report generation | **Query object** |
|
|
27
|
+
| Queries needing constructor params | **Query object** |
|
|
28
|
+
| Reusable across controllers | **Query object** |
|
|
29
|
+
|
|
30
|
+
**Rule of thumb:** If the query fits in one line and needs no context, use a scope. If it needs parameters, joins multiple tables, or returns computed data, use a query object.
|
|
31
|
+
|
|
32
|
+
## Project Conventions
|
|
33
|
+
|
|
34
|
+
Query objects in this project:
|
|
35
|
+
- Accept context via constructor (`user:` or `account:`)
|
|
36
|
+
- Return `ActiveRecord::Relation` for chainability OR `Hash` for aggregations
|
|
37
|
+
- Have a `call` method for primary operation
|
|
38
|
+
- Support multi-tenancy (scoped to account)
|
|
39
|
+
|
|
40
|
+
## TDD Workflow
|
|
41
|
+
|
|
42
|
+
### Step 1: Create Query Test (RED)
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# test/queries/stale_leads_query_test.rb
|
|
46
|
+
require "test_helper"
|
|
47
|
+
|
|
48
|
+
class StaleLeadsQueryTest < ActiveSupport::TestCase
|
|
49
|
+
setup do
|
|
50
|
+
@account = accounts(:one)
|
|
51
|
+
@other_account = accounts(:two)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
test "requires an account parameter" do
|
|
55
|
+
assert_raises(ArgumentError) { StaleLeadsQuery.new }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
test "#call returns ActiveRecord::Relation" do
|
|
59
|
+
query = StaleLeadsQuery.new(account: @account)
|
|
60
|
+
assert_kind_of ActiveRecord::Relation, query.call
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
test "#call returns only leads for the account (multi-tenant)" do
|
|
64
|
+
own_lead = leads(:stale_one)
|
|
65
|
+
other_lead = leads(:other_account_stale)
|
|
66
|
+
|
|
67
|
+
results = StaleLeadsQuery.new(account: @account).call
|
|
68
|
+
|
|
69
|
+
assert_includes results, own_lead
|
|
70
|
+
assert_not_includes results, other_lead
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
test "#call returns only stale leads" do
|
|
74
|
+
stale = leads(:stale_one)
|
|
75
|
+
fresh = leads(:fresh_one)
|
|
76
|
+
|
|
77
|
+
results = StaleLeadsQuery.new(account: @account).call
|
|
78
|
+
|
|
79
|
+
assert_includes results, stale
|
|
80
|
+
assert_not_includes results, fresh
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
test "multi-tenant isolation" do
|
|
84
|
+
other_query = StaleLeadsQuery.new(account: @other_account)
|
|
85
|
+
own_query = StaleLeadsQuery.new(account: @account)
|
|
86
|
+
|
|
87
|
+
assert_empty(other_query.call.where(id: leads(:stale_one).id))
|
|
88
|
+
assert_not_empty(own_query.call.where(id: leads(:stale_one).id))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Step 2: Run Test (Confirm RED)
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
bin/rails test test/queries/stale_leads_query_test.rb
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Step 3: Implement Query Object (GREEN)
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# app/queries/stale_leads_query.rb
|
|
103
|
+
class StaleLeadsQuery
|
|
104
|
+
attr_reader :account
|
|
105
|
+
|
|
106
|
+
def initialize(account:)
|
|
107
|
+
@account = account
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def call
|
|
111
|
+
account.leads.stale
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Step 4: Run Test (Confirm GREEN)
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
bin/rails test test/queries/stale_leads_query_test.rb
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Query Object Patterns
|
|
123
|
+
|
|
124
|
+
### Pattern 1: Simple Filtered Query
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# app/queries/stale_leads_query.rb
|
|
128
|
+
class StaleLeadsQuery
|
|
129
|
+
attr_reader :account
|
|
130
|
+
|
|
131
|
+
def initialize(account:)
|
|
132
|
+
@account = account
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def call
|
|
136
|
+
account.leads.stale
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Pattern 2: Aggregation Query (Multiple Methods)
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# app/queries/dashboard_stats_query.rb
|
|
145
|
+
class DashboardStatsQuery
|
|
146
|
+
attr_reader :user, :account
|
|
147
|
+
|
|
148
|
+
def initialize(user:)
|
|
149
|
+
@user = user
|
|
150
|
+
@account = user.account
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def upcoming_events(limit: 3)
|
|
154
|
+
account.events
|
|
155
|
+
.where("event_date >= ?", Date.today)
|
|
156
|
+
.order(event_date: :asc)
|
|
157
|
+
.limit(limit)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def pending_commissions_total
|
|
161
|
+
EventVendor
|
|
162
|
+
.joins(:event)
|
|
163
|
+
.where(events: { account_id: account.id })
|
|
164
|
+
.where(commission_status: :to_invoice)
|
|
165
|
+
.sum(:commission_value)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def top_vendors(limit: 5)
|
|
169
|
+
account.vendors
|
|
170
|
+
.left_joins(:event_vendors)
|
|
171
|
+
.select("vendors.*, COUNT(event_vendors.id) as events_count")
|
|
172
|
+
.group("vendors.id")
|
|
173
|
+
.order("events_count DESC")
|
|
174
|
+
.limit(limit)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def leads_by_status
|
|
178
|
+
account.leads.group(:status).count
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Pattern 3: Grouping Query
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
# app/queries/leads_by_status_query.rb
|
|
187
|
+
class LeadsByStatusQuery
|
|
188
|
+
attr_reader :account
|
|
189
|
+
|
|
190
|
+
def initialize(account:)
|
|
191
|
+
@account = account
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def call
|
|
195
|
+
leads = account.leads.order(created_at: :desc)
|
|
196
|
+
result = Lead.statuses.keys.map(&:to_sym).index_with { [] }
|
|
197
|
+
|
|
198
|
+
leads.group_by(&:status).each do |status, status_leads|
|
|
199
|
+
result[status.to_sym] = status_leads
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
result
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Testing Aggregation Queries
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# test/queries/dashboard_stats_query_test.rb
|
|
211
|
+
require "test_helper"
|
|
212
|
+
|
|
213
|
+
class DashboardStatsQueryTest < ActiveSupport::TestCase
|
|
214
|
+
setup do
|
|
215
|
+
@user = users(:one)
|
|
216
|
+
@query = DashboardStatsQuery.new(user: @user)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
test "#upcoming_events returns future events only" do
|
|
220
|
+
results = @query.upcoming_events
|
|
221
|
+
results.each do |event|
|
|
222
|
+
assert event.event_date >= Date.today
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
test "#upcoming_events respects limit" do
|
|
227
|
+
results = @query.upcoming_events(limit: 2)
|
|
228
|
+
assert results.size <= 2
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
test "#leads_by_status returns hash of status to count" do
|
|
232
|
+
result = @query.leads_by_status
|
|
233
|
+
assert_kind_of Hash, result
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
test "scoped to user account only" do
|
|
237
|
+
other_user = users(:other_account)
|
|
238
|
+
other_query = DashboardStatsQuery.new(user: other_user)
|
|
239
|
+
|
|
240
|
+
own_events = @query.upcoming_events
|
|
241
|
+
other_events = other_query.upcoming_events
|
|
242
|
+
|
|
243
|
+
own_events.each do |event|
|
|
244
|
+
assert_equal @user.account_id, event.account_id
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Usage in Controllers
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
# Simple query
|
|
254
|
+
def index
|
|
255
|
+
@leads_by_status = LeadsByStatusQuery.new(account: current_account).call
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Aggregation query with presenter
|
|
259
|
+
def index
|
|
260
|
+
stats_query = DashboardStatsQuery.new(user: current_user)
|
|
261
|
+
@stats = DashboardStatsPresenter.new(stats_query)
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Directory Structure
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
app/queries/
|
|
269
|
+
stale_leads_query.rb
|
|
270
|
+
leads_by_status_query.rb
|
|
271
|
+
dashboard_stats_query.rb
|
|
272
|
+
events/
|
|
273
|
+
upcoming_query.rb
|
|
274
|
+
by_vendor_query.rb
|
|
275
|
+
test/queries/
|
|
276
|
+
stale_leads_query_test.rb
|
|
277
|
+
dashboard_stats_query_test.rb
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Checklist
|
|
281
|
+
|
|
282
|
+
- [ ] Test written first (RED)
|
|
283
|
+
- [ ] Constructor accepts context (`user:` or `account:`)
|
|
284
|
+
- [ ] Multi-tenant isolation tested
|
|
285
|
+
- [ ] Return type documented
|
|
286
|
+
- [ ] Methods have clear, descriptive names
|
|
287
|
+
- [ ] Complex queries use `.includes()` to prevent N+1
|
|
288
|
+
- [ ] Database-agnostic (no PostgreSQL-specific SQL)
|
|
289
|
+
- [ ] All tests GREEN
|