rails_analytics 1.1.1 → 1.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6a607ecb6f634b056bce7db997068d29e41ecc29c20d1fe38fb352a2e81b70d
4
- data.tar.gz: 9ce65316e5f58c2c644df424315d29597d129560ead62e530c2499a1cbd2c26f
3
+ metadata.gz: 235ecc86f0e45e6c0989fbe9a5bc7b3623ab9f5b1f38ede89d991d938c6b6235
4
+ data.tar.gz: 7e46192a35e21e6e255b6f87f09329ddd74d1b2bcd177beaa5063161d4361006
5
5
  SHA512:
6
- metadata.gz: 48c2a3920a2ffab6d0d59093608dd61607c6a3fabf1f1cb998b0acd368781473bdcd672e9a4d384b439123a9029912d9e519ef08e0e6624cd87721b5f4115541
7
- data.tar.gz: c19d0b161aeeb89208cac8c59e975e21be96539cb7ac1adbd8a6e693fcd0461decce6a65338a04262fac527119438671e66f3e6d1eedb06c688450463765850f
6
+ metadata.gz: b30ba06c428da41976626821312671a5c66f8463522b5e35bdef6355b60762deb06b33ba51b518e1381378caf083ec418e378511d358e9c0d402d1ca316e9382
7
+ data.tar.gz: d06bdbee94eb980257e973ed7d9c7158af4203b8d907af502f56c15935c592cbaa8f6d3cb39e00e0f7cc6a105695fc478211a7ef4c07d5e7e032b985acab4c70
data/README.md CHANGED
@@ -9,6 +9,12 @@ The dashboard's chart shows CSS-only tooltips: hover (or keyboard-focus) any
9
9
  data point to see its date and visit count — labels on the x-axis mark first,
10
10
  last and month boundaries. No JavaScript anywhere.
11
11
 
12
+ The **Journeys** view lists recent sessions as cards: each card shows the
13
+ user's ordered trajectory (landing page → clicks → scroll depth → forms) with
14
+ arrows between steps, hover/focus details on every step, and origin, device
15
+ and UTM in the card header. Filter by event name to see the journeys that
16
+ contain a specific click/topic.
17
+
12
18
  ## Screenshots
13
19
 
14
20
  | Dashboard — aggregated overview | Dashboard — events & visits |
@@ -139,6 +139,107 @@ body {
139
139
  }
140
140
  }
141
141
 
