.vbw-planning/milestones/07-rails-audit-and-refactoring/05-view-layer-extraction/.context-lead.md
Not available
Not available
No matching requirements found
None
Goal: Extract view logic into ViewComponents, presenters, and helpers to improve maintainability, testability, and reusability.
Total views: 44 ERB templates across 12 directories
app/views/source_monitor/
├── dashboard/ (8 files)
│ ├── index.html.erb (54 lines)
│ ├── _stats.html.erb (30 lines)
│ ├── _stat_card.html.erb (partial)
│ ├── _recent_activity.html.erb (partial)
│ ├── _fetch_schedule.html.erb (partial)
│ ├── _job_metrics.html.erb (partial)
│ └── _scrape_recommendations.html.erb (partial)
│
├── sources/ (11 files)
│ ├── index.html.erb (282 lines) [LARGEST]
│ ├── show.html.erb (partial)
│ ├── edit.html.erb (partial)
│ ├── new.html.erb (partial)
│ ├── _form.html.erb (partial)
│ ├── _form_fields.html.erb (partial)
│ ├── _row.html.erb (144 lines)
│ ├── _details.html.erb (362 lines) [VERY LARGE]
│ ├── _health_status_badge.html.erb (47 lines)
│ ├── _bulk_scrape_modal.html.erb (partial)
│ └── _bulk_scrape_form.html.erb (partial)
│
├── items/ (3 files)
│ ├── index.html.erb (78+ lines)
│ ├── show.html.erb (partial)
│ └── _details.html.erb (partial)
│
├── import_sessions/ (8 files)
│ ├── show.html.erb (partial)
│ ├── _header.html.erb (partial)
│ ├── _sidebar.html.erb (partial)
│ ├── steps/ (5 files)
│ └── health_check/ (2 files)
│
├── logs/ (1 file)
│ └── index.html.erb (partial)
│
├── shared/ (4 files)
│ ├── _toast.html.erb (36 lines)
│ ├── _pagination.html.erb (partial)
│ └── other shared partials
│
└── other/ (remaining controllers)
├── fetch_logs/, scrape_logs/, source_scrape_tests/
Total: 7 controllers in app/assets/javascripts/source_monitor/controllers/
| Controller | Lines | Purpose | Size |
|---|---|---|---|
dropdown_controller.js | 110 | Dropdown toggle with optional stimulus-use transitions | Medium |
modal_controller.js | 65 | Modal open/close, backdrop, escape handling | Medium |
async_submit_controller.js | 36 | Button disabled state + loading text during submit | Small |
notification_controller.js | 63 | Auto-dismiss toast notifications | Small |
notification_container_controller.js | ? | Container for stacking toasts | Small |
select_all_controller.js | 56 | Master/item checkbox toggling for bulk actions | Small |
confirm_navigation_controller.js | ? | Unsaved changes warning | Small |
Total: 3 helper modules in app/helpers/source_monitor/
| Helper | Lines | Purpose |
|---|---|---|
application_helper.rb | 356 | Asset bundles, heatmap colors, status badges, SVG helpers, favicon helpers, pagination |
health_badge_helper.rb | 57 | Health status badge styling + dropdown actions |
table_sort_helper.rb | 54 | Sort link generation, aria labels, direction arrows |
app/components/ is empty)Files that write to window.*:
notification_controller.js (line 9-10, 30)
window.SourceMonitorControllers = {};
window.SourceMonitorControllers.notification = this;
turbo_actions.js (line 4-5, 10)
window.Turbo.StreamActions.redirect = function() { ... }
window.Turbo.visit(url, { action: visitAction });
application.js (line 11, 15)
window.SourceMonitorStimulus = application;
File: app/views/source_monitor/sources/index.html.erb (lines 22-53)
Issue: Complex filter building logic mixed in view template:
Evidence:
<% adapter_options = SourceMonitor::Source.distinct.where.not(scraper_adapter: [nil, ""]).order(:scraper_adapter).pluck(:scraper_adapter) %>
<% active_dropdown_filters = dropdown_filter_keys.select { |k| @search_params[k].present? } %>
<% has_any_filter = @search_term.present? || @fetch_interval_filter.present? || active_dropdown_filters.any? %>
<% filter_labels = {
"active_eq" => @search_params["active_eq"] == "true" ? "Status: Active" : "Status: Paused",
"health_status_eq" => "Health: #{@search_params['health_status_eq']&.titleize}",
"feed_format_eq" => "Format: #{@search_params['feed_format_eq']&.upcase}",
... (6 items total)
} %>
Recommendation: Extract to SourcesIndexPresenter with:
active_filter_keys, filter_labels, has_any_filter? as instance methodsFile: app/views/source_monitor/sources/_row.html.erb (lines 1-11)
Issue: Undocumented preload requirements for optimal performance
Evidence:
<% rate_map = local_assigns[:item_activity_rates] || {} %>
<% avg_feed_words_map = local_assigns[:avg_feed_word_counts] || {} %>
<% avg_scraped_words_map = local_assigns[:avg_scraped_word_counts] || {} %>
The partial expects caller to preload these maps. No controller-level documentation of what queries these require. If maps are missing, fallback values hide performance issues.
Evidence of hidden data dependency:
source_favicon_tag(source, size: 24) — requires favicon attachment (Active Storage guard)source_health_badge(source) — depends on health_status enumRecommendation: Document preload requirements + add to controller or create SourceRowPresenter to encapsulate map lookups.
File: app/views/source_monitor/dashboard/index.html.erb (lines 1-10)
Issue: Dashboard listens to single stream but re-renders entire _stats partial on any stat change
<%= turbo_stream_from SourceMonitor::Dashboard::TurboBroadcaster::STREAM_NAME %>
<div class="mt-6">
<%= render "stats", stats: @stats %>
</div>
When any stat changes (e.g., fetches_today increments):
Evidence from _stats.html.erb (lines 1-30):
<div id="source_monitor_dashboard_stats">
<div class="grid gap-5 sm:grid-cols-2 xl:grid-cols-5">
<%= render partial: "stat_card", collection: [
{ label: "Sources", value: stats[:total_sources], ... },
{ label: "Active", value: stats[:active_sources], ... },
{ label: "Failures", value: stats[:failed_sources], ... },
...
], locals: { stats: stats } %>
</div>
Recommendation: Split into per-stat Turbo Stream updates. Each stat gets its own IDs and targeted updates, e.g., source_monitor_dashboard_stat_total_sources, source_monitor_dashboard_stat_active_sources.
Files:
app/views/source_monitor/sources/index.html.erb (lines 22-53)app/views/source_monitor/items/index.html.erb (similar structure)app/views/source_monitor/logs/index.html.erb (similar structure)Issue: Identical filter dropdown pattern repeated across 3 views with no shared component
Evidence (sources index):
<div>
<%= form.label :active_eq, "Status", class: "block text-xs font-medium text-slate-500 mb-1" %>
<%= form.select :active_eq, options_for_select([["All Statuses", ""], ["Active", "true"], ["Paused", "false"]], @search_params["active_eq"].to_s), {},
class: "rounded-md border border-slate-200 bg-white px-2 py-2 text-sm text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500",
onchange: "this.form.requestSubmit()" %>
</div>
Same pattern in items/index, logs/index with only label/param names changing.
Recommendation: Extract to FilterDropdownComponent (ViewComponent) with:
:label, :param_name, :options inputsFile: app/assets/javascripts/source_monitor/controllers/dropdown_controller.js (lines 36-58)
Issue: Complex dynamic module loading (stimulus-use) with fragile fallback
Evidence:
async loadTransitions() {
if (!this.hasMenuTarget || this.transitionModuleValue === "") {
this.logFallback();
return;
}
try {
const module = await import(this.transitionModuleValue);
const useTransition = module?.useTransition || module?.default?.useTransition;
if (typeof useTransition === "function") {
useTransition(this, {
element: this.menuTarget,
hiddenClass: this.hiddenClassValue
});
this.transitionEnabled = true;
} else {
this.logFallback();
}
} catch (error) {
this.logFallback(error);
}
}
Risks:
default?.useTransition vs useTransition check is defensive but unclearthis.transitionEnabled, this.toggleTransition, this.leave state management is hard to follow_fallbackLogged flag), hard to debug in productionCode comment notes (line 35): "Evaluated for simplification in Phase 20.05.07 - Decision: Keep current implementation."
Recommendation: Simplify to one path:
Files:
app/views/source_monitor/sources/_row.html.erb (lines 109-141) — actions dropdownapp/views/source_monitor/sources/_health_status_badge.html.erb (lines 8-37) — health menuIssue: Multiple dropdowns on the same page use data-controller="dropdown" with shared class names and no unique IDs for menu visibility state
Evidence:
<!-- Row dropdown -->
<div data-controller="dropdown" class="relative inline-block text-left">
<button data-action="dropdown#toggle click@window->dropdown#hide">...</button>
<div data-dropdown-target="menu" class="... hidden">...</div>
</div>
<!-- Health badge dropdown (elsewhere on same page) -->
<div data-controller="dropdown" class="relative inline-block text-left" data-testid="source-health-menu">
<button data-action="dropdown#toggle click@window->dropdown#hide">...</button>
<div data-dropdown-target="menu" class="... hidden">...</div>
</div>
Risk:
click@window->dropdown#hide event bound globally — all dropdowns listen to ALL clickshide event fires, it matches ALL data-dropdown-target="menu" selectors on the pageTest case: Open row actions dropdown, then click row name link elsewhere → all dropdowns may close unintentionally
Recommendation:
data-open="true")Files with inline SVGs:
app/views/source_monitor/sources/_row.html.erb (lines 114-117) — 3-dot menu icon (4 lines)app/views/source_monitor/sources/_details.html.erb (lines 31-33) — favicon refresh icon (3 lines)app/views/source_monitor/sources/_health_status_badge.html.erb (lines 17-19) — chevron-down icon (3 lines)app/helpers/source_monitor/application_helper.rb (lines 285-299) — external-link icon (15 lines)app/helpers/source_monitor/application_helper.rb (lines 191-201) — spinner SVG helper (11 lines)Issue: SVG markup duplicated or scattered across helpers without centralized icon system
Evidence:
<!-- _row.html.erb: 3-dot menu -->
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="..." />
...
</svg>
<!-- _details.html.erb: refresh icon (different path) -->
<svg class="h-4 w-4 text-slate-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m..." />
</svg>
Recommendation: Create IconComponent (ViewComponent) with named icon registry:
render IconComponent.new(:menu_dots, size: :md)
render IconComponent.new(:refresh, size: :sm)
render IconComponent.new(:chevron_down)
Files with Turbo Frame IDs:
app/views/source_monitor/sources/index.html.erb (line 56): source_monitor_sources_tableapp/views/source_monitor/items/index.html.erb (line 17): source_monitor_items_tableapp/views/source_monitor/dashboard/_fetch_schedule.html.erb (line 11): source_monitor_schedule_#{group.key} (dynamic)app/views/source_monitor/import_sessions/show.html.erb (line 10): import_session_stepIssue: Naming convention is inconsistent
source_monitor_*_table prefix for listssource_monitor_*_* prefix for sectionsimport_session_* prefix for import wizard (no source_monitor prefix!)Risk: Makes it hard to:
Evidence:
data: { turbo_frame: "source_monitor_sources_table" }data: { turbo_frame: "source_monitor_items_table" }turbo_frame_tag "source_monitor_schedule_#{group.key}"Recommendation: Establish consistent naming scheme:
source_monitor_{section}_{element}source_monitor_sources_table, source_monitor_dashboard_stats, source_monitor_import_stepIdentified locations:
notification_controller.js (lines 9-10, 30)
window.SourceMonitorControllers.notification = thisturbo_actions.js (lines 4-5)
window.Turbo.StreamActions.redirect = ...application.js (lines 11, 15)
window.SourceMonitorStimulus = applicationImplicit globals in templates
source_health_badge(), async_status_badge() — these are method calls, not global pollutionwindow. in view templates (✓ good)Recommendation:
window.SourceMonitorControllers — controllers don't need global registrationwindow.SourceMonitorStimulus only in dev modewindow.Turbo.StreamActions.redirect (standard pattern)File: app/views/source_monitor/sources/_details.html.erb (362 lines) [VERY LARGE]
Issue: Massive partial with embedded view logic, no presenter abstraction
Evidence (lines 177-203):
<% interval_hours = number_with_precision(source.fetch_interval_minutes / 60.0, precision: 2)
circuit_state =
if source.fetch_circuit_open?
until_time = source.fetch_circuit_until&.strftime("%b %d, %Y %H:%M %Z") || "unknown"
"Open until #{until_time}"
else
"Closed"
end
details = {
"Website" => (source.website_url.present? ? external_link_to(...) : "\u2014"),
"Fetch interval" => "#{source.fetch_interval_minutes} minutes (~#{interval_hours} hours)",
"Adaptive interval" => source.adaptive_fetching_enabled? ? "Auto" : "Fixed",
"Scraper" => source.scraper_adapter,
...
} %>
Methods called in template (should move to presenter):
number_with_precision (formatting)external_link_to (link generation)Array(item.categories).filter_map)log.started_at&.strftime(...))Recommendation: Create SourceDetailsPresenter extending BasePresenter:
class SourceDetailsPresenter < BasePresenter
def fetch_interval_hours
number_with_precision(source.fetch_interval_minutes / 60.0, precision: 2)
end
def circuit_state_label
source.fetch_circuit_open? ? "Open until #{...}" : "Closed"
end
def details_hash
{
"Website" => website_link,
"Fetch interval" => "#{fetch_interval} minutes (~#{fetch_interval_hours} hours)",
...
}
end
private
def website_link
source.website_url.present? ? external_link_to(source.website_url, ...) : "—"
end
end
Files:
app/views/source_monitor/sources/_bulk_scrape_modal.html.erbapp/views/source_monitor/sources/_bulk_scrape_enable_modal.html.erbapp/views/source_monitor/import_sessions/show.html.erb (modals for import steps)Issue: Modal structure duplicated across views, no ViewComponent abstraction. Marked as "to be handled by Phase 07 UTM integration" — deferring this audit finding is acceptable for now.
Note: Phase 07 will extract modals into a reusable ModalComponent.
application_helper.rb (356 lines):
loading_spinner_svg() — SVG helper (lines 191-202)external_link_icon() (private, lines 284-300)external_link_to() — wrapper for link_to with icon (lines 223-230)source_favicon_tag() — favicon + placeholder (lines 248-256)async_status_badge() — badge styling for fetch/scrape states (lines 140-165)item_scrape_status_badge() — item-specific badge logic (lines 171-183)health_badge_helper.rb (57 lines):
source_health_badge() — health status styling (lines 5-19)source_health_actions() — contextual dropdown menu items (lines 21-48)interactive_health_status?() — boolean check for interactivity (lines 50-54)table_sort_helper.rb (54 lines):
table_sort_direction() — detect current sort (lines 5-10)table_sort_arrow() — visual indicator (▲▼↕, lines 12-23)table_sort_aria() — ARIA label (lines 25-36)table_sort_link() — link generation with Ransack (lines 38-51)Well-structured:
async_submit_controller.js (36 lines) — button state management, cleanselect_all_controller.js (56 lines) — master/detail checkbox sync, cleannotification_controller.js (63 lines) — toast auto-dismiss + global registration (⚠ V13 pollution)modal_controller.js (65 lines) — modal open/close, escape handling, cleanconfirm_navigation_controller.js — unsaved changes checkFragile:
dropdown_controller.js (110 lines) — complex async loading with stimulus-use (V6)Streams:
turbo_stream_from for live stat updatesFrames:
| Finding | Component | Type | Effort |
|---|---|---|---|
| V1 | SourcesIndexPresenter | Presenter | Small |
| V14 | SourceDetailsPresenter | Presenter | Medium |
| V5 | FilterDropdownComponent | ViewComponent | Medium |
Rationale: These unblock V3, V4 improvements and reduce line counts immediately.
| Finding | Fix | Type | Effort |
|---|---|---|---|
| V4 | Split dashboard stats into per-stat Turbo Streams | Turbo refactor | Medium |
| V12 | Establish consistent frame naming scheme | Documentation + refactor | Small |
Rationale: Improves real-time UX and reduces re-renders.
| Finding | Fix | Type | Effort |
|---|---|---|---|
| V6 | Simplify dropdown transitions (remove stimulus-use OR require as dependency) | Controller refactor | Medium |
| V7 | Isolate dropdown state with unique IDs | Controller refactor | Small |
| V13 | Remove global window pollution | Controller refactor | Small |
| V9 | Create IconComponent with named registry | ViewComponent | Small |
Rationale: Improves maintainability and prevents memory leaks in SPA.
| Finding | Action | Type | Effort |
|---|---|---|---|
| V3 | Document row partial preload requirements in controller | Docs | Small |
| V12 | Create Turbo Frame ID reference guide | Docs | Small |
| ID | Title | Severity | Type | Lines | Recommendation |
|---|---|---|---|---|---|
| V1 | Filter logic in view | High | Logic extraction | 56 | SourcesIndexPresenter |
| V3 | Row partial N+1 risk | Medium | Documentation | — | Preload guide + RowPresenter |
| V4 | Dashboard re-renders entire section | High | Turbo Stream | 30 | Split to per-stat streams |
| V5 | Filter dropdown duplication | Medium | Component | 3x 20 | FilterDropdownComponent |
| V6 | Dropdown async loading fragility | High | Refactor | 22 | Simplify to one path |
| V7 | Dropdown state isolation risk | Medium | Testing | — | Add unique IDs + tests |
| V9 | Inline SVG repetition | Medium | Component | 5+ files | IconComponent registry |
| V12 | Turbo Frame naming inconsistent | Low | Documentation | — | Naming scheme guide |
| V13 | Window namespace pollution | Medium | Cleanup | 3 locations | Remove globals |
| V14 | No SourceDetailsPresenter | High | Presenter | 362 | SourceDetailsPresenter |
| V15 | Modal markup not extracted | Medium | Component | (deferred) | Phase 07: ModalComponent |
Phase 04 completed Item/Source model extraction; this phase targets presentation layer.claude/skills/sm-architecture/reference/module-map.md.claude/skills/sm-domain-model/reference/model-graph.md.claude/skills/viewcomponent-patterns/.claude/skills/rails-presenter/.claude/skills/hotwire-patterns/test/helpers/ (may need coverage for new presenters)test/system/ (Hotwire interactions already tested)High confidence — all findings backed by direct code evidence, line-number references, and clear patterns.
Evidence quality: Direct inspection of 44 view files, 7 Stimulus controllers, 3 helpers, application layout.