lookout-ahoy 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 (223) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +99 -0
  4. data/Rakefile +24 -0
  5. data/app/assets/images/lookout/apple-touch-icon.png +0 -0
  6. data/app/assets/images/lookout/favicon-16x16.png +0 -0
  7. data/app/assets/images/lookout/favicon-32x32.png +0 -0
  8. data/app/assets/images/lookout/logo.png +0 -0
  9. data/app/assets/images/lookout/safari-pinned-tab.png +0 -0
  10. data/app/assets/images/lookout/safari-pinned-tab.svg +199 -0
  11. data/app/assets/javascript/lookout/application.js +2 -0
  12. data/app/assets/javascript/lookout/controllers/application.js +9 -0
  13. data/app/assets/javascript/lookout/controllers/application_controller.js +33 -0
  14. data/app/assets/javascript/lookout/controllers/combobox_controller.js +371 -0
  15. data/app/assets/javascript/lookout/controllers/details_modal_controller.js +18 -0
  16. data/app/assets/javascript/lookout/controllers/dropdown_label_controller.js +39 -0
  17. data/app/assets/javascript/lookout/controllers/filter/item_controller.js +12 -0
  18. data/app/assets/javascript/lookout/controllers/filter_form_controller.js +13 -0
  19. data/app/assets/javascript/lookout/controllers/filter_modal_controller.js +45 -0
  20. data/app/assets/javascript/lookout/controllers/frame_link_controller.js +20 -0
  21. data/app/assets/javascript/lookout/controllers/funnel_chart_controller.js +159 -0
  22. data/app/assets/javascript/lookout/controllers/index.js +4 -0
  23. data/app/assets/javascript/lookout/controllers/interval_controller.js +15 -0
  24. data/app/assets/javascript/lookout/controllers/line_chart_controller.js +251 -0
  25. data/app/assets/javascript/lookout/controllers/predicate_select_controller.js +10 -0
  26. data/app/assets/javascript/lookout/controllers/properties_controller.js +8 -0
  27. data/app/assets/javascript/lookout/controllers/property_filter_controller.js +45 -0
  28. data/app/assets/javascript/lookout/controllers/realtime_controller.js +30 -0
  29. data/app/assets/javascript/lookout/controllers/sparkline_controller.js +64 -0
  30. data/app/assets/javascript/lookout/controllers/tile_controller.js +33 -0
  31. data/app/assets/javascript/lookout/controllers/toggle_controller.js +17 -0
  32. data/app/assets/javascript/lookout/helpers/chart_utils.js +156 -0
  33. data/app/assets/javascript/lookout/helpers/countries.js +2261 -0
  34. data/app/assets/javascript/lookout/helpers/number_formatters.js +55 -0
  35. data/app/assets/manifest/lookout/manifest.js +2 -0
  36. data/app/components/lookout/combobox_component.html.erb +33 -0
  37. data/app/components/lookout/combobox_component.rb +13 -0
  38. data/app/components/lookout/comparison_link_component.html.erb +17 -0
  39. data/app/components/lookout/comparison_link_component.rb +44 -0
  40. data/app/components/lookout/dropdown_button_component.html.erb +16 -0
  41. data/app/components/lookout/dropdown_button_component.rb +14 -0
  42. data/app/components/lookout/dropdown_link_component.html.erb +17 -0
  43. data/app/components/lookout/dropdown_link_component.rb +19 -0
  44. data/app/components/lookout/filter/dropdown_component.html.erb +50 -0
  45. data/app/components/lookout/filter/dropdown_component.rb +51 -0
  46. data/app/components/lookout/filter/modal_component.html.erb +16 -0
  47. data/app/components/lookout/filter/modal_component.rb +13 -0
  48. data/app/components/lookout/filter/select_component.html.erb +25 -0
  49. data/app/components/lookout/filter/select_component.rb +64 -0
  50. data/app/components/lookout/filter/tag_component.html.erb +13 -0
  51. data/app/components/lookout/filter/tag_component.rb +14 -0
  52. data/app/components/lookout/filter/tag_container_component.html.erb +4 -0
  53. data/app/components/lookout/filter/tag_container_component.rb +6 -0
  54. data/app/components/lookout/previous_next_component.html.erb +8 -0
  55. data/app/components/lookout/previous_next_component.rb +11 -0
  56. data/app/components/lookout/stats/comparable_container_component.html.erb +25 -0
  57. data/app/components/lookout/stats/comparable_container_component.rb +103 -0
  58. data/app/components/lookout/stats/container_component.html.erb +23 -0
  59. data/app/components/lookout/stats/container_component.rb +28 -0
  60. data/app/components/lookout/sticky_nav_component.html.erb +32 -0
  61. data/app/components/lookout/sticky_nav_component.rb +24 -0
  62. data/app/components/lookout/table_component.html.erb +16 -0
  63. data/app/components/lookout/table_component.rb +48 -0
  64. data/app/components/lookout/tables/devices_table_component.rb +11 -0
  65. data/app/components/lookout/tables/dynamic_table.rb +13 -0
  66. data/app/components/lookout/tables/dynamic_table_component.rb +207 -0
  67. data/app/components/lookout/tables/goals_table_component.rb +17 -0
  68. data/app/components/lookout/tables/header_component.html.erb +6 -0
  69. data/app/components/lookout/tables/header_component.rb +18 -0
  70. data/app/components/lookout/tables/headers/header_component.html.erb +5 -0
  71. data/app/components/lookout/tables/headers/header_component.rb +16 -0
  72. data/app/components/lookout/tables/properties_table_component.rb +27 -0
  73. data/app/components/lookout/tables/row_component.html.erb +4 -0
  74. data/app/components/lookout/tables/rows/row_component.html.erb +6 -0
  75. data/app/components/lookout/tables/rows/row_component.rb +40 -0
  76. data/app/components/lookout/tile_component.html.erb +24 -0
  77. data/app/components/lookout/tile_component.rb +24 -0
  78. data/app/components/lookout/tooltip_component.html.erb +3 -0
  79. data/app/components/lookout/tooltip_component.rb +18 -0
  80. data/app/controllers/lookout/application_controller.rb +83 -0
  81. data/app/controllers/lookout/campaigns_controller.rb +19 -0
  82. data/app/controllers/lookout/devices_controller.rb +20 -0
  83. data/app/controllers/lookout/entry_pages_controller.rb +19 -0
  84. data/app/controllers/lookout/exit_pages_controller.rb +19 -0
  85. data/app/controllers/lookout/exports_controller.rb +14 -0
  86. data/app/controllers/lookout/filters/base_controller.rb +15 -0
  87. data/app/controllers/lookout/filters/goals_controller.rb +9 -0
  88. data/app/controllers/lookout/filters/locations_controller.rb +11 -0
  89. data/app/controllers/lookout/filters/operating_systems/names_controller.rb +13 -0
  90. data/app/controllers/lookout/filters/operating_systems/versions_controller.rb +13 -0
  91. data/app/controllers/lookout/filters/pages/actions_controller.rb +13 -0
  92. data/app/controllers/lookout/filters/pages/entry_pages_controller.rb +14 -0
  93. data/app/controllers/lookout/filters/pages/exit_pages_controller.rb +15 -0
  94. data/app/controllers/lookout/filters/properties/names_controller.rb +29 -0
  95. data/app/controllers/lookout/filters/properties/values_controller.rb +15 -0
  96. data/app/controllers/lookout/filters/screens_controller.rb +11 -0
  97. data/app/controllers/lookout/filters/sources_controller.rb +11 -0
  98. data/app/controllers/lookout/filters/utms_controller.rb +10 -0
  99. data/app/controllers/lookout/funnels_controller.rb +8 -0
  100. data/app/controllers/lookout/goals_controller.rb +7 -0
  101. data/app/controllers/lookout/locations/cities_controller.rb +22 -0
  102. data/app/controllers/lookout/locations/countries_controller.rb +22 -0
  103. data/app/controllers/lookout/locations/maps_controller.rb +24 -0
  104. data/app/controllers/lookout/locations/regions_controller.rb +22 -0
  105. data/app/controllers/lookout/properties_controller.rb +73 -0
  106. data/app/controllers/lookout/realtimes_controller.rb +7 -0
  107. data/app/controllers/lookout/roots_controller.rb +6 -0
  108. data/app/controllers/lookout/sources_controller.rb +21 -0
  109. data/app/controllers/lookout/stats/base_controller.rb +148 -0
  110. data/app/controllers/lookout/stats/bounce_rates_controller.rb +12 -0
  111. data/app/controllers/lookout/stats/total_pageviews_controller.rb +10 -0
  112. data/app/controllers/lookout/stats/total_visits_controller.rb +10 -0
  113. data/app/controllers/lookout/stats/unique_visitors_controller.rb +11 -0
  114. data/app/controllers/lookout/stats/views_per_visits_controller.rb +11 -0
  115. data/app/controllers/lookout/stats/visit_durations_controller.rb +10 -0
  116. data/app/controllers/lookout/stats_controller.rb +7 -0
  117. data/app/controllers/lookout/top_pages_controller.rb +20 -0
  118. data/app/decorators/lookout/application_decorator.rb +58 -0
  119. data/app/decorators/lookout/campaign_decorator.rb +27 -0
  120. data/app/decorators/lookout/city_decorator.rb +24 -0
  121. data/app/decorators/lookout/country_decorator.rb +38 -0
  122. data/app/decorators/lookout/device_decorator.rb +27 -0
  123. data/app/decorators/lookout/entry_page_decorator.rb +7 -0
  124. data/app/decorators/lookout/exit_page_decorator.rb +7 -0
  125. data/app/decorators/lookout/page_decorator.rb +27 -0
  126. data/app/decorators/lookout/region_decorator.rb +28 -0
  127. data/app/decorators/lookout/source_decorator.rb +27 -0
  128. data/app/decorators/lookout/top_page_decorator.rb +7 -0
  129. data/app/helpers/lookout/application_helper.rb +124 -0
  130. data/app/models/concerns/lookout/compare_mode.rb +19 -0
  131. data/app/models/concerns/lookout/limitable.rb +17 -0
  132. data/app/models/concerns/lookout/range_options.rb +8 -0
  133. data/app/models/lookout/comparison_mode.rb +72 -0
  134. data/app/models/lookout/export.rb +48 -0
  135. data/app/models/lookout/filter_parser.rb +82 -0
  136. data/app/models/lookout/range_from_params.rb +78 -0
  137. data/app/models/lookout/rangeable.rb +7 -0
  138. data/app/models/lookout/widget.rb +15 -0
  139. data/app/presenters/lookout/dashboard_presenter.rb +53 -0
  140. data/app/presenters/lookout/funnel_presenter.rb +75 -0
  141. data/app/presenters/lookout/goals_presenter.rb +72 -0
  142. data/app/queries/concerns/lookout/comparable_queries.rb +25 -0
  143. data/app/queries/concerns/lookout/comparable_query.rb +152 -0
  144. data/app/queries/concerns/lookout/lazy_comparable_query.rb +42 -0
  145. data/app/queries/lookout/application_query.rb +186 -0
  146. data/app/queries/lookout/campaign_query.rb +14 -0
  147. data/app/queries/lookout/city_query.rb +14 -0
  148. data/app/queries/lookout/country_query.rb +10 -0
  149. data/app/queries/lookout/device_query.rb +10 -0
  150. data/app/queries/lookout/entry_pages_query.rb +18 -0
  151. data/app/queries/lookout/event_query.rb +42 -0
  152. data/app/queries/lookout/exit_pages_query.rb +19 -0
  153. data/app/queries/lookout/region_query.rb +14 -0
  154. data/app/queries/lookout/source_query.rb +11 -0
  155. data/app/queries/lookout/stats/average_views_per_visit_query.rb +20 -0
  156. data/app/queries/lookout/stats/average_visit_duration_query.rb +34 -0
  157. data/app/queries/lookout/stats/base_query.rb +18 -0
  158. data/app/queries/lookout/stats/bounce_rates_query.rb +33 -0
  159. data/app/queries/lookout/stats/total_pageviews_query.rb +9 -0
  160. data/app/queries/lookout/stats/total_visitors_query.rb +9 -0
  161. data/app/queries/lookout/stats/unique_visitors_query.rb +9 -0
  162. data/app/queries/lookout/stats/views_per_visit_query.rb +17 -0
  163. data/app/queries/lookout/stats/visit_duration_query.rb +19 -0
  164. data/app/queries/lookout/top_page_query.rb +13 -0
  165. data/app/queries/lookout/visit_query.rb +42 -0
  166. data/app/views/lookout/campaigns/index.html+details.erb +4 -0
  167. data/app/views/lookout/campaigns/index.html.erb +3 -0
  168. data/app/views/lookout/devices/_table.html.erb +2 -0
  169. data/app/views/lookout/devices/index.html+details.erb +4 -0
  170. data/app/views/lookout/devices/index.html.erb +3 -0
  171. data/app/views/lookout/entry_pages/index.html+details.erb +4 -0
  172. data/app/views/lookout/entry_pages/index.html.erb +3 -0
  173. data/app/views/lookout/exit_pages/index.html+details.erb +4 -0
  174. data/app/views/lookout/exit_pages/index.html.erb +3 -0
  175. data/app/views/lookout/funnels/index.html.erb +7 -0
  176. data/app/views/lookout/funnels/show.html.erb +15 -0
  177. data/app/views/lookout/goals/index.html.erb +4 -0
  178. data/app/views/lookout/layouts/application.html.erb +144 -0
  179. data/app/views/lookout/layouts/shared/_tile_loader.html.erb +5 -0
  180. data/app/views/lookout/layouts/shared/_widget_disabled.html+details.erb +3 -0
  181. data/app/views/lookout/layouts/shared/_widget_disabled.html.erb +3 -0
  182. data/app/views/lookout/locations/cities/index.html+details.erb +4 -0
  183. data/app/views/lookout/locations/cities/index.html.erb +3 -0
  184. data/app/views/lookout/locations/countries/index.html+details.erb +5 -0
  185. data/app/views/lookout/locations/countries/index.html.erb +3 -0
  186. data/app/views/lookout/locations/maps/_simple_map.html.erb +26 -0
  187. data/app/views/lookout/locations/maps/show.html.erb +106 -0
  188. data/app/views/lookout/locations/regions/index.html+details.erb +4 -0
  189. data/app/views/lookout/locations/regions/index.html.erb +3 -0
  190. data/app/views/lookout/properties/_form.html.erb +6 -0
  191. data/app/views/lookout/properties/index.html.erb +3 -0
  192. data/app/views/lookout/properties/show.html.erb +6 -0
  193. data/app/views/lookout/realtimes/show.html.erb +9 -0
  194. data/app/views/lookout/roots/_filters.html.erb +80 -0
  195. data/app/views/lookout/roots/show.html.erb +191 -0
  196. data/app/views/lookout/sources/index.html+details.erb +4 -0
  197. data/app/views/lookout/sources/index.html.erb +3 -0
  198. data/app/views/lookout/stats/base/index.html.erb +40 -0
  199. data/app/views/lookout/stats/show.html.erb +15 -0
  200. data/app/views/lookout/top_pages/index.html+details.erb +4 -0
  201. data/app/views/lookout/top_pages/index.html.erb +3 -0
  202. data/config/routes.rb +69 -0
  203. data/lib/generators/lookout/install_generator.rb +31 -0
  204. data/lib/generators/lookout/migration_generator.rb +21 -0
  205. data/lib/generators/lookout/templates/config.rb.tt +185 -0
  206. data/lib/generators/lookout/templates/migration.rb.tt +7 -0
  207. data/lib/lookout/active_record.rb +108 -0
  208. data/lib/lookout/ahoy/event_methods.rb +75 -0
  209. data/lib/lookout/ahoy/visit_methods.rb +24 -0
  210. data/lib/lookout/configuration.rb +58 -0
  211. data/lib/lookout/database_adapter.rb +168 -0
  212. data/lib/lookout/engine.rb +47 -0
  213. data/lib/lookout/filter_configuration/filter.rb +16 -0
  214. data/lib/lookout/filter_configuration/filter_collection.rb +48 -0
  215. data/lib/lookout/filters_configuration.rb +77 -0
  216. data/lib/lookout/funnels.rb +44 -0
  217. data/lib/lookout/goals.rb +51 -0
  218. data/lib/lookout/period_collection.rb +115 -0
  219. data/lib/lookout/predicate_label.rb +7 -0
  220. data/lib/lookout/railtie.rb +9 -0
  221. data/lib/lookout/version.rb +3 -0
  222. data/lib/lookout.rb +78 -0
  223. metadata +673 -0