142
+ /* Journey cards */
143
+ .ra-journey {
144
+ border: 1px solid var(--ra-border);
145
+ border-radius: var(--ra-radius);
146
+ margin-bottom: 16px;
147
+ overflow: hidden;
148
+ }
149
+
150
+ .ra-journey-head {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: 8px;
154
+ padding: 10px 14px;
155
+ background: var(--ra-accent-soft);
156
+ border-bottom: 1px solid var(--ra-border);
157
+ font-size: 12px;
158
+ }
159
+
160
+ .ra-journey-origin {
161
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
162
+ font-weight: 700;
163
+ }
164
+ .ra-journey-origin:empty::before { content: "direct"; }
165
+
166
+ .ra-journey-meta { color: var(--ra-muted); }
167
+ .ra-journey-utm {
168
+ background: var(--ra-accent);
169
+ color: #fff;
170
+ border-radius: 10px;
171
+ padding: 1px 8px;
172
+ font-size: 11px;
173
+ }
174
+ .ra-journey-steps { margin-left: auto; color: var(--ra-muted); font-variant-numeric: tabular-nums; }
175
+
176
+ .ra-journey-path {
177
+ display: flex;
178
+ flex-wrap: wrap;
179
+ gap: 4px;
180
+ align-items: center;
181
+ padding: 14px;
182
+ }
183
+
184
+ .ra-journey-arrow {
185
+ color: var(--ra-muted);
186
+ font-size: 12px;
187
+ flex: none;
188
+ }
189
+
190
+ /* Step chip. Tooltip is CSS-only: shown on :hover and :focus. */
191
+ .ra-journey-step {
192
+ position: relative;
193
+ display: inline-flex;
194
+ align-items: center;
195
+ border: 1px solid var(--ra-border);
196
+ border-radius: 999px;
197
+ padding: 4px 12px;
198
+ background: var(--ra-card);
199
+ cursor: default;
200
+ outline: none;
201
+ }
202
+ .ra-journey-step:focus-visible { border-color: var(--ra-accent); }
203
+
204
+ .ra-journey-step:focus-visible,
205
+ .ra-journey-step:focus:not(:focus-visible) {
206
+ border-color: var(--ra-accent);
207
+ }
208
+
209
+ .ra-journey-step--click { border-color: var(--ra-accent); color: var(--ra-accent); font-weight: 600; }
210
+ .ra-journey-step--outbound { border-style: dashed; color: var(--ra-muted); }
211
+ .ra-journey-step--form { border-color: #059669; color: #059669; }
212
+ .ra-journey-step--scroll { color: var(--ra-muted); }
213
+ .ra-journey-step--email { border-color: #d97706; color: #d97706; }
214
+ .ra-journey-step--pageview { background: var(--ra-bg); font-weight: 600; }
215
+
216
+ .ra-journey-step-detail {
217
+ position: absolute;
218
+ bottom: calc(100% + 8px);
219
+ left: 50%;
220
+ transform: translateX(-50%);
221
+ min-width: 140px;
222
+ max-width: 260px;
223
+ background: var(--ra-text);
224
+ color: var(--ra-card);
225
+ border-radius: 8px;
226
+ padding: 6px 10px;
227
+ font-size: 12px;
228
+ line-height: 1.5;
229
+ display: flex;
230
+ flex-direction: column;
231
+ gap: 2px;
232
+ opacity: 0;
233
+ pointer-events: none;
234
+ transition: opacity 0.12s ease;
235
+ z-index: 10;
236
+ }
237
+
238
+ .ra-journey-step:hover .ra-journey-step-detail,
239
+ .ra-journey-step:focus .ra-journey-step-detail { opacity: 1; }
240
+
241
+ .ra-journey-step-time { font-weight: 700; opacity: 0.75; }
242
+
142
243
  /* Pagination */
143
244
  .ra-pagination {
144
245
  display: flex;
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAnalytics
4
+ class JourneysController < ApplicationController
5
+ def index
6
+ @event_name = params[:event]
7
+ @page = (params[:page] || 1).to_i
8
+ @journeys = Stats.journeys(event: @event_name.presence, page: @page)
9
+ @event_names = Stats.event_counts_by_name.keys.sort
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAnalytics
4
+ # Serializes a Visit (session) into a user journey: ordered steps with
5
+ # kind/label/detail, plus origin and context metadata for the card header.
6
+ class Journey
7
+ def initialize(visit)
8
+ @visit = visit
9
+ end
10
+
11
+ def to_h
12
+ {
13
+ id: @visit.id,
14
+ origin: origin,
15
+ device: @visit.device_type,
16
+ viewport: @visit.viewport,
17
+ language: @visit.language,
18
+ utm: utm,
19
+ started_at: @visit.started_at,
20
+ duration: duration,
21
+ steps: steps
22
+ }
23
+ end
24
+
25
+ private
26
+
27
+ def origin
28
+ @visit.referrer_domain.presence || "direct"
29
+ end
30
+
31
+ def utm
32
+ {
33
+ source: @visit.utm_source,
34
+ campaign: @visit.utm_campaign,
35
+ medium: @visit.utm_medium
36
+ }.compact
37
+ end
38
+
39
+ def duration
40
+ last = @visit.events.map(&:time).max || @visit.started_at
41
+ (last - @visit.started_at).to_i
42
+ end
43
+
44
+ # Ordered: landing page first, then tracked events by time.
45
+ def steps
46
+ sorted = @visit.events.sort_by(&:time)
47
+ [pageview_step] + sorted.map { |event| event_step(event) }
48
+ end
49
+
50
+ def pageview_step
51
+ {
52
+ kind: :pageview,
53
+ label: @visit.landing_page_path.to_s.split("?").first.presence || "/",
54
+ detail: pageview_detail,
55
+ time: @visit.started_at
56
+ }
57
+ end
58
+
59
+ def pageview_detail
60
+ parts = [I18n.t("rails_analytics.journeys.devices.#{@visit.device_type}", default: @visit.device_type)]
61
+ parts << I18n.t("rails_analytics.journeys.details.viewport", value: @visit.viewport) if @visit.viewport.present?
62
+ parts << I18n.t("rails_analytics.journeys.details.language", value: @visit.language) if @visit.language.present?
63
+ parts.join(" · ")
64
+ end
65
+
66
+ def event_step(event)
67
+ props = event.properties.is_a?(Hash) ? event.properties.symbolize_keys : {}
68
+ kind = kind_for(event.name)
69
+
70
+ {
71
+ kind: kind,
72
+ label: label_for(event.name, kind, props),
73
+ detail: detail_for(props),
74
+ time: event.time
75
+ }
76
+ end
77
+
78
+ def kind_for(name)
79
+ case name
80
+ when /outbound/ then :outbound
81
+ when /email/ then :email
82
+ when /scroll/ then :scroll
83
+ when /form/ then :form
84
+ when /time/ then :time
85
+ when /click/ then :click
86
+ else :other
87
+ end
88
+ end
89
+
90
+ def label_for(name, kind, props)
91
+ case kind
92
+ when :click then props[:value].presence || name
93
+ when :scroll then I18n.t("rails_analytics.journeys.steps.scroll", mark: props[:mark].to_i)
94
+ when :outbound then props[:target].presence || name
95
+ when :email then I18n.t("rails_analytics.journeys.steps.email")
96
+ when :form then I18n.t("rails_analytics.journeys.steps.form", form: props[:form].to_s)
97
+ when :time then I18n.t("rails_analytics.journeys.steps.time_on_page", seconds: props[:seconds].to_i)
98
+ else props[:value].presence || name
99
+ end
100
+ end
101
+
102
+ def detail_for(props)
103
+ return if props.empty?
104
+
105
+ props
106
+ .reject { |_, value| value.blank? }
107
+ .map { |key, value| "#{key}: #{value}" }
108
+ .join(" · ")
109
+ end
110
+ end
111
+ end
@@ -14,6 +14,7 @@
14
14
  <%= link_to t("rails_analytics.nav.overview"), rails_analytics.root_path %>
15
15
  <%= link_to t("rails_analytics.nav.events"), rails_analytics.events_path %>
16
16
  <%= link_to t("rails_analytics.nav.visits"), rails_analytics.visits_path %>
17
+ <%= link_to t("rails_analytics.nav.journeys"), rails_analytics.journeys_path %>
17
18
  </nav>
18
19
  </header>
19
20
  <main class="ra-main">
@@ -28,9 +28,11 @@
28
28
  <% @events[:data].each do |row| %>
29
29
  <tr>
30
30
  <td class="ra-mono"><%= row[:date] %></td>
31
- <td class="ra-mono"><%= row[:name] %></td>
32
- <td class="ra-num"><%= number_with_delimiter(row[:count]) %></td>
33
- </tr>
31
+ <td class="ra-mono">
32
+ <%= row[:name] %>
33
+ <%= link_to t("rails_analytics.journeys.view"), rails_analytics.journeys_path(event: row[:name]) %>
34
+ </td>
35
+ <td class="ra-num"><%= number_with_delimiter(row[:count]) %></td> </tr>
34
36
  <% end %>
35
37
  </tbody>
36
38
  </table>
@@ -53,7 +53,13 @@
53
53
  </thead>
54
54
  <tbody>
55
55
  <% @stats[:top_events].each do |name, count| %>
56
- <tr><td class="ra-mono"><%= name %></td><td class="ra-num"><%= number_with_delimiter(count) %></td></tr>
56
+ <tr>
57
+ <td class="ra-mono">
58
+ <%= name %>
59
+ <%= link_to t("rails_analytics.journeys.view"), rails_analytics.journeys_path(event: name) %>
60
+ </td>
61
+ <td class="ra-num"><%= number_with_delimiter(count) %></td>
62
+ </tr>
57
63
  <% end %>
58
64
  </tbody>
59
65
  </table>
@@ -0,0 +1,62 @@
1
+ <%# app/views/rails_analytics/journeys/index.html.erb %>
2
+ <% fmt = t("rails_analytics.journeys.datetime_format") %>
3
+ <section class="ra-card">
4
+ <h2 class="ra-card-title"><%= t("rails_analytics.journeys.title") %></h2>
5
+
6
+ <div class="ra-filter">
7
+ <%= form_tag rails_analytics.journeys_path, method: :get do %>
8
+ <%= select_tag :event,
9
+ options_for_select(
10
+ [[t("rails_analytics.filter.all_events"), ""]] +
11
+ @event_names.map { |n| [n, n] },
12
+ @event_name
13
+ ),
14
+ onchange: "this.form.submit()" %>
15
+ <noscript><%= submit_tag t("rails_analytics.filter.filter") %></noscript>
16
+ <% end %>
17
+ </div>
18
+
19
+ <% if @journeys[:data].any? %>
20
+ <% @journeys[:data].each do |journey| %>
21
+ <% j = journey.to_h %>
22
+ <article class="ra-journey">
23
+ <header class="ra-journey-head">
24
+ <span class="ra-journey-origin" title="<%= t("rails_analytics.journeys.origin") %>"><%= j[:origin] %></span>
25
+ <span class="ra-journey-meta"><%= j[:device] %></span>
26
+ <% if j[:utm][:source].present? %>
27
+ <span class="ra-journey-utm" title="<%= j[:utm].map { |k, v| "#{k}: #{v}" }.join(', ') %>"><%= j[:utm][:source] %></span>
28
+ <% end %>
29
+ <span class="ra-journey-steps">
30
+ <%= t("rails_analytics.journeys.step_count", count: j[:steps].size) %>
31
+ </span>
32
+ <time class="ra-journey-steps"><%= j[:started_at].strftime(fmt) %></time>
33
+ </header>
34
+
35
+ <div class="ra-journey-path">
36
+ <% j[:steps].each_with_index do |step, i| %>
37
+ <% if i > 0 %><span class="ra-journey-arrow" aria-hidden="true">→</span><% end %>
38
+ <span class="ra-journey-step ra-journey-step--<%= step[:kind] %>" tabindex="0">
39
+ <span class="ra-journey-step-label"><%= step[:label] %></span>
40
+ <span class="ra-journey-step-detail" role="tooltip">
41
+ <span class="ra-journey-step-time"><%= step[:time].strftime(fmt) if step[:time] %></span>
42
+ <% if step[:detail].present? %><span><%= step[:detail] %></span><% end %>
43
+ </span>
44
+ </span>
45
+ <% end %>
46
+ </div>
47
+ </article>
48
+ <% end %>
49
+
50
+ <div class="ra-pagination">
51
+ <% if @page > 1 %>
52
+ <%= link_to t("rails_analytics.pagination.previous"), rails_analytics.journeys_path(page: @page - 1, event: @event_name) %>
53
+ <% end %>
54
+ <span class="current"><%= @page %></span>
55
+ <% if @journeys[:data].length == @journeys[:per_page] %>
56
+ <%= link_to t("rails_analytics.pagination.next"), rails_analytics.journeys_path(page: @page + 1, event: @event_name) %>
57
+ <% end %>
58
+ </div>
59
+ <% else %>
60
+ <p class="ra-empty"><%= t("rails_analytics.empty") %></p>
61
+ <% end %>
62
+ </section>
@@ -5,6 +5,7 @@ en:
5
5
  overview: "Overview"
6
6
  events: "Events"
7
7
  visits: "Visits"
8
+ journeys: "Journeys"
8
9
  footer: "Aggregated and anonymous data — 6 month retention"
9
10
  kpi:
10
11
  visits: "Visits"
@@ -38,4 +39,25 @@ en:
38
39
  filter: "Filter"
39
40
  pagination:
40
41
  previous: "Previous"
41
- next: "Next"
42
+ next: "Next"
43
+ journeys:
44
+ view: "journeys"
45
+ title: "Journeys by session"
46
+ datetime_format: "%m/%d %H:%M"
47
+ origin: "Origin"
48
+ step_count:
49
+ one: "1 step"
50
+ other: "%{count} steps"
51
+ steps:
52
+ scroll: "Scroll %{mark}%"
53
+ form: "Form %{form}"
54
+ email: "Email"
55
+ time_on_page: "%{seconds}s on page"
56
+ devices:
57
+ desktop: "Desktop"
58
+ mobile: "Mobile"
59
+ tablet: "Tablet"
60
+ unknown: "Unknown"
61
+ details:
62
+ viewport: "Screen %{value}"
63
+ language: "Language %{value}"
@@ -5,6 +5,7 @@ pt-BR:
5
5
  overview: "Visão geral"
6
6
  events: "Eventos"
7
7
  visits: "Visitas"
8
+ journeys: "Trajetórias"
8
9
  footer: "Dados agregados e anônimos — retenção 6 meses"
9
10
  kpi:
10
11
  visits: "Visitas"
@@ -38,4 +39,25 @@ pt-BR:
38
39
  filter: "Filtrar"
39
40
  pagination:
40
41
  previous: "Anterior"
41
- next: "Próximo"
42
+ next: "Próximo"
43
+ journeys:
44
+ view: "trajetórias"
45
+ title: "Trajetórias por sessão"
46
+ datetime_format: "%d/%m %H:%M"
47
+ origin: "Origem"
48
+ step_count:
49
+ one: "1 passo"
50
+ other: "%{count} passos"
51
+ steps:
52
+ scroll: "Scroll %{mark}%"
53
+ form: "Form %{form}"
54
+ email: "E-mail"
55
+ time_on_page: "%{seconds}s na página"
56
+ devices:
57
+ desktop: "Desktop"
58
+ mobile: "Mobile"
59
+ tablet: "Tablet"
60
+ unknown: "Desconhecido"
61
+ details:
62
+ viewport: "Tela %{value}"
63
+ language: "Idioma %{value}"
data/config/routes.rb CHANGED
@@ -8,4 +8,5 @@ RailsAnalytics::Engine.routes.draw do
8
8
  root to: "dashboards#overview"
9
9
  get "events" => "dashboards#events"
10
10
  get "visits" => "dashboards#visits"
11
+ get "journeys" => "journeys#index"
11
12
  end
@@ -139,6 +139,21 @@ module RailsAnalytics
139
139
  Visit.since(since)
140
140
  end
141
141
 
142
+ # Sessions with their events, newest first. Optional filter by event name.
143
+ def journeys(event: nil, page: 1, per_page: 10, since: default_since)
144
+ scope = base_scope(since)
145
+ if event.present?
146
+ scope = scope.joins(:events).merge(Event.named(event))
147
+ end
148
+ visits = scope.order(started_at: :desc)
149
+ .offset((page - 1) * per_page)
150
+ .limit(per_page)
151
+ .includes(:events)
152
+
153
+ { data: visits.map { |visit| Journey.new(visit) },
154
+ total: scope.count, page: page, per_page: per_page }
155
+ end
156
+
142
157
  def default_since
143
158
  RailsAnalytics.config.since_default.ago
144
159
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAnalytics
4
- VERSION = "1.1.1"
4
+ VERSION = "1.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_analytics
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - br4zz4
@@ -40,16 +40,19 @@ files:
40
40
  - app/controllers/rails_analytics/analytics_controller.rb
41
41
  - app/controllers/rails_analytics/application_controller.rb
42
42
  - app/controllers/rails_analytics/dashboards_controller.rb
43
+ - app/controllers/rails_analytics/journeys_controller.rb
43
44
  - app/helpers/rails_analytics/application_helper.rb
44
45
  - app/jobs/rails_analytics/retention_job.rb
45
46
  - app/models/rails_analytics/chart_builder.rb
46
47
  - app/models/rails_analytics/daily_salt.rb
47
48
  - app/models/rails_analytics/event.rb
49
+ - app/models/rails_analytics/journey.rb
48
50
  - app/models/rails_analytics/visit.rb
49
51
  - app/views/layouts/rails_analytics.html.erb
50
52
  - app/views/rails_analytics/dashboards/events.html.erb
51
53
  - app/views/rails_analytics/dashboards/overview.html.erb
52
54
  - app/views/rails_analytics/dashboards/visits.html.erb
55
+ - app/views/rails_analytics/journeys/index.html.erb
53
56
  - config/locales/rails_analytics.en.yml
54
57
  - config/locales/rails_analytics.pt-BR.yml
55
58
  - config/routes.rb