.vbw-planning/milestones/ui-fixes-and-smart-scraping/phases/03-dashboard-pagination/.context-dev.md
Not available
Codebase mapping exists in .vbw-planning/codebase/. Key files:
ARCHITECTURE.mdCONCERNS.mdPATTERNS.mdDEPENDENCIES.mdSTRUCTURE.mdCONVENTIONS.mdTESTING.mdSTACK.mdRead CONVENTIONS.md, PATTERNS.md, STRUCTURE.md, and DEPENDENCIES.md first to bootstrap codebase understanding.
.vbw-planning/discovery.json.vbw-planning/STATE.mdapp/assets/builds/source_monitor/application.cssGemfile.locktest/dummy/Gemfile.lock.vbw-planning/discovery.json (156 lines, first 30 shown){
"answered": [
{
"question": "What matters most in the conventions cleanup?",
"answer": "All of the above: Model conventions, Controller patterns, Dead code removal",
"category": "scope",
"phase": "4",
"date": "2026-02-10"
},
{
"question": "How should we handle convention violations that would change public API behavior?",
"answer": "Fix everything -- rename/restructure even if it changes method signatures or route patterns",
"category": "api-policy",
"phase": "4",
"date": "2026-02-10"
},
{
"question": "Favicon discovery strategy?",
"answer": "Multi-strategy cascade: /favicon.ico -> HTML parsing (full GET, Nokogiri, prefer largest) -> Google Favicon API. Skip DuckDuckGo.",
"area": "favicon-discovery",
"phase": "02",
"date": "2026-02-20"
},
{
"question": "How to handle downloaded favicons before storage?",
"answer": "Store raw original via Active Storage, define two variants: 32x32 (standard) and 64x64 (retina). SVGs stored as-is AND rasterized to PNG.",
"area": "image-processing",
"phase": "02",
"date": "2026-02-20"
},
.vbw-planning/STATE.md (25 lines)# State
**Project:** SourceMonitor
**Milestone:** ui-fixes-and-smart-scraping
**Phase:** 03 (Dashboard Pagination)
**Plans:** 4 planned, 0 complete
**Progress:** 50%
**Status:** Planned
## Decisions
| Decision | Date | Context |
|----------|------|---------|
| Active Storage for favicons | 2026-02-20 | has_one_attached with guard, consistent with ItemContent pattern |
| Smarter scrape limit | 2026-02-20 | Count only running jobs, not queued; keeps safety but removes false bottleneck |
| Browser-like default UA | 2026-02-20 | Simple global fix for bot-blocked feeds like Uber |
| Health check triggers status update | 2026-02-20 | Successful manual health check should transition declining -> improving |
| Toast cap + hover expand | 2026-02-20 | Max 3 visible, +N more badge, hover to see all |
## Todos
- [x] Fix deprecation: `rails/tasks/statistics.rake` removed from Rakefile (2026-02-21)
## Blockers
None
app/assets/builds/source_monitor/application.css (2179 lines, first 30 shown)*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
Gemfile.lock (426 lines, first 30 shown)PATH
remote: .
specs:
source_monitor (0.10.2)
cssbundling-rails (~> 1.4)
faraday (~> 2.9)
faraday-follow_redirects (~> 0.4)
faraday-gzip (~> 3.0)
faraday-retry (~> 2.2)
feedjira (>= 3.2, < 5.0)
jsbundling-rails (~> 1.3)
nokolexbor (~> 0.5)
rails (>= 8.0.3, < 10.0)
ransack (~> 4.2)
ruby-readability (~> 0.7)
solid_cable (>= 3.0, < 4.0)
solid_queue (>= 0.3, < 3.0)
turbo-rails (~> 2.0)
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.16)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
test/dummy/Gemfile.lock (409 lines, first 30 shown)PATH
remote: ../..
specs:
source_monitor (0.10.2)
cssbundling-rails (~> 1.4)
faraday (~> 2.9)
faraday-follow_redirects (~> 0.4)
faraday-gzip (~> 3.0)
faraday-retry (~> 2.2)
feedjira (>= 3.2, < 5.0)
jsbundling-rails (~> 1.3)
nokolexbor (~> 0.5)
rails (>= 8.0.3, < 10.0)
ransack (~> 4.2)
ruby-readability (~> 0.7)
solid_cable (>= 3.0, < 4.0)
solid_queue (>= 0.3, < 3.0)
turbo-rails (~> 2.0)
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.16)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
phase: "03" plan: "04" title: "Health Distribution Badge Counts on Dashboard" wave: 1 depends_on: [] must_haves:
Add health status distribution counts (Healthy N, Warning N, Declining N, Critical N) to the dashboard stats section. The distribution is computed via a new query in StatsQuery and rendered as inline badge counts below the existing stats cards.
What: Extend StatsQuery#call to include a health_distribution hash with counts per health status.
Files to modify:
lib/source_monitor/dashboard/queries/stats_query.rbImplementation details:
health_distribution key to the returned hashSourceMonitor::Source.active.group(:health_status).count (returns { "healthy" => 42, "warning" => 3, ... })SELECT health_status, COUNT(*) FROM sources WHERE active = true GROUP BY health_status%w[healthy warning declining critical].each_with_object({}) { |s, h| h[s] = raw_counts.fetch(s, 0) }Acceptance criteria:
stats[:health_distribution] returns { "healthy" => N, "warning" => N, "declining" => N, "critical" => N }What: Add a row of inline badge counts below the existing stats cards grid.
Files to modify:
app/views/source_monitor/dashboard/_stats.html.erbImplementation details:
grid of stat cards, add a <div> with inline flex badgesHealthBadgeHelper:
bg-green-100 text-green-700bg-amber-100 text-amber-700bg-orange-100 text-orange-700bg-rose-100 text-rose-700<span class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold [color-classes]">Healthy <span class="font-bold">42</span></span>id="source_monitor_dashboard_health_distribution" for Turbo Stream targetingAcceptance criteria:
What: Record health distribution metrics via the existing instrumentation pattern.
Files to modify:
lib/source_monitor/dashboard/queries.rbImplementation details:
record_stats_metrics, add gauges for each health status count:
SourceMonitor::Metrics.gauge(:dashboard_stats_health_healthy, stats[:health_distribution]["healthy"])Acceptance criteria:
What: Test the query and verify the badge rendering.
Files to create:
test/lib/source_monitor/dashboard/stats_query_test.rbTest cases:
test "health_distribution counts active sources by health_status" -- Create sources with different health statuses, verify countstest "health_distribution excludes inactive sources" -- Create inactive source with "critical" status, verify it's not countedtest "health_distribution includes zero for missing statuses" -- Only healthy sources exist, verify warning/declining/critical are 0test "health_distribution handles no active sources" -- No active sources, verify all counts are 0Acceptance criteria:
create_source! factory with explicit health_status valuesThis plan modifies:
lib/source_monitor/dashboard/queries/stats_query.rbapp/views/source_monitor/dashboard/_stats.html.erblib/source_monitor/dashboard/queries.rb (metrics recording only -- different methods than Plan 02's upcoming_fetch_schedule method)test/lib/source_monitor/dashboard/stats_query_test.rb (NEW)Conflict analysis with Plan 02: Both plans modify lib/source_monitor/dashboard/queries.rb. However:
upcoming_fetch_schedule method signature and its cache keyrecord_stats_metrics private method onlyrecord_stats_metrics by appending lines (not restructuring), making merge trivial.No overlap with Plan 01 (paginator, shared partial, application_helper) or Plan 03 (sources controller/index).
UpcomingFetchSchedule (127 lines) loads ALL active sources into memory, groups by fetch window in RubyDashboardController#index calls Dashboard::Queries which caches results per-request_fetch_schedule.html.erb partial with grouped dataPagination::Paginator (91 lines) at lib/source_monitor/pagination/paginator.rbrecords, page, per_page, has_next_page, has_previous_page(page-1)*per_page + 1 records to detect next pagetotal_count and total_pages for jump-to-pagePaginator.new(scope: @q.result, page: params[:page], per_page: params[:per_page])source_monitor_sources_table Turbo FrameStatsQuery computes: total sources, active sources, failed sources, total items, fetches todaySource model has health_status column with values: healthy, warning, declining, criticalSource.active scope exists (where active: true)health_status is a string column — can group by itnext_fetch_at is a datetime column — can use range queries for schedule bucketsturbo_frame_tag "source_monitor_sources_table"Paginator with optional total_count / total_pages — backward compatibleSource.active.where(next_fetch_at: now..now+30.minutes) for each windowSource.active.group(:health_status).count — single SQL queryCOUNT(*) adds a second query per paginated section. For 5 schedule sections + 1 sources index = 6 count queries. Mitigate: count query is cheap on indexed columns.schedule_0_30_page=2).include_total: true flag to compute total pages. Keep backward compatible.group(:health_status).count to StatsQuery.