sage-rails 0.0.3

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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +202 -0
  3. data/app/assets/images/chevron-down-zinc-500.svg +1 -0
  4. data/app/assets/images/chevron-right.svg +1 -0
  5. data/app/assets/images/loading.svg +4 -0
  6. data/app/assets/images/sage/chevron-down-zinc-500.svg +1 -0
  7. data/app/assets/images/sage/chevron-right.svg +1 -0
  8. data/app/assets/images/sage/loading.svg +4 -0
  9. data/app/assets/javascripts/sage/application.js +18 -0
  10. data/app/assets/stylesheets/sage/application.css +308 -0
  11. data/app/controllers/sage/actions_controller.rb +5 -0
  12. data/app/controllers/sage/application_controller.rb +4 -0
  13. data/app/controllers/sage/base_controller.rb +10 -0
  14. data/app/controllers/sage/checks_controller.rb +65 -0
  15. data/app/controllers/sage/dashboards_controller.rb +130 -0
  16. data/app/controllers/sage/queries/messages_controller.rb +62 -0
  17. data/app/controllers/sage/queries_controller.rb +596 -0
  18. data/app/helpers/sage/application_helper.rb +30 -0
  19. data/app/helpers/sage/queries_helper.rb +23 -0
  20. data/app/javascript/controllers/element_removal_controller.js +7 -0
  21. data/app/javascript/sage/controllers/clipboard_controller.js +26 -0
  22. data/app/javascript/sage/controllers/dashboard_controller.js +132 -0
  23. data/app/javascript/sage/controllers/reverse_infinite_scroll_controller.js +146 -0
  24. data/app/javascript/sage/controllers/search_controller.js +47 -0
  25. data/app/javascript/sage/controllers/select_controller.js +215 -0
  26. data/app/javascript/sage.js +19 -0
  27. data/app/jobs/sage/application_job.rb +4 -0
  28. data/app/jobs/sage/process_report_job.rb +80 -0
  29. data/app/mailers/sage/application_mailer.rb +6 -0
  30. data/app/models/sage/application_record.rb +5 -0
  31. data/app/models/sage/message.rb +8 -0
  32. data/app/schemas/sage/report_response_schema.rb +8 -0
  33. data/app/views/layouts/application.html.erb +34 -0
  34. data/app/views/layouts/sage/application.html.erb +94 -0
  35. data/app/views/sage/checks/_form.html.erb +81 -0
  36. data/app/views/sage/checks/_search.html.erb +8 -0
  37. data/app/views/sage/checks/edit.html.erb +10 -0
  38. data/app/views/sage/checks/index.html.erb +58 -0
  39. data/app/views/sage/checks/new.html.erb +8 -0
  40. data/app/views/sage/dashboards/_form.html.erb +50 -0
  41. data/app/views/sage/dashboards/_search.html.erb +8 -0
  42. data/app/views/sage/dashboards/index.html.erb +58 -0
  43. data/app/views/sage/dashboards/new.html.erb +8 -0
  44. data/app/views/sage/dashboards/show.html.erb +58 -0
  45. data/app/views/sage/messages/_form.html.erb +14 -0
  46. data/app/views/sage/queries/_caching.html.erb +17 -0
  47. data/app/views/sage/queries/_form.html.erb +72 -0
  48. data/app/views/sage/queries/_input.html.erb +17 -0
  49. data/app/views/sage/queries/_message.html.erb +25 -0
  50. data/app/views/sage/queries/_message.turbo_stream.erb +10 -0
  51. data/app/views/sage/queries/_new_form.html.erb +43 -0
  52. data/app/views/sage/queries/_run.html.erb +232 -0
  53. data/app/views/sage/queries/_search.html.erb +8 -0
  54. data/app/views/sage/queries/_statement_box.html.erb +241 -0
  55. data/app/views/sage/queries/_streaming_message.html.erb +14 -0
  56. data/app/views/sage/queries/create.turbo_stream.erb +114 -0
  57. data/app/views/sage/queries/edit.html.erb +48 -0
  58. data/app/views/sage/queries/index.html.erb +59 -0
  59. data/app/views/sage/queries/messages/create.turbo_stream.erb +22 -0
  60. data/app/views/sage/queries/messages/index.html.erb +44 -0
  61. data/app/views/sage/queries/messages/index.turbo_stream.erb +15 -0
  62. data/app/views/sage/queries/new.html.erb +195 -0
  63. data/app/views/sage/queries/run.html.erb +1 -0
  64. data/app/views/sage/queries/run.turbo_stream.erb +3 -0
  65. data/app/views/sage/queries/show.html.erb +49 -0
  66. data/app/views/sage/queries/table_schema.html.erb +77 -0
  67. data/app/views/sage/shared/_navigation.html.erb +26 -0
  68. data/app/views/sage/shared/_overlay.html.erb +11 -0
  69. data/config/importmap.rb +11 -0
  70. data/config/initializers/pagy.rb +2 -0
  71. data/config/initializers/ransack.rb +152 -0
  72. data/config/routes.rb +31 -0
  73. data/lib/generators/sage/USAGE +13 -0
  74. data/lib/generators/sage/install/install_generator.rb +128 -0
  75. data/lib/generators/sage/install/templates/sage.rb +22 -0
  76. data/lib/sage/database_schema_context.rb +56 -0
  77. data/lib/sage/engine.rb +260 -0
  78. data/lib/sage/model_scopes_context.rb +185 -0
  79. data/lib/sage/report_processor.rb +263 -0
  80. data/lib/sage/version.rb +3 -0
  81. data/lib/sage.rb +25 -0
  82. data/lib/tasks/sage_tasks.rake +4 -0
  83. metadata +245 -0
