code_sunset 0.1.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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +242 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/images/code_sunset/.keep +0 -0
  6. data/app/assets/images/code_sunset/logo-dark.png +0 -0
  7. data/app/assets/images/code_sunset/logo-light.png +0 -0
  8. data/app/assets/images/code_sunset/topography.svg +1 -0
  9. data/app/assets/javascripts/code_sunset/application.js +179 -0
  10. data/app/assets/javascripts/code_sunset/vendor/chart.umd.min.js +14 -0
  11. data/app/assets/stylesheets/code_sunset/application.css +1075 -0
  12. data/app/controllers/code_sunset/application_controller.rb +50 -0
  13. data/app/controllers/code_sunset/dashboard_controller.rb +12 -0
  14. data/app/controllers/code_sunset/features_controller.rb +62 -0
  15. data/app/controllers/code_sunset/removal_candidates_controller.rb +9 -0
  16. data/app/controllers/concerns/.keep +0 -0
  17. data/app/helpers/code_sunset/application_helper.rb +244 -0
  18. data/app/jobs/code_sunset/aggregate_daily_rollups_job.rb +48 -0
  19. data/app/jobs/code_sunset/application_job.rb +4 -0
  20. data/app/jobs/code_sunset/cleanup_events_job.rb +10 -0
  21. data/app/jobs/code_sunset/evaluate_alerts_job.rb +15 -0
  22. data/app/jobs/code_sunset/flush_buffered_events_job.rb +9 -0
  23. data/app/jobs/code_sunset/persist_event_job.rb +9 -0
  24. data/app/mailers/code_sunset/application_mailer.rb +6 -0
  25. data/app/models/code_sunset/alert_delivery.rb +14 -0
  26. data/app/models/code_sunset/application_record.rb +5 -0
  27. data/app/models/code_sunset/daily_rollup.rb +31 -0
  28. data/app/models/code_sunset/event.rb +190 -0
  29. data/app/models/code_sunset/feature.rb +33 -0
  30. data/app/models/concerns/.keep +0 -0
  31. data/app/views/code_sunset/dashboard/index.html.erb +123 -0
  32. data/app/views/code_sunset/features/index.html.erb +88 -0
  33. data/app/views/code_sunset/features/show.html.erb +266 -0
  34. data/app/views/code_sunset/removal_candidates/index.html.erb +118 -0
  35. data/app/views/code_sunset/shared/_filter_bar.html.erb +81 -0
  36. data/app/views/layouts/code_sunset/application.html.erb +57 -0
  37. data/bin/rails +26 -0
  38. data/code_sunset.gemspec +35 -0
  39. data/config/routes.rb +5 -0
  40. data/db/migrate/20260330000000_create_code_sunset_tables.rb +62 -0
  41. data/db/migrate/20260331000000_harden_code_sunset_reliability.rb +39 -0
  42. data/lib/code_sunset/alert_dispatcher.rb +103 -0
  43. data/lib/code_sunset/analytics_filters.rb +59 -0
  44. data/lib/code_sunset/configuration.rb +104 -0
  45. data/lib/code_sunset/context.rb +49 -0
  46. data/lib/code_sunset/controller_context.rb +23 -0
  47. data/lib/code_sunset/engine.rb +24 -0
  48. data/lib/code_sunset/event_buffer.rb +28 -0
  49. data/lib/code_sunset/event_ingestor.rb +57 -0
  50. data/lib/code_sunset/event_payload.rb +79 -0
  51. data/lib/code_sunset/feature_query.rb +69 -0
  52. data/lib/code_sunset/feature_snapshot.rb +14 -0
  53. data/lib/code_sunset/feature_usage_series.rb +59 -0
  54. data/lib/code_sunset/identity_hash.rb +9 -0
  55. data/lib/code_sunset/instrumentation.rb +17 -0
  56. data/lib/code_sunset/job_context.rb +17 -0
  57. data/lib/code_sunset/removal_candidate_query.rb +17 -0
  58. data/lib/code_sunset/removal_prompt_builder.rb +331 -0
  59. data/lib/code_sunset/score_calculator.rb +83 -0
  60. data/lib/code_sunset/status_engine.rb +38 -0
  61. data/lib/code_sunset/subscriber.rb +24 -0
  62. data/lib/code_sunset/version.rb +3 -0
  63. data/lib/code_sunset.rb +109 -0
  64. data/lib/generators/code_sunset/eject_ui/eject_ui_generator.rb +33 -0
  65. data/lib/generators/code_sunset/initializer/initializer_generator.rb +13 -0
  66. data/lib/generators/code_sunset/initializer/templates/code_sunset.rb +34 -0
  67. data/lib/generators/code_sunset/install/install_generator.rb +23 -0
  68. data/lib/generators/code_sunset/migration/migration_generator.rb +21 -0
  69. data/lib/generators/code_sunset/migration/templates/create_code_sunset_tables.rb +75 -0
  70. data/lib/tasks/code_sunset_tasks.rake +21 -0
  71. metadata +185 -0