@@ -0,0 +1,108 @@
1
+ module ActiveRecord
2
+ module Querying
3
+ delegate :with, to: :all
4
+ end
5
+
6
+ module WithMerger
7
+ def merge
8
+ super
9
+ merge_withs
10
+ relation
11
+ end
12
+
13
+ private
14
+
15
+ def merge_withs
16
+ relation.recursive_with = true if other.recursive_with?
17
+ other_values = other.with_values.reject { |value| relation.with_values.include?(value) }
18
+ relation.with!(*other_values) if other_values.any?
19
+ end
20
+ end
21
+
22
+ class Relation
23
+ class Merger
24
+ prepend WithMerger
25
+ end
26
+
27
+ def with(opts, *rest)
28
+ spawn.with!(opts, *rest)
29
+ end
30
+
31
+ def with!(opts, *rest)
32
+ if opts == :recursive
33
+ self.recursive_with = true
34
+ self.with_values += rest
35
+ else
36
+ self.with_values += [opts] + rest
37
+ end
38
+ self
39
+ end
40
+
41
+ def with_values
42
+ @values[:with] || []
43
+ end
44
+
45
+ def with_values=(values)
46
+ raise ImmutableRelation if @loaded
47
+
48
+ @values[:with] = values
49
+ end
50
+
51
+ def recursive_with?
52
+ @values[:recursive_with]
53
+ end
54
+
55
+ def recursive_with=(value)
56
+ raise ImmutableRelation if @loaded
57
+
58
+ @values[:recursive_with] = value
59
+ end
60
+
61
+ private
62
+
63
+ def build_arel(*args)
64
+ arel = super
65
+ build_with(arel) if @values[:with]
66
+ arel
67
+ end
68
+
69
+ def build_with(arel) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
70
+ return if with_values.empty?
71
+
72
+ with_statements = with_values.map do |with_value|
73
+ case with_value
74
+ when String then Arel::Nodes::SqlLiteral.new(with_value)
75
+ when Arel::Nodes::As then with_value
76
+ when Hash then build_with_value_from_hash(with_value)
77
+ when Array then build_with_value_from_array(with_value)
78
+ else
79
+ raise ArgumentError, "Unsupported argument type: #{with_value} #{with_value.class}"
80
+ end
81
+ end
82
+
83
+ recursive_with? ? arel.with(:recursive, with_statements) : arel.with(with_statements)
84
+ end
85
+
86
+ def build_with_value_from_array(array)
87
+ unless array.map(&:class).uniq == [Arel::Nodes::As]
88
+ raise ArgumentError, "Unsupported argument type: #{array} #{array.class}"
89
+ end
90
+
91
+ array
92
+ end
93
+
94
+ def build_with_value_from_hash(hash) # rubocop:disable Metrics/MethodLength
95
+ hash.map do |name, value|
96
+ table = Arel::Table.new(name)
97
+ expression = case value
98
+ when String then Arel::Nodes::SqlLiteral.new("(#{value})")
99
+ when ActiveRecord::Relation then value.arel
100
+ when Arel::SelectManager, Arel::Nodes::Union then value
101
+ else
102
+ raise ArgumentError, "Unsupported argument type: #{value} #{value.class}"
103
+ end
104
+ Arel::Nodes::As.new(table, expression)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,75 @@
1
+ module Lookout
2
+ module Ahoy
3
+ module EventMethods
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ scope :page_view, -> { where("name = '#{Lookout.config.event[:view_name]}'") }
8
+
9
+ ransacker :route do |_parent|
10
+ Arel.sql(Lookout.config.event[:url_column])
11
+ end
12
+
13
+ ransacker :entry_page do |parent|
14
+ Arel.sql("entry_pages.url")
15
+ end
16
+
17
+ ransacker :exit_page do |parent|
18
+ Arel.sql("exit_pages.url")
19
+ end
20
+
21
+ scope :with_entry_pages, -> {
22
+ with(entry_pages: self.select("MIN(#{table_name}.id) as min_id, #{Arel.sql("#{Lookout.config.event.url_column} AS url")}").where(name: Lookout.config.event[:view_name]).group("#{table_name}.properties")).joins("INNER JOIN entry_pages ON entry_pages.min_id = #{table_name}.id")
23
+ }
24
+
25
+ scope :with_exit_pages, -> {
26
+ with(exit_pages: self.select("MAX(#{table_name}.id) as max_id, #{Arel.sql("#{Lookout.config.event.url_column} AS url")}")
27
+ .where(name: Lookout.config.event[:view_name]).group("#{table_name}.properties"))
28
+ .joins("INNER JOIN exit_pages ON exit_pages.max_id = #{table_name}.id")
29
+ }
30
+
31
+ scope :with_routes, -> { where(Lookout.config.event[:url_exists]) }
32
+
33
+ scope :with_url, -> {
34
+ select(Arel.sql("#{Lookout.config.event.url_column} AS url"))
35
+ }
36
+
37
+ scope :distinct_url, -> {
38
+ distinct(Arel.sql("#{Lookout.config.event.url_column}"))
39
+ }
40
+
41
+ scope :with_property_values, ->(value) {
42
+ where(Lookout::DatabaseAdapter.json_key_exists("properties", value))
43
+ }
44
+
45
+ ransacker :properties, args: [:parent, :ransacker_args] do |parent, args|
46
+ Lookout::DatabaseAdapter.ransacker_json_extract(parent, args)
47
+ end
48
+
49
+ ransacker :goal,
50
+ formatter: ->(value) {
51
+ ::Arel::Nodes::SqlLiteral.new(
52
+ ::Lookout.config.goals[value].event_query.call.select(:id).to_sql
53
+ )
54
+ } do |parent|
55
+ parent.table[:id]
56
+ end
57
+ end
58
+
59
+ class_methods do
60
+ def ransackable_attributes(auth_object = nil)
61
+ super + [ "action", "controller", "id", "name", "page", "properties", "time", "url", "user_id", "visit_id", "goal"] + self._ransackers.keys
62
+ end
63
+
64
+ def ransackable_scopes(auth_object = nil)
65
+ super + [:with_property_values, :property_value_i_cont]
66
+ end
67
+
68
+ def ransackable_associations(auth_object = nil)
69
+ super + [:visit]
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
@@ -0,0 +1,24 @@
1
+ module Lookout
2
+ module Ahoy
3
+ module VisitMethods
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ ransacker :ref_domain do
8
+ expr = Lookout::DatabaseAdapter.domain_from("#{self.table_name}.referring_domain")
9
+ Arel.sql("(#{expr})")
10
+ end
11
+ end
12
+
13
+ class_methods do
14
+ def ransackable_attributes(auth = nil)
15
+ columns_hash.keys + ["ref_domain"]
16
+ end
17
+
18
+ def ransackable_associations(auth = nil)
19
+ super + ["events"]
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,58 @@
1
+ require 'lookout/period_collection'
2
+ require 'lookout/filters_configuration'
3
+
4
+ module Lookout
5
+ class Configuration
6
+ attr_accessor :view_name, :theme, :realtime_interval, :disabled_widgets, :return_path, :return_copy
7
+ attr_reader :goals, :funnels, :cache, :ranges, :event, :models, :filters, :predicate_labels
8
+ def initialize
9
+ @goals = GoalCollection.new
10
+ @funnels = FunnelCollection.new
11
+ @theme = "dark"
12
+ @return_path = "/"
13
+ @return_copy = "← Back"
14
+ @ranges = ::Lookout::PeriodCollection.load_default
15
+ @cache = ActiveSupport::OrderedOptions.new.tap do |option|
16
+ option.enabled = false
17
+ option.store = Rails.cache
18
+ option.ttl = 1.minute
19
+ end
20
+ @models = ActiveSupport::OrderedOptions.new.tap do |option|
21
+ option.event = "::Ahoy::Event"
22
+ option.visit = "::Ahoy::Visit"
23
+ end
24
+ @event = ActiveSupport::OrderedOptions.new.tap do |option|
25
+ option.view_name = "$view"
26
+ # Dynamically generate SQL based on database adapter
27
+ table_name = @models.event.parameterize.tableize
28
+ option.url_column = DatabaseAdapter.build_url_column(table_name)
29
+ option.url_exists = DatabaseAdapter.build_url_exists(table_name)
30
+ end
31
+ @filters = FiltersConfiguration.load_default
32
+ @predicate_labels = {
33
+ eq: 'equals',
34
+ not_eq: 'not equals',
35
+ cont: 'contains',
36
+ in: 'in',
37
+ not_in: 'not in',
38
+ }
39
+
40
+ @realtime_interval = 30.seconds
41
+ @disabled_widgets = []
42
+ end
43
+
44
+ def goal(id, &block)
45
+ instance = Goal.new
46
+ instance.id = id
47
+ instance.instance_exec(&block)
48
+ @goals.register(instance)
49
+ end
50
+
51
+ def funnel(id, &block)
52
+ instance = Funnel.new
53
+ instance.id = id
54
+ instance.instance_exec(&block)
55
+ @funnels.register(instance)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,168 @@
1
+ module Lookout
2
+ module DatabaseAdapter
3
+ class << self
4
+ # Detect the database adapter being used
5
+ def adapter_name
6
+ @adapter_name ||= detect_adapter
7
+ end
8
+
9
+ # Reset cached adapter name (useful for testing)
10
+ def reset!
11
+ @adapter_name = nil
12
+ end
13
+
14
+ def postgresql?
15
+ adapter_name == :postgresql
16
+ end
17
+
18
+ def sqlite?
19
+ adapter_name == :sqlite
20
+ end
21
+
22
+ # Generate SQL for concatenating strings
23
+ def concat(*args)
24
+ if postgresql?
25
+ "CONCAT(#{args.join(', ')})"
26
+ else # sqlite
27
+ args.join(' || ')
28
+ end
29
+ end
30
+
31
+ # Generate SQL for extracting JSON text value
32
+ # Usage: json_extract_text('properties', 'controller')
33
+ def json_extract_text(column, key)
34
+ if postgresql?
35
+ "#{column}->>'#{key}'"
36
+ else # sqlite
37
+ "JSON_EXTRACT(#{column}, '$.#{key}')"
38
+ end
39
+ end
40
+
41
+ # Generate SQL for checking if JSON key exists
42
+ def json_key_exists(column, key)
43
+ if postgresql?
44
+ "JSONB_EXISTS(#{column}, '#{key}')"
45
+ else # sqlite
46
+ "JSON_TYPE(#{column}, '$.#{key}') IS NOT NULL"
47
+ end
48
+ end
49
+
50
+ # Generate SQL for getting all JSON object keys
51
+ # Returns a query that produces rows with a 'keys' column
52
+ def json_object_keys(column)
53
+ if postgresql?
54
+ "jsonb_object_keys(#{column})"
55
+ else # sqlite
56
+ # SQLite doesn't have a built-in function for this
57
+ # We'll need to use json_each which returns key/value pairs
58
+ # This will be used in a subquery context
59
+ "json_each.key"
60
+ end
61
+ end
62
+
63
+ # Generate the FROM clause for getting JSON keys
64
+ # For SQLite, we need to use json_each()
65
+ def json_keys_from_clause(table_name, column)
66
+ if postgresql?
67
+ table_name
68
+ else # sqlite
69
+ "#{table_name}, json_each(#{table_name}.#{column})"
70
+ end
71
+ end
72
+
73
+ # Generate SQL for JSON contains check (for ransacker)
74
+ def json_infix_operator
75
+ if postgresql?
76
+ '->>'
77
+ else # sqlite
78
+ # We'll handle this differently in SQLite
79
+ # Return a marker that we can detect and handle specially
80
+ 'JSON_EXTRACT'
81
+ end
82
+ end
83
+
84
+ # Generate SQL for building URL from controller/action in properties
85
+ # table_name: the table name (e.g., 'ahoy_events')
86
+ def build_url_column(table_name)
87
+ controller = json_extract_text("#{table_name}.properties", 'controller')
88
+ action = json_extract_text("#{table_name}.properties", 'action')
89
+
90
+ if postgresql?
91
+ "CONCAT(#{controller}, '#', #{action})"
92
+ else # sqlite
93
+ "#{controller} || '#' || #{action}"
94
+ end
95
+ end
96
+
97
+ # Generate SQL for checking if URL properties exist
98
+ def build_url_exists(table_name)
99
+ controller_exists = json_key_exists("#{table_name}.properties", 'controller')
100
+ action_exists = json_key_exists("#{table_name}.properties", 'action')
101
+
102
+ "#{controller_exists} AND #{action_exists}"
103
+ end
104
+
105
+ # For SQLite JSON_EXTRACT in ransacker, we need special handling
106
+ def ransacker_json_extract(parent, key)
107
+ if postgresql?
108
+ Arel::Nodes::InfixOperation.new('->>', parent.table[:properties], Arel::Nodes.build_quoted(key))
109
+ else # sqlite
110
+ Arel::Nodes::NamedFunction.new(
111
+ 'JSON_EXTRACT',
112
+ [parent.table[:properties], Arel::Nodes.build_quoted("$.#{key}")]
113
+ )
114
+ end
115
+ end
116
+
117
+ # Extract domain from a referrer/URL column
118
+ # column can be a bare column (referring_domain) or qualified (table.referring_domain)
119
+ def domain_from(column)
120
+ if postgresql?
121
+ # Use regex form supported by Postgres
122
+ "substring(#{column} from '(?:.*://)?(?:www\\.)?([^/?]*)')"
123
+ else
124
+ # SQLite: derive domain using instr/substr
125
+ # Step 1: strip scheme
126
+ base = "CASE WHEN instr(#{column}, '://') > 0 THEN substr(#{column}, instr(#{column}, '://') + 3) ELSE #{column} END"
127
+ # Step 2: strip leading www.
128
+ no_www = "CASE WHEN substr(#{base}, 1, 4) = 'www.' THEN substr(#{base}, 5) ELSE #{base} END"
129
+ # Step 3: take up to first '/'
130
+ "CASE WHEN instr(#{no_www}, '/') > 0 THEN substr(#{no_www}, 1, instr(#{no_www}, '/') - 1) ELSE #{no_www} END"
131
+ end
132
+ end
133
+
134
+ # Cast a number to decimal/real for percentage calculations
135
+ def numeric_cast(expression)
136
+ if postgresql?
137
+ "#{expression}::numeric"
138
+ else # sqlite
139
+ "CAST(#{expression} AS REAL)"
140
+ end
141
+ end
142
+
143
+ # Calculate percentage: (numerator / denominator) * 100
144
+ def percentage_calculation(numerator, denominator)
145
+ if postgresql?
146
+ "(#{numerator}/#{denominator}::numeric) * 100"
147
+ else # sqlite
148
+ "(CAST(#{numerator} AS REAL) / #{denominator}) * 100"
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def detect_adapter
155
+ name = ActiveRecord::Base.connection_db_config.adapter.to_s
156
+ case name
157
+ when /postg/i
158
+ :postgresql
159
+ when /sqlite/i
160
+ :sqlite
161
+ else
162
+ raise "Unsupported database adapter: #{name}. Lookout supports PostgreSQL and SQLite only."
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+
@@ -0,0 +1,47 @@
1
+ require 'ahoy'
2
+ require 'turbo-rails'
3
+ require 'stimulus-rails'
4
+ require 'ransack'
5
+ require 'view_component'
6
+ require 'groupdate'
7
+ require 'pagy'
8
+ require 'zip'
9
+
10
+ module Ransack
11
+ module Nodes
12
+ class Condition
13
+
14
+ # allows for sql from a formatter
15
+ # see https://github.com/activerecord-hackery/ransack/issues/702
16
+ def casted_array?(predicate)
17
+ return unless predicate.is_a?(Arel::Nodes::Casted)
18
+
19
+ predicate.value.is_a?(Array)
20
+ end
21
+
22
+ end
23
+ end
24
+ end
25
+
26
+ Ransack.configure do |config|
27
+ config.add_predicate 'json_cont', arel_predicate: 'contains', formatter: proc { |v| JSON.parse(v) }
28
+ config.add_predicate 'json_eq', arel_predicate: 'eq', formatter: proc { |v| JSON.parse(v) }
29
+ end
30
+
31
+ module Lookout
32
+ class Engine < Rails::Engine
33
+ isolate_namespace ::Lookout
34
+
35
+ initializer "lookout.precompile" do |app|
36
+ if app.config.respond_to?(:assets)
37
+ app.config.assets.paths << Lookout::Engine.root.join("app/javascript")
38
+ app.config.assets.paths << Lookout::Engine.root.join("app/images")
39
+ app.config.assets.precompile << "lookout/application.js"
40
+ end
41
+ end
42
+
43
+ ActiveSupport.on_load(:active_record) do
44
+ require "lookout/active_record"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,16 @@
1
+ module Lookout
2
+ class FilterConfiguration
3
+ class Filter
4
+ attr_reader :column, :label, :url, :predicates, :multiple, :position
5
+
6
+ def initialize(label:, column:, url:, predicates: [:in, :not_in], multiple: true, position: nil)
7
+ @column = column
8
+ @label = label
9
+ @url = url
10
+ @predicates = predicates
11
+ @multiple = multiple
12
+ @position = position
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,48 @@
1
+ module Lookout
2
+ class FilterConfiguration
3
+ class FilterCollection
4
+ def initialize(label)
5
+ @label = label
6
+ @registry = []
7
+ end
8
+
9
+ def filter(label:, column:, url:, predicates: [:in, :not_in], multiple: true, position: nil)
10
+ position ||= @registry.size
11
+ if item = find(column)
12
+ @registry.delete(item)
13
+ end
14
+
15
+ @registry << FilterConfiguration::Filter.new(label: label, column: column, url: url, predicates: predicates, multiple: multiple, position: position)
16
+ @registry = @registry.sort_by { |filter| filter.position }
17
+ end
18
+
19
+ def each(&block)
20
+ @registry.each(&block)
21
+ end
22
+
23
+ def modal_name
24
+ "#{@label.parameterize.underscore}Modal"
25
+ end
26
+
27
+ def find(column)
28
+ @registry.find { |filter| filter.column == column.to_sym }
29
+ end
30
+
31
+ def delete(name)
32
+ @registry.delete_if { |filter| filter.column == name }
33
+ end
34
+
35
+ def filters
36
+ @registry
37
+ end
38
+
39
+ def [](name)
40
+ find(name)
41
+ end
42
+
43
+ def include?(name)
44
+ find(name).present?
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,77 @@
1
+ require 'lookout/filter_configuration/filter'
2
+ require 'lookout/filter_configuration/filter_collection'
3
+
4
+ module Lookout
5
+ class FiltersConfiguration
6
+ def self.load_default
7
+ new.tap do |config|
8
+ config.register("Page") do
9
+ filter column: :route, label: "Route", url: :filters_actions_path, predicates: [:in, :not_in]
10
+ filter column: :entry_page, label: "Entry Page", url: :filters_entry_pages_path, predicates: [:in, :not_in]
11
+ filter column: :exit_page, label: "Exit Page", url: :filters_exit_pages_path, predicates: [:in, :not_in]
12
+ end
13
+
14
+ config.register("Geography") do
15
+ filter column: :country, label: "Country", url: :filters_locations_countries_path, predicates: [:in, :not_in]
16
+ filter column: :region, label: "Region", url: :filters_locations_regions_path, predicates: [:in, :not_in]
17
+ filter column: :city, label: "City", url: :filters_locations_cities_path, predicates: [:in, :not_in]
18
+ end
19
+
20
+ config.register("Source") do
21
+ filter column: :referring_domain, label: "Source", url: :filters_sources_path, predicates: [:in, :not_in]
22
+ end
23
+
24
+ config.register("Screen size") do
25
+ filter column: :device_type, label: "Screen size", url: :filters_screens_path, predicates: [:in, :not_in]
26
+ end
27
+
28
+ config.register("Operating System") do
29
+ filter column: :os, label: "OS Name", url: :filters_names_path, predicates: [:in, :not_in]
30
+ filter column: :os_version, label: "OS Version", url: :filters_versions_path, predicates: [:in, :not_in]
31
+ end
32
+
33
+ config.register("UTM Tags") do
34
+ filter column: :utm_medium, label: "UTM Medium", url: :filters_utm_mediums_path, predicates: [:in, :not_in, :cont]
35
+ filter column: :utm_source, label: "UTM Source", url: :filters_utm_sources_path, predicates: [:in, :not_in, :cont]
36
+ filter column: :utm_campaign, label: "UTM Campaign", url: :filters_utm_campaigns_path, predicates: [:in, :not_in, :cont]
37
+ filter column: :utm_term, label: "UTM Term", url: :filters_utm_terms_path, predicates: [:in, :not_in, :cont]
38
+ filter column: :utm_content, label: "UTM Content", url: :filters_utm_contents_path, predicates: [:in, :not_in, :cont]
39
+ end
40
+
41
+ config.register("Goal") do
42
+ filter column: :goal, label: "Goal", url: :filters_goals_path, predicates: [:in]
43
+ end
44
+ end
45
+ end
46
+
47
+ def initialize
48
+ @registry = {}
49
+ end
50
+
51
+ def register(label, &block)
52
+ item = FilterConfiguration::FilterCollection.new(label)
53
+ item.instance_exec(&block)
54
+ @registry[label] = item
55
+ end
56
+
57
+ def [](val)
58
+ @registry[val]
59
+ end
60
+
61
+ def delete(name)
62
+ @registry.delete(name)
63
+ end
64
+
65
+ def reset
66
+ @registry = {}
67
+ end
68
+
69
+ def each(&block)
70
+ @registry.each(&block)
71
+ end
72
+
73
+ def detect(&block)
74
+ @registry.detect(&block)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,44 @@
1
+ module Lookout
2
+ class Funnel
3
+ attr_accessor :id
4
+ attr_reader :goals
5
+
6
+ def initialize
7
+ @id = nil
8
+ @label = nil
9
+ @goals = []
10
+ end
11
+
12
+ def goal(id)
13
+ @goals << Lookout.config.goals[id]
14
+ end
15
+
16
+ def label(value)
17
+ @label = value
18
+ end
19
+
20
+ def title
21
+ @label
22
+ end
23
+ end
24
+
25
+ class FunnelCollection
26
+ include Enumerable
27
+
28
+ def initialize
29
+ @funnels = {}
30
+ end
31
+
32
+ def register(funnel)
33
+ @funnels[funnel.id] = funnel
34
+ end
35
+
36
+ def each(&block)
37
+ @funnels.each(&block)
38
+ end
39
+
40
+ def [](value)
41
+ @funnels[value.to_sym]
42
+ end
43
+ end
44
+ end