@@ -0,0 +1,596 @@
1
+ module Sage
2
+ class QueriesController < BaseController
3
+ before_action :set_query, only: [ :show, :edit, :update, :destroy, :refresh, :run ]
4
+ before_action :set_data_source, only: [ :tables, :docs, :schema, :cancel ]
5
+
6
+ def index
7
+ @q = Blazer::Query.ransack(params[:q])
8
+ @queries = @q.result.named.active
9
+
10
+ # Only include creator if Blazer.user_class is configured
11
+ @queries = @queries.includes(:creator) if Blazer.user_class
12
+
13
+ @queries = @queries.order(:name)
14
+
15
+ # Apply additional filters if needed
16
+ if blazer_user && params[:filter] == "mine"
17
+ @queries = @queries.where(creator_id: blazer_user.id).reorder(updated_at: :desc)
18
+ elsif blazer_user && params[:filter] == "viewed" && Blazer.audit
19
+ query_ids = Blazer::Audit.where(user_id: blazer_user.id).order(created_at: :desc).limit(500).pluck(:query_id).uniq
20
+ @queries = @queries.where(id: query_ids)
21
+ end
22
+
23
+ # Filter out private queries (starting with #) unless they belong to the current user
24
+ @queries = @queries.where("name NOT LIKE ? OR creator_id = ?", "#%", blazer_user.try(:id))
25
+
26
+ # Apply pagination with Pagy
27
+ @pagy, @queries = pagy(@queries)
28
+ end
29
+
30
+ def new
31
+ @query = Blazer::Query.new(
32
+ data_source: params[:data_source],
33
+ name: params[:name]
34
+ )
35
+ if params[:fork_query_id]
36
+ @query.statement ||= Blazer::Query.find(params[:fork_query_id]).try(:statement)
37
+ end
38
+ if params[:upload_id]
39
+ upload = Blazer::Upload.find(params[:upload_id])
40
+ upload_settings = Blazer.settings["uploads"]
41
+ @query.data_source ||= upload_settings["data_source"]
42
+ @query.statement ||= "SELECT * FROM #{upload.table_name} LIMIT 10"
43
+ end
44
+
45
+ # Get schema information for the current data source
46
+ data_source_key = @query.data_source || Blazer.data_sources.keys.first
47
+ @data_source = Blazer.data_sources[data_source_key]
48
+ if @data_source
49
+ schema = @data_source.schema
50
+ # Filter out internal/system tables that aren't relevant for users
51
+ @schema = schema.reject do |table_info|
52
+ table_name = table_info[:table].to_s.downcase
53
+ table_name.start_with?("sage_", "blazer_") ||
54
+ %w[ar_internal_metadata schema_migrations sqlite_sequence].include?(table_name)
55
+ end
56
+ end
57
+ end
58
+
59
+ def create
60
+ # Handle the new Sage form submission with question parameter
61
+ if params[:query][:question].present?
62
+ question = params[:query][:question]
63
+
64
+ # Create query with placeholder name and statement
65
+ @query = Blazer::Query.new(
66
+ name: "Sage Query - #{Time.current.strftime('%Y-%m-%d %H:%M')}",
67
+ statement: "-- Processing your question...\n-- #{question}",
68
+ data_source: Blazer.data_sources.keys.first
69
+ )
70
+ @query.creator = blazer_user if @query.respond_to?(:creator)
71
+ @query.status = "active" if @query.respond_to?(:status)
72
+
73
+ if @query.save
74
+ # Create associated Sage::Message record with the user's question
75
+ message = @query.messages.create!(
76
+ body: question,
77
+ creator: (blazer_user if ::Blazer.user_class)
78
+ )
79
+
80
+ # Generate a unique stream target ID for real-time updates
81
+ stream_target_id = "message_#{SecureRandom.hex(8)}"
82
+
83
+ # Kick off the ProcessReportJob with 1-second delay
84
+ Sage::ProcessReportJob.set(wait: 1.second).perform_later(
85
+ question,
86
+ query_id: @query.id,
87
+ stream_target_id: stream_target_id
88
+ )
89
+
90
+ redirect_to edit_query_path(@query)
91
+ else
92
+ render_errors @query
93
+ end
94
+ else
95
+ # Handle traditional Blazer query creation
96
+ @query = Blazer::Query.new(query_params)
97
+ @query.creator = blazer_user if @query.respond_to?(:creator)
98
+ @query.status = "active" if @query.respond_to?(:status)
99
+
100
+ if @query.save
101
+ redirect_to query_path(@query, params: variable_params(@query))
102
+ else
103
+ render_errors @query
104
+ end
105
+ end
106
+ end
107
+
108
+ def show
109
+ @statement = @query.statement_object
110
+ @success = process_vars(@statement)
111
+
112
+ @smart_vars = {}
113
+ @sql_errors = []
114
+ @bind_vars.each do |var|
115
+ smart_var, error = parse_smart_variables(var, @statement.data_source)
116
+ @smart_vars[var] = smart_var if smart_var
117
+ @sql_errors << error if error
118
+ end
119
+
120
+ @query.update!(status: "active") if @query.respond_to?(:status) && @query.status.in?([ "archived", nil ])
121
+
122
+ add_cohort_analysis_vars if @query.cohort_analysis?
123
+
124
+ if @success
125
+ @run_data = { statement: @query.statement, query_id: @query.id, data_source: @query.data_source, variables: variable_params(@query) }
126
+ @run_data[:forecast] = "t" if params[:forecast]
127
+ @run_data[:cohort_period] = params[:cohort_period] if params[:cohort_period]
128
+ end
129
+ end
130
+
131
+ def edit
132
+ # Messages will be loaded via turbo_frame from messages#index
133
+ end
134
+
135
+ def run
136
+ # @query is set by before_action for member routes (GET /queries/:id/run)
137
+ # For collection routes (POST /queries/run), load query if query_id is provided
138
+ @query ||= Blazer::Query.find_by(id: params[:query_id]) if params[:query_id]
139
+
140
+ # use query data source when present
141
+ data_source = @query.data_source if @query && @query.data_source
142
+ data_source ||= params[:data_source]
143
+ @data_source = Blazer.data_sources[data_source]
144
+
145
+ # Prefer params statement over query's saved statement (for live editing)
146
+ statement = params[:statement].presence || @query&.statement
147
+ @statement = Blazer::Statement.new(statement, @data_source)
148
+ # before process_vars
149
+ @cohort_analysis = @statement.cohort_analysis?
150
+
151
+ # fallback for now for users with open tabs
152
+ # TODO remove fallback in future version
153
+ @var_params = request.request_parameters["variables"] || request.request_parameters
154
+ @success = process_vars(@statement, @var_params)
155
+ @only_chart = params[:only_chart]
156
+ @run_id = blazer_params[:run_id]
157
+
158
+ run_cohort_analysis if @cohort_analysis
159
+
160
+ query_running = !@run_id.nil?
161
+
162
+ if query_running
163
+ @timestamp = blazer_params[:timestamp].to_i
164
+
165
+ @result = @data_source.run_results(@run_id)
166
+ @success = !@result.nil?
167
+
168
+ if @success
169
+ @data_source.delete_results(@run_id)
170
+ @columns = @result.columns
171
+ @rows = @result.rows
172
+ @error = @result.error
173
+ @just_cached = !@result.error && @result.cached_at.present?
174
+ @cached_at = nil
175
+ params[:data_source] = nil
176
+ render_run
177
+ elsif Time.now > Time.at(@timestamp + (@data_source.timeout || 600).to_i + 5)
178
+ # query lost
179
+ Rails.logger.info "[blazer lost query] #{@run_id}"
180
+ @error = "We lost your query :("
181
+ @rows = []
182
+ @columns = []
183
+ render_run
184
+ else
185
+ continue_run
186
+ end
187
+ elsif @success
188
+ @run_id = blazer_run_id
189
+
190
+ async = Blazer.async
191
+
192
+ options = { user: blazer_user, query: @query, refresh_cache: params[:check], run_id: @run_id, async: async }
193
+ if async && request.format.symbol != :csv
194
+ Blazer::RunStatementJob.perform_later(@data_source.id, @statement.statement, options.merge(values: @statement.values))
195
+ wait_start = Blazer.monotonic_time
196
+ loop do
197
+ sleep(0.1)
198
+ @result = @data_source.run_results(@run_id)
199
+ break if @result || Blazer.monotonic_time - wait_start > 3
200
+ end
201
+ else
202
+ @result = Blazer::RunStatement.new.perform(@statement, options)
203
+ end
204
+
205
+ if @result
206
+ @data_source.delete_results(@run_id) if @run_id && async
207
+
208
+ @columns = @result.columns
209
+ @rows = @result.rows
210
+ @error = @result.error
211
+ @cached_at = @result.cached_at
212
+ @just_cached = @result.just_cached
213
+
214
+ @forecast = @query && @result.forecastable? && params[:forecast]
215
+ if @forecast
216
+ @result.forecast
217
+ @forecast_error = @result.forecast_error
218
+ @forecast = @forecast_error.nil?
219
+ end
220
+
221
+ render_run
222
+ else
223
+ @timestamp = Time.now.to_i
224
+ continue_run
225
+ end
226
+ else
227
+ render layout: false
228
+ end
229
+ end
230
+
231
+ def refresh
232
+ refresh_query(@query)
233
+ redirect_to query_path(@query, params: variable_params(@query))
234
+ end
235
+
236
+ def update
237
+ if params[:commit] == "Fork"
238
+ @query = Blazer::Query.new
239
+ @query.creator = blazer_user if @query.respond_to?(:creator)
240
+ end
241
+ @query.status = "active" if @query.respond_to?(:status)
242
+ unless @query.editable?(blazer_user)
243
+ @query.errors.add(:base, "Sorry, permission denied")
244
+ end
245
+ if @query.errors.empty? && @query.update(query_params)
246
+ redirect_to query_path(@query, params: variable_params(@query))
247
+ else
248
+ render_errors @query
249
+ end
250
+ end
251
+
252
+ def destroy
253
+ @query.destroy if @query.editable?(blazer_user)
254
+ redirect_to root_path
255
+ end
256
+
257
+ def tables
258
+ render json: @data_source.tables
259
+ end
260
+
261
+ def docs
262
+ @smart_variables = @data_source.smart_variables
263
+ @linked_columns = @data_source.linked_columns
264
+ @smart_columns = @data_source.smart_columns
265
+ end
266
+
267
+ def schema
268
+ @schema = @data_source.schema
269
+ end
270
+
271
+ def table_schema
272
+ table_name = params[:table_name]
273
+ data_source_key = params[:data_source] || Blazer.data_sources.keys.first
274
+ @data_source = Blazer.data_sources[data_source_key]
275
+
276
+ if @data_source && table_name.present?
277
+ schema = @data_source.schema
278
+ @table_info = schema.find { |table| table[:table] == table_name }
279
+ @table_display_name = table_name.to_s.gsub("_", " ").titleize
280
+ end
281
+
282
+ render layout: false
283
+ end
284
+
285
+ def cancel
286
+ @data_source.cancel(blazer_run_id)
287
+ head :ok
288
+ end
289
+
290
+ private
291
+
292
+ def set_data_source
293
+ @data_source = Blazer.data_sources[params[:data_source]]
294
+ rescue Blazer::Error => e
295
+ raise unless e.message.start_with?("Unknown data source:")
296
+ render plain: "Unknown data source", status: :not_found
297
+ end
298
+
299
+ def continue_run
300
+ render json: { run_id: @run_id, timestamp: @timestamp }, status: :accepted
301
+ end
302
+
303
+ def render_run
304
+ @checks = @query ? @query.checks.order(:id) : []
305
+
306
+ @first_row = @rows.first || []
307
+ @column_types = []
308
+ if @rows.any?
309
+ @columns.each_with_index do |_, i|
310
+ @column_types << (
311
+ case @first_row[i]
312
+ when Integer
313
+ "int"
314
+ when Float, BigDecimal
315
+ "float"
316
+ else
317
+ "string-ins"
318
+ end
319
+ )
320
+ end
321
+ end
322
+
323
+ @min_width_types = @columns.each_with_index.select { |c, i| @first_row[i].is_a?(Time) || @first_row[i].is_a?(String) || @data_source.smart_columns[c] }.map(&:last)
324
+
325
+ @smart_values = @result.smart_values if @result
326
+
327
+ @linked_columns = @data_source.linked_columns
328
+
329
+ @markers = []
330
+ @geojson = []
331
+ set_map_data if Blazer.maps?
332
+
333
+ render_cohort_analysis if @cohort_analysis && !@error
334
+
335
+ respond_to do |format|
336
+ format.html do
337
+ render layout: false
338
+ end
339
+ format.turbo_stream
340
+ format.csv do
341
+ # not ideal, but useful for testing
342
+ raise Error, @error if @error && Rails.env.test?
343
+
344
+ data = csv_data(@columns, @rows, @data_source)
345
+ filename = "#{@query.try(:name).try(:parameterize).presence || 'query'}.csv"
346
+ send_data data, type: "text/csv; charset=utf-8", disposition: "attachment", filename: filename
347
+ end
348
+ end
349
+ end
350
+
351
+ def set_map_data
352
+ [ [ "latitude", "longitude" ], [ "lat", "lon" ], [ "lat", "lng" ] ].each do |keys|
353
+ lat_index = @columns.index(keys.first)
354
+ lon_index = @columns.index(keys.last)
355
+ if lat_index && lon_index
356
+ @markers =
357
+ @rows.select do |r|
358
+ r[lat_index] && r[lon_index]
359
+ end.map do |r|
360
+ {
361
+ tooltip: map_tooltip(r.each_with_index.reject { |v, i| i == lat_index || i == lon_index }),
362
+ latitude: r[lat_index],
363
+ longitude: r[lon_index]
364
+ }
365
+ end
366
+
367
+ return if @markers.any?
368
+ end
369
+ end
370
+
371
+ geo_index = @columns.index("geojson")
372
+ if geo_index
373
+ @geojson =
374
+ @rows.filter_map do |r|
375
+ if r[geo_index].is_a?(String) && (geometry = (JSON.parse(r[geo_index]) rescue nil)) && geometry.is_a?(Hash)
376
+ {
377
+ tooltip: map_tooltip(r.each_with_index.reject { |v, i| i == geo_index }),
378
+ geometry: geometry
379
+ }
380
+ end
381
+ end
382
+ end
383
+ end
384
+
385
+ def map_tooltip(r)
386
+ r.map { |v, i| "<strong>#{ERB::Util.html_escape(@columns[i])}:</strong> #{ERB::Util.html_escape(v)}" }.join("<br>").truncate(140, separator: " ")
387
+ end
388
+
389
+ def set_queries(limit = nil)
390
+ @queries = Blazer::Query.named.select(:id, :name, :creator_id, :statement)
391
+ @queries = @queries.includes(:creator) if Blazer.user_class
392
+
393
+ if blazer_user && params[:filter] == "mine"
394
+ @queries = @queries.where(creator_id: blazer_user.id).reorder(updated_at: :desc)
395
+ elsif blazer_user && params[:filter] == "viewed" && Blazer.audit
396
+ @queries = queries_by_ids(Blazer::Audit.where(user_id: blazer_user.id).order(created_at: :desc).limit(500).pluck(:query_id).uniq)
397
+ else
398
+ @queries = @queries.limit(limit) if limit
399
+ @queries = @queries.active.order(:name)
400
+ end
401
+ @queries = @queries.to_a
402
+
403
+ @more = limit && @queries.size >= limit
404
+
405
+ @queries = @queries.select { |q| !q.name.to_s.start_with?("#") || q.try(:creator).try(:id) == blazer_user.try(:id) }
406
+ end
407
+
408
+ def queries_by_ids(favorite_query_ids)
409
+ queries = Blazer::Query.active.named.where(id: favorite_query_ids)
410
+ queries = queries.includes(:creator) if Blazer.user_class
411
+ queries = queries.index_by(&:id)
412
+ favorite_query_ids.map { |query_id| queries[query_id] }.compact
413
+ end
414
+
415
+ def set_query
416
+ @query = Blazer::Query.find_by(id: params[:id].to_s.split("-").first) if params[:id]
417
+ end
418
+
419
+ def render_forbidden
420
+ render plain: "Access denied", status: :forbidden
421
+ end
422
+
423
+ def query_params
424
+ params.require(:query).permit(:name, :description, :statement, :data_source, :question)
425
+ end
426
+
427
+ def blazer_params
428
+ params[:blazer] || {}
429
+ end
430
+
431
+ def csv_data(columns, rows, data_source)
432
+ CSV.generate do |csv|
433
+ csv << columns
434
+ rows.each do |row|
435
+ csv << row.each_with_index.map { |v, i| v.is_a?(Time) ? blazer_time_value(data_source, columns[i], v) : v }
436
+ end
437
+ end
438
+ end
439
+
440
+ def blazer_time_value(data_source, k, v)
441
+ data_source.local_time_suffix.any? { |s| k.ends_with?(s) } ? v.to_s.sub(" UTC", "") : v.in_time_zone(Blazer.time_zone)
442
+ end
443
+ helper_method :blazer_time_value
444
+
445
+ def blazer_run_id
446
+ params[:run_id].to_s.gsub(/[^a-z0-9\-]/i, "")
447
+ end
448
+
449
+ def run_cohort_analysis
450
+ unless @statement.data_source.supports_cohort_analysis?
451
+ @cohort_error = "This data source does not support cohort analysis"
452
+ end
453
+
454
+ @show_cohort_rows = !params[:query_id] || @cohort_error
455
+ cohort_analysis_statement(@statement) unless @show_cohort_rows
456
+ end
457
+
458
+ def render_cohort_analysis
459
+ if @show_cohort_rows
460
+ @cohort_analysis = false
461
+
462
+ @row_limit = 1000
463
+
464
+ # check results
465
+ unless @cohort_error
466
+ # check names
467
+ expected_columns = [ "user_id", "conversion_time" ]
468
+ missing_columns = expected_columns - @result.columns
469
+ if missing_columns.any?
470
+ @cohort_error = "Expected user_id and conversion_time columns"
471
+ end
472
+
473
+ # check types (user_id can be any type)
474
+ unless @cohort_error
475
+ column_types = @result.columns.zip(@result.column_types).to_h
476
+
477
+ if !column_types["cohort_time"].in?([ "time", nil ])
478
+ @cohort_error = "cohort_time must be time column"
479
+ elsif !column_types["conversion_time"].in?([ "time", nil ])
480
+ @cohort_error = "conversion_time must be time column"
481
+ end
482
+ end
483
+ end
484
+ else
485
+ @today = Blazer.time_zone.today
486
+ @min_cohort_date, @max_cohort_date = @result.rows.map { |r| r[0] }.minmax
487
+ @buckets = {}
488
+ @rows.each do |r|
489
+ @buckets[[ r[0], r[1] ]] = r[2]
490
+ end
491
+
492
+ @cohort_dates = []
493
+ current_date = @max_cohort_date
494
+ while current_date && current_date >= @min_cohort_date
495
+ @cohort_dates << current_date
496
+ current_date =
497
+ case @cohort_period
498
+ when "day"
499
+ current_date - 1
500
+ when "week"
501
+ current_date - 7
502
+ else
503
+ current_date.prev_month
504
+ end
505
+ end
506
+
507
+ num_cols = @cohort_dates.size
508
+ @columns = [ "Cohort", "Users" ] + num_cols.times.map { |i| "#{@conversion_period.titleize} #{i + 1}" }
509
+ rows = []
510
+ date_format = @cohort_period == "month" ? "%b %Y" : "%b %-e, %Y"
511
+ @cohort_dates.each do |date|
512
+ row = [ date.strftime(date_format), @buckets[[ date, 0 ]] || 0 ]
513
+
514
+ num_cols.times do |i|
515
+ if @today >= date + (@cohort_days * i)
516
+ row << (@buckets[[ date, i + 1 ]] || 0)
517
+ end
518
+ end
519
+
520
+ rows << row
521
+ end
522
+ @rows = rows
523
+ end
524
+ end
525
+ end
526
+ end
527
+ #
528
+ # module Sage
529
+ # class QueriesController < ApplicationController
530
+ # before_action :set_data_source
531
+ #
532
+ # def new
533
+ # @query = Blazer::Query.new
534
+ # end
535
+ #
536
+ # def create
537
+ # @query = Blazer::Query.new(name: "Sage Query: #{Time.current}")
538
+ # @query.statement = generate_sql_from_question(query_params[:question])
539
+ # @query.creator = blazer_user if defined?(blazer_user)
540
+ #
541
+ # # Optionally save the query if configured
542
+ # if Sage.configuration.auto_save_queries && @query.statement.present?
543
+ # @query.save
544
+ # end
545
+ #
546
+ # # Store the question for display
547
+ # @question = query_params[:question]
548
+ #
549
+ # respond_to do |format|
550
+ # format.turbo_stream
551
+ # format.html { render :new }
552
+ # end
553
+ # end
554
+ #
555
+ # def run
556
+ # @query = Blazer::Query.new(statement: query_params[:sql])
557
+ #
558
+ # # Use Blazer's run method to execute the query
559
+ # @result = Blazer.data_sources[@data_source].run_statement(@query.statement)
560
+ #
561
+ # # Check for errors
562
+ # if @result.error.present?
563
+ # @error = @result.error
564
+ # end
565
+ #
566
+ # respond_to do |format|
567
+ # format.turbo_stream
568
+ # format.html
569
+ # end
570
+ # end
571
+ #
572
+ # private
573
+ #
574
+ # def set_data_source
575
+ # @data_source = params[:data_source] || Blazer.data_sources.keys.first
576
+ # end
577
+ #
578
+ # def query_params
579
+ # params.require(:query).permit(:question, :sql, :data_source)
580
+ # end
581
+ #
582
+ # def generate_sql_from_question(question)
583
+ # # Placeholder for AI integration
584
+ # # In production, this would call your AI service (OpenAI, Anthropic, etc.)
585
+ # "-- AI generated SQL for: #{question}\n" +
586
+ # "-- TODO: Integrate with AI service\n" +
587
+ # "SELECT 'Please configure AI service' as message;"
588
+ # end
589
+ #
590
+ # def blazer_user
591
+ # # Override this method to provide the current user
592
+ # # For example: current_user.email if using Devise
593
+ # "sage-user"
594
+ # end
595
+ # end
596
+ # end
@@ -0,0 +1,30 @@
1
+ module Sage
2
+ module ApplicationHelper
3
+ include Pagy::Frontend
4
+
5
+ # Provide access to Sage engine routes
6
+ def sage
7
+ Sage::Engine.routes.url_helpers
8
+ end
9
+
10
+ def messages_grouped_by_day(messages)
11
+ messages.group_by { |message| message.created_at.to_date }
12
+ .sort_by { |date, _| date }
13
+ end
14
+
15
+ def format_message_date(date)
16
+ case date
17
+ when Date.current
18
+ "Today"
19
+ when Date.current - 1
20
+ "Yesterday"
21
+ else
22
+ if date.year == Date.current.year
23
+ date.strftime("%B %d")
24
+ else
25
+ date.strftime("%B %d, %Y")
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ module Sage
2
+ module QueriesHelper
3
+ def messages_grouped_by_day(messages)
4
+ messages.group_by { |message| message.created_at.to_date }
5
+ .sort_by { |date, _| date }
6
+ end
7
+
8
+ def format_message_date(date)
9
+ case date
10
+ when Date.current
11
+ "Today"
12
+ when Date.current - 1
13
+ "Yesterday"
14
+ else
15
+ if date.year == Date.current.year
16
+ date.strftime("%B %d")
17
+ else
18
+ date.strftime("%B %d, %Y")
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ remove() {
5
+ this.element.remove()
6
+ }
7
+ }
@@ -0,0 +1,26 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { text: String }
5
+
6
+ connect() {
7
+ console.log("Clipboard controller connected");
8
+ }
9
+
10
+ copy(event) {
11
+ event.preventDefault()
12
+
13
+ navigator.clipboard.writeText(this.textValue).then(() => {
14
+ const originalText = event.target.textContent
15
+ event.target.textContent = "Copied!"
16
+ event.target.classList.add("btn-success")
17
+
18
+ setTimeout(() => {
19
+ event.target.textContent = originalText
20
+ event.target.classList.remove("btn-success")
21
+ }, 2000)
22
+ }).catch(err => {
23
+ console.error('Failed to copy text: ', err)
24
+ })
25
+ }
26
+ }