@@ -0,0 +1,50 @@
1
+ module CodeSunset
2
+ class ApplicationController < ActionController::Base
3
+ layout "code_sunset/application"
4
+
5
+ before_action :authorize_dashboard!
6
+ helper_method :filters, :analytics_filters
7
+
8
+ private
9
+
10
+ def authorize_dashboard!
11
+ authorizer = CodeSunset.configuration.dashboard_authorizer
12
+ allowed = if authorizer.respond_to?(:call)
13
+ authorizer.arity.zero? ? instance_exec(&authorizer) : authorizer.call(self)
14
+ else
15
+ false
16
+ end
17
+
18
+ return if allowed
19
+
20
+ head :forbidden
21
+ rescue StandardError => error
22
+ CodeSunset.log_error("dashboard authorization", error)
23
+ head :forbidden
24
+ end
25
+
26
+ def filters
27
+ @filters ||= params.permit(*permitted_filter_keys).to_h.symbolize_keys
28
+ end
29
+
30
+ def analytics_filters
31
+ @analytics_filters ||= CodeSunset::AnalyticsFilters.new(filters)
32
+ end
33
+
34
+ def permitted_filter_keys
35
+ [
36
+ :app_env,
37
+ :plan,
38
+ :org_id,
39
+ :user_id,
40
+ :from,
41
+ :to,
42
+ :paid_only,
43
+ :exclude_internal,
44
+ :window,
45
+ :page,
46
+ *CodeSunset.configuration.custom_filters.keys
47
+ ]
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,12 @@
1
+ module CodeSunset
2
+ class DashboardController < ApplicationController
3
+ def index
4
+ @summaries = CodeSunset::FeatureQuery.new(filters).call
5
+ @active_count = @summaries.count { |summary| summary.status == "active" }
6
+ @removable_count = @summaries.count { |summary| %w[candidate_for_removal safe_to_remove].include?(summary.status) }
7
+ @recent_features = @summaries.select { |summary| summary.hits_7d.to_i.positive? }.sort_by { |summary| [-summary.hits_7d.to_i, summary.feature.key] }.first(6)
8
+ @safe_to_remove = @summaries.select { |summary| summary.status == "safe_to_remove" }.sort_by { |summary| [-summary.score.to_f, summary.feature.key] }.first(4)
9
+ @cooling_down = @summaries.select { |summary| summary.status == "candidate_for_removal" }.sort_by { |summary| [-summary.score.to_f, summary.feature.key] }.first(4)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,62 @@
1
+ module CodeSunset
2
+ class FeaturesController < ApplicationController
3
+ RECENT_EVENTS_PER_PAGE = 25
4
+
5
+ before_action :load_feature, only: :show
6
+
7
+ def index
8
+ @summaries = CodeSunset::FeatureQuery.new(filters).call
9
+ end
10
+
11
+ def show
12
+ @summary = CodeSunset::FeatureQuery.new(filters, scope: CodeSunset::Feature.where(id: @feature.id)).call.first
13
+ @usage_series = CodeSunset::FeatureUsageSeries.new(feature: @feature, filters: filters).call
14
+
15
+ scoped_events = @feature.events.apply_filters(filters).where(occurred_at: 30.days.ago..Time.current)
16
+ @top_orgs = CodeSunset::Event.top_orgs(scoped_events, limit: 8)
17
+ @top_users = CodeSunset::Event.top_users(scoped_events, limit: 8)
18
+ @request_paths = CodeSunset::Event.top_request_paths(scoped_events, limit: 8)
19
+ @job_usage = CodeSunset::Event.top_job_classes(scoped_events, limit: 8)
20
+
21
+ recent_events_scope = @feature.events.apply_filters(filters.except(:page)).recent_first
22
+ @recent_events_page = requested_recent_events_page
23
+ @recent_events_per_page = RECENT_EVENTS_PER_PAGE
24
+ @recent_events_total_count = recent_events_scope.count
25
+ @recent_events_total_pages = [(@recent_events_total_count.to_f / @recent_events_per_page).ceil, 1].max
26
+ @recent_events_page = [@recent_events_page, @recent_events_total_pages].min
27
+ @recent_events = recent_events_scope.offset((@recent_events_page - 1) * @recent_events_per_page).limit(@recent_events_per_page)
28
+
29
+ @removal_prompt_artifact = build_removal_prompt_artifact if generate_removal_prompt?
30
+ end
31
+
32
+ private
33
+
34
+ def load_feature
35
+ @feature = CodeSunset::Feature.find_by!(key: params[:id])
36
+ end
37
+
38
+ def requested_recent_events_page
39
+ page = params[:page].to_i
40
+ page.positive? ? page : 1
41
+ end
42
+
43
+ def generate_removal_prompt?
44
+ ActiveModel::Type::Boolean.new.cast(params[:generate_removal_prompt])
45
+ end
46
+
47
+ def build_removal_prompt_artifact
48
+ CodeSunset::RemovalPromptBuilder.new(
49
+ feature: @feature,
50
+ snapshot: @summary,
51
+ filters: filters,
52
+ top_orgs: @top_orgs,
53
+ top_users: @top_users,
54
+ request_paths: @request_paths,
55
+ job_usage: @job_usage,
56
+ recent_events: @recent_events,
57
+ recent_events_total_count: @recent_events_total_count,
58
+ usage_series: @usage_series
59
+ ).call
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,9 @@
1
+ module CodeSunset
2
+ class RemovalCandidatesController < ApplicationController
3
+ def index
4
+ @summaries = CodeSunset::RemovalCandidateQuery.new(filters).call
5
+ @safe = @summaries.select { |summary| summary.status == "safe_to_remove" }
6
+ @candidates = @summaries.select { |summary| summary.status == "candidate_for_removal" }
7
+ end
8
+ end
9
+ end
File without changes
@@ -0,0 +1,244 @@
1
+ module CodeSunset
2
+ module ApplicationHelper
3
+ def format_seen_at(timestamp)
4
+ return "Never" unless timestamp
5
+
6
+ time = timestamp.in_time_zone
7
+ relative_time = if time > Time.current
8
+ "just now"
9
+ else
10
+ "#{time_ago_in_words(time)} ago"
11
+ end
12
+
13
+ content_tag(
14
+ :time,
15
+ relative_time,
16
+ class: "cs-time",
17
+ datetime: time.iso8601,
18
+ title: time.strftime("%Y-%m-%d %H:%M:%S %Z")
19
+ )
20
+ end
21
+
22
+ def human_count(value)
23
+ number_with_delimiter(value || 0)
24
+ end
25
+
26
+ def status_badge(status)
27
+ content_tag(:span, status.to_s.tr("_", " ").titleize, class: "cs-badge cs-badge--#{status}")
28
+ end
29
+
30
+ def current_filter_value(key)
31
+ filters[key]
32
+ end
33
+
34
+ def analytics_notice
35
+ analytics_filters.raw_filter_notice
36
+ end
37
+
38
+ def filters_applied?
39
+ filters.except(:page).values.any?(&:present?)
40
+ end
41
+
42
+ def sidebar_nav_link(label, path, icon:)
43
+ classes = ["cs-nav__link"]
44
+ classes << "is-active" if current_page?(path)
45
+
46
+ link_to path, class: classes.join(" ") do
47
+ safe_join(
48
+ [
49
+ content_tag(:span, sidebar_icon(icon), class: "cs-nav__icon"),
50
+ content_tag(:span, label, class: "cs-nav__text")
51
+ ]
52
+ )
53
+ end
54
+ end
55
+
56
+ def removal_score_badge(feature, snapshot, label: nil)
57
+ label ||= format("%.1f", snapshot&.score.to_f)
58
+
59
+ content_tag(
60
+ :span,
61
+ label,
62
+ class: "cs-score cs-score--help",
63
+ title: removal_score_tooltip(feature, snapshot)
64
+ )
65
+ end
66
+
67
+ def removal_score_tooltip(feature, snapshot)
68
+ return "Removal score is unavailable until usage has been analyzed." unless feature && snapshot
69
+
70
+ calculator = CodeSunset::ScoreCalculator.new(feature: feature, snapshot: snapshot)
71
+ breakdown = calculator.breakdown
72
+
73
+ [
74
+ "Removal score favors features that look safer to remove.",
75
+ "Recency: #{format('%.1f', breakdown[:recency][:score])}/#{format('%.0f', breakdown[:recency][:max])} from #{breakdown[:recency][:detail]}",
76
+ "Volume: #{format('%.1f', breakdown[:volume][:score])}/#{format('%.0f', breakdown[:volume][:max])} from #{breakdown[:volume][:detail]}",
77
+ "Org breadth: #{format('%.1f', breakdown[:orgs][:score])}/#{format('%.0f', breakdown[:orgs][:max])} from #{breakdown[:orgs][:detail]}",
78
+ "Paid usage: #{format('%.1f', breakdown[:paid_usage][:score])}/#{format('%.0f', breakdown[:paid_usage][:max])} from #{breakdown[:paid_usage][:detail]}",
79
+ "Total: #{format('%.1f', calculator.score)}/100"
80
+ ].join("\n")
81
+ end
82
+
83
+ def usage_chart(points)
84
+ return content_tag(:p, "No usage data yet.", class: "cs-subtle") if points.blank?
85
+
86
+ labels = points.keys.map { |day| day.strftime("%b %-d") }
87
+ values = points.values.map(&:to_i)
88
+
89
+ content_tag(:div, class: "cs-chart-shell") do
90
+ content_tag(
91
+ :canvas,
92
+ nil,
93
+ class: "cs-chart",
94
+ role: "img",
95
+ aria: { label: "Feature usage trend chart" },
96
+ data: {
97
+ code_sunset_usage_chart: true,
98
+ labels: labels.to_json,
99
+ values: values.to_json
100
+ }
101
+ )
102
+ end
103
+ end
104
+
105
+ def pagination_link_for(feature, label, page:, current:, filters: {}, rel: nil)
106
+ classes = ["cs-pagination__link"]
107
+ classes << "is-active" if current
108
+
109
+ link_to label, feature_path(feature.key, filters.merge(page: page)), class: classes.join(" "), rel: rel
110
+ end
111
+
112
+ def removal_prompt_path_for(feature)
113
+ feature_path(feature.key, filters.merge(generate_removal_prompt: true))
114
+ end
115
+
116
+ def removal_prompt_reset_path_for(feature)
117
+ feature_path(feature.key, filters.except(:page))
118
+ end
119
+
120
+ def filter_query_values(overrides = {})
121
+ filters.except(:page).merge(overrides).reject { |_key, value| value.blank? }
122
+ end
123
+
124
+ def filter_path_for(path, values = filter_query_values)
125
+ query_values = values.reject { |_key, value| value.blank? }
126
+ return path if query_values.empty?
127
+
128
+ "#{path}?#{query_values.to_query}"
129
+ end
130
+
131
+ def active_filter_chips_for(path)
132
+ filters.except(:page).each_with_object([]) do |(key, value), chips|
133
+ next if value.blank?
134
+
135
+ chips << {
136
+ key: key,
137
+ label: filter_chip_label(key),
138
+ value: filter_chip_value(key, value),
139
+ clear_path: filter_path_for(path, filters.except(:page, key))
140
+ }
141
+ end
142
+ end
143
+
144
+ def render_filter_bar(form_url:, reset_url:, title: "Filters", copy: nil)
145
+ render(
146
+ "code_sunset/shared/filter_bar",
147
+ form_url: form_url,
148
+ reset_url: reset_url,
149
+ title: title,
150
+ copy: copy,
151
+ filters_applied: filters_applied?,
152
+ active_chips: active_filter_chips_for(reset_url)
153
+ )
154
+ end
155
+
156
+ def advanced_filters_open?
157
+ filters.values_at(:plan, :org_id, :user_id, :paid_only, :exclude_internal, *configured_custom_filter_keys).any?(&:present?)
158
+ end
159
+
160
+ def configured_custom_filters
161
+ CodeSunset.configuration.custom_filters
162
+ end
163
+
164
+ def configured_custom_filter_keys
165
+ configured_custom_filters.keys
166
+ end
167
+
168
+ def custom_filter_input_tag(key, definition)
169
+ current_value = current_filter_value(key)
170
+
171
+ case definition[:type].to_sym
172
+ when :boolean
173
+ select_tag key, options_for_select([["Any", nil], ["Yes", true], ["No", false]], current_value), include_blank: false
174
+ when :select
175
+ select_tag key, options_for_select([["Any", nil]] + Array(definition[:options]), current_value), include_blank: false
176
+ else
177
+ text_field_tag key, current_value, placeholder: definition[:placeholder]
178
+ end
179
+ end
180
+
181
+ private
182
+
183
+ def filter_chip_label(key)
184
+ configured = configured_custom_filters[key.to_sym]
185
+ return configured[:label] if configured
186
+
187
+ {
188
+ app_env: "Environment",
189
+ plan: "Plan",
190
+ org_id: "Org",
191
+ user_id: "User",
192
+ from: "From",
193
+ to: "To",
194
+ paid_only: "Paid",
195
+ exclude_internal: "Internal"
196
+ }.fetch(key.to_sym, key.to_s.humanize)
197
+ end
198
+
199
+ def filter_chip_value(key, value)
200
+ configured = configured_custom_filters[key.to_sym]
201
+ if configured
202
+ return truthy_filter_label(value) if configured[:type].to_sym == :boolean
203
+
204
+ if configured[:type].to_sym == :select && configured[:options].present?
205
+ matched = configured[:options].find { |label, option_value| option_value.to_s == value.to_s }
206
+ return matched.first if matched
207
+ end
208
+
209
+ return value
210
+ end
211
+
212
+ case key.to_sym
213
+ when :paid_only
214
+ truthy_filter_label(value)
215
+ when :exclude_internal
216
+ truthy_filter_label(value, truthy: "Excluded", falsey: "Included")
217
+ else
218
+ value
219
+ end
220
+ end
221
+
222
+ def truthy_filter_label(value, truthy: "Yes", falsey: "No")
223
+ ActiveModel::Type::Boolean.new.cast(value) ? truthy : falsey
224
+ end
225
+
226
+ def sidebar_icon(name)
227
+ path =
228
+ case name.to_sym
229
+ when :dashboard
230
+ "M3.75 4.5h6.75v6.75H3.75zm9.75 0h6.75v4.5H13.5zm0 7.5h6.75v8.25H13.5zm-9.75 2.25h6.75V20.25H3.75z"
231
+ when :features
232
+ "M4.5 6.75A2.25 2.25 0 0 1 6.75 4.5h10.5a2.25 2.25 0 0 1 2.25 2.25v10.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 17.25zm3 2.25h9m-9 3.75h9m-9 3.75h5.25"
233
+ when :removal_queue
234
+ "M9 3.75h6m-7.5 4.5h9m-10.5 4.5h12m-10.5 4.5h9"
235
+ else
236
+ "M4.5 12h15"
237
+ end
238
+
239
+ content_tag(:svg, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.75", "stroke-linecap": "round", "stroke-linejoin": "round", aria: { hidden: true }) do
240
+ content_tag(:path, nil, d: path)
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,48 @@
1
+ module CodeSunset
2
+ class AggregateDailyRollupsJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(start_date: nil, end_date: nil)
6
+ range = normalize_range(start_date, end_date)
7
+ relation = CodeSunset::Event.where(occurred_at: range)
8
+
9
+ rows = relation
10
+ .group(:feature_key, :app_env, Arel.sql("DATE(occurred_at)"))
11
+ .pluck(
12
+ :feature_key,
13
+ :app_env,
14
+ Arel.sql("DATE(occurred_at)"),
15
+ Arel.sql("COUNT(*)"),
16
+ Arel.sql("COUNT(DISTINCT #{CodeSunset::Event.user_identifier_expression})"),
17
+ Arel.sql("COUNT(DISTINCT #{CodeSunset::Event.org_identifier_expression})"),
18
+ Arel.sql("MAX(occurred_at)")
19
+ )
20
+
21
+ upserts = rows.map do |feature_key, app_env, day, hits_count, unique_users_count, unique_orgs_count, last_seen_at|
22
+ {
23
+ feature_key: feature_key,
24
+ app_env: app_env,
25
+ day: day,
26
+ hits_count: hits_count,
27
+ unique_users_count: unique_users_count,
28
+ unique_orgs_count: unique_orgs_count,
29
+ last_seen_at: last_seen_at,
30
+ created_at: Time.current,
31
+ updated_at: Time.current
32
+ }
33
+ end
34
+
35
+ return if upserts.empty?
36
+
37
+ CodeSunset::DailyRollup.upsert_all(upserts, unique_by: %i[feature_key app_env day])
38
+ end
39
+
40
+ private
41
+
42
+ def normalize_range(start_date, end_date)
43
+ from = start_date.present? ? Time.zone.parse(start_date.to_s).beginning_of_day : 30.days.ago.beginning_of_day
44
+ to = end_date.present? ? Time.zone.parse(end_date.to_s).end_of_day : Time.current.end_of_day
45
+ from..to
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,4 @@
1
+ module CodeSunset
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,10 @@
1
+ module CodeSunset
2
+ class CleanupEventsJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform
6
+ cutoff = CodeSunset.configuration.event_retention_days.days.ago
7
+ CodeSunset::Event.where("occurred_at < ?", cutoff).delete_all
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ module CodeSunset
2
+ class EvaluateAlertsJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(feature_key = nil)
6
+ if feature_key.present?
7
+ CodeSunset::AlertDispatcher.new(feature_key).dispatch_if_due
8
+ else
9
+ CodeSunset::Feature.find_each do |feature|
10
+ CodeSunset::AlertDispatcher.new(feature.key).dispatch_if_due
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ module CodeSunset
2
+ class FlushBufferedEventsJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(limit: nil)
6
+ CodeSunset::EventIngestor.flush_buffer(limit: limit)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module CodeSunset
2
+ class PersistEventJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(payload)
6
+ CodeSunset::Event.create_from_payload(payload)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ module CodeSunset
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,14 @@
1
+ module CodeSunset
2
+ class AlertDelivery < ApplicationRecord
3
+ self.table_name = "code_sunset_alert_deliveries"
4
+
5
+ validates :feature_key, :status, :alert_kind, :delivered_at, presence: true
6
+
7
+ class << self
8
+ def recent_for(feature_key:, status:, alert_kind:, since:)
9
+ where(feature_key: feature_key, status: status, alert_kind: alert_kind)
10
+ .where("delivered_at >= ?", since)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ module CodeSunset
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,31 @@
1
+ module CodeSunset
2
+ class DailyRollup < ApplicationRecord
3
+ self.table_name = "code_sunset_daily_rollups"
4
+
5
+ belongs_to :feature, primary_key: :key, foreign_key: :feature_key, inverse_of: :daily_rollups, optional: true
6
+
7
+ validates :feature_key, :day, presence: true
8
+ validates :feature_key, uniqueness: { scope: [:app_env, :day] }
9
+
10
+ scope :ordered, -> { order(day: :asc) }
11
+
12
+ class << self
13
+ def apply_filters(filters = {})
14
+ normalized = filters.to_h.symbolize_keys
15
+ scoped = all
16
+ scoped = scoped.where(app_env: normalized[:app_env]) if normalized[:app_env].present?
17
+ scoped = scoped.where(day: parse_date(normalized[:from])..) if normalized[:from].present? && parse_date(normalized[:from])
18
+ scoped = scoped.where(day: ..parse_date(normalized[:to])) if normalized[:to].present? && parse_date(normalized[:to])
19
+ scoped
20
+ end
21
+
22
+ private
23
+
24
+ def parse_date(value)
25
+ Date.parse(value.to_s)
26
+ rescue Date::Error
27
+ nil
28
+ end
29
+ end
30
+ end
31
+ end