ruby_cms 0.2.0.9 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73750814275495274cd4153a97c081ff9b93e63b1afb62714ed9eec06ef9aa76
4
- data.tar.gz: dfd98b9a461601b9feadbcf55817d23dc62eb8bfacfa3992548d7d623bb91b69
3
+ metadata.gz: 6d3c084a1a0a48bcb56bfe935ed7005b29c967617575e4ee284adbdfe5d020b8
4
+ data.tar.gz: 8d2b6780c2195cb72fb571e9c532e1b20092132ba96699fc918e407c820a8b59
5
5
  SHA512:
6
- metadata.gz: e8245f92e21637fd81d019629b800774261bf7aa83a8810c54836a5089164be05b039822201413bb8e5c80e90036a4a5b969344069b171d81882b89a1563e0cf
7
- data.tar.gz: cf0669c97fc3f289a111e76d38a93e8eec8f22839869cfe0bc3256bdf7648eee45e663458499e8d271d5cbe3b3057288862114644a1542c50858d75c242f93d8
6
+ metadata.gz: 57a352ac8f7998cf1ae2287fd7f4c7264d90d000254a4be81f85b5e3c21ec35589cbc255f7ea54985f30498ad486d8ccdbd7b5eb73ff73fb133b43bef53d00cd
7
+ data.tar.gz: 3fa14b8361bcadd7558965276034cd40461dc49c8ac5831fbf97d21aa0b56bfa03d2363b12395f363b54389f94a9bc8de05cb4f074f36bc0c0bcf301698d12e0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.1.0] - 2026-04-10
4
+
5
+ - Analytics rework and dashboard changes
6
+
3
7
  ## [0.2.0.8] - 2026-04-09
4
8
 
5
9
  - Analytics performance: migration adds `ahoy_events (name, time)`, `ahoy_events (visit_id, time)`, `ahoy_visits (started_at)`, `ahoy_visits (visitor_token)` indexes
@@ -117,7 +117,7 @@ module RubyCms
117
117
  nil
118
118
  end
119
119
 
120
- helper_method :format_chart_date, :format_chart_date_short
120
+ helper_method :format_chart_date, :format_chart_date_short, :format_chart_date_axis
121
121
 
122
122
  def format_chart_date(date_string)
123
123
  format_chart_date_by_granularity(date_string, long: true)
@@ -131,6 +131,16 @@ module RubyCms
131
131
  date_string.to_s
132
132
  end
133
133
 
134
+ # Compact x-axis tick for daily chart (month view: day number only to avoid crowded labels)
135
+ def format_chart_date_axis(date_string)
136
+ date = Date.parse(date_string.to_s)
137
+ return date.day.to_s if @period == "month"
138
+
139
+ format_chart_date_short(date_string)
140
+ rescue Date::Error
141
+ date_string.to_s
142
+ end
143
+
134
144
  def format_daily_date(date_string)
135
145
  date = Date.parse(date_string.to_s)
136
146
  if @period == "month"
@@ -26,9 +26,8 @@ module RubyCms
26
26
 
27
27
  def assign_dashboard_blocks
28
28
  visible = RubyCms.visible_dashboard_blocks(user: current_user_cms)
29
- @stats_blocks = visible
30
- .select {|b| b[:section] == :stats }
31
- .map {|b| prepare_dashboard_block(b) }
29
+ # Top :stats row (pages, users, permissions, errors) is not rendered on the dashboard layout.
30
+ @stats_blocks = []
32
31
  main = visible
33
32
  .select {|b| b[:section] == :main }
34
33
  .map {|b| prepare_dashboard_block(b) }
@@ -58,7 +58,6 @@ export function registerRubyCmsControllers(application) {
58
58
  "ruby-cms--nav-order-sortable",
59
59
  NavOrderSortableController,
60
60
  );
61
-
62
61
  registeredApplications.add(application);
63
62
  }
64
63
 
@@ -37,23 +37,23 @@
37
37
  <% end %>
38
38
  </div>
39
39
 
40
- <%# ── Activity charts (3/4) + KPI summary card (1/4) ── %>
41
- <div class="grid gap-4 items-start" style="grid-template-columns: 3fr 1fr;">
40
+ <%# ── Activity charts (3/4) + KPI summary card (1/4), same row height ── %>
41
+ <div class="grid gap-4 items-stretch" style="grid-template-columns: 3fr 1fr;">
42
42
 
43
43
  <%# Left: activity charts stacked %>
44
- <div class="space-y-4 min-w-0">
44
+ <div class="flex min-h-0 min-w-0 flex-col gap-4">
45
45
  <%= render "ruby_cms/admin/analytics/partials/daily_activity_chart" %>
46
46
  <%= render "ruby_cms/admin/analytics/partials/hourly_activity_chart" %>
47
47
  </div>
48
48
 
49
- <%# Right: single KPI summary card %>
50
- <div class="rounded-xl border border-border/60 bg-white shadow-sm overflow-hidden">
51
- <div class="px-4 py-3 border-b border-border/60">
49
+ <%# Right: KPI summary stretches to match combined chart column height %>
50
+ <div class="flex h-full min-h-0 min-w-0 flex-col rounded-xl border border-border/60 bg-white shadow-sm overflow-hidden">
51
+ <div class="shrink-0 px-4 py-3 border-b border-border/60">
52
52
  <p class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Overview</p>
53
53
  </div>
54
54
 
55
55
  <%# Active users now %>
56
- <div class="px-4 py-3 bg-emerald-50/60 border-b border-emerald-100">
56
+ <div class="shrink-0 px-4 py-3 bg-emerald-50/60 border-b border-emerald-100">
57
57
  <div class="flex items-center justify-between">
58
58
  <div>
59
59
  <p class="text-xs font-medium text-emerald-700">Nu actief</p>
@@ -73,7 +73,7 @@
73
73
  </div>
74
74
  </div>
75
75
 
76
- <div class="divide-y divide-border/50">
76
+ <div class="min-h-0 flex-1 divide-y divide-border/50 overflow-y-auto">
77
77
 
78
78
  <%# Page Views %>
79
79
  <div class="px-4 py-3">
@@ -1,76 +1,331 @@
1
- <div class="rounded-xl border border-border/60 bg-white shadow-sm overflow-hidden">
1
+ <%
2
+ chart_id = "rcms-daily-#{SecureRandom.hex(4)}"
3
+ daily_arr = @daily_activity.is_a?(Hash) ? @daily_activity.to_a : (@daily_activity || [])
4
+ has_data = daily_arr.present? && daily_arr.any?
5
+ max_views = daily_arr.map { |_k, v| v.to_i }.max.to_i
6
+ max_visitors = (@daily_visitors || {}).values.map(&:to_i).max.to_i
7
+ max_value = [max_views, max_visitors, 1].max
8
+ # Plot height + x-axis band (month view labels like "03/12–03/14" need ~32–36px; 20px was too tight and clipped)
9
+ bar_area_h = 160
10
+ x_axis_h = 40
11
+ chart_h = bar_area_h + x_axis_h
12
+ total_views = daily_arr.sum { |_k, v| v.to_i }
13
+ total_visitors = (@daily_visitors || {}).values.sum(&:to_i)
14
+ n_days = [daily_arr.size.to_f, 1.0].max
15
+ line_pts_views = daily_arr.each_with_index.map { |(d, vc), i|
16
+ vx = ((i + 0.5) / n_days) * 100.0
17
+ vy = 100.0 - (max_value.positive? ? (vc.to_f / max_value * 100.0) : 0.0)
18
+ "#{vx.round(4)},#{vy.round(4)}"
19
+ }.join(" ")
20
+ line_pts_visitors = daily_arr.each_with_index.map { |(d, _vc), i|
21
+ vc = (@daily_visitors || {})[d].to_i
22
+ vx = ((i + 0.5) / n_days) * 100.0
23
+ vy = 100.0 - (max_value.positive? ? (vc.to_f / max_value * 100.0) : 0.0)
24
+ "#{vx.round(4)},#{vy.round(4)}"
25
+ }.join(" ")
26
+ # Month view: compact day-number ticks + scroll; ~14px per slot is enough when labels are "3".."31"
27
+ plot_scroll_min_w = [daily_arr.size * 14 + 56, 280].max
28
+ %>
29
+
30
+ <div class="min-w-0 rounded-xl border border-border/60 bg-white shadow-sm">
31
+
32
+ <%# ── Header ── %>
2
33
  <div class="flex items-center justify-between gap-3 px-4 py-3 border-b border-border/40">
3
34
  <div class="flex items-center gap-3">
4
35
  <div class="rounded-md bg-sky-50 p-1.5 flex-shrink-0">
5
- <svg class="w-4 h-4 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
36
+ <svg class="w-4 h-4 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
37
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/>
38
+ </svg>
6
39
  </div>
7
40
  <div>
8
41
  <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.daily_activity", default: "Daily Activity") %></p>
9
42
  <p class="text-xs text-muted-foreground"><%= t("ruby_cms.admin.analytics.page_views_visitors_over_time", default: "Page views and visitors over time") %></p>
10
43
  </div>
11
44
  </div>
12
- <div class="flex items-center gap-4 text-xs text-muted-foreground flex-shrink-0">
13
- <span class="inline-flex items-center gap-1.5"><span class="h-2 w-2 rounded-full bg-primary"></span><%= t("ruby_cms.admin.analytics.views", default: "Views") %></span>
14
- <span class="inline-flex items-center gap-1.5"><span class="h-2 w-2 rounded-full bg-emerald-500"></span><%= t("ruby_cms.admin.analytics.visitors", default: "Visitors") %></span>
45
+
46
+ <div class="flex items-center gap-3 sm:gap-4 flex-shrink-0 flex-wrap justify-end">
47
+ <% if has_data %>
48
+ <div class="hidden sm:flex items-center gap-3 text-xs text-muted-foreground">
49
+ <span class="tabular-nums"><strong class="text-foreground font-semibold"><%= number_with_delimiter(total_views) %></strong> views</span>
50
+ <span class="tabular-nums"><strong class="text-foreground font-semibold"><%= number_with_delimiter(total_visitors) %></strong> visitors</span>
51
+ </div>
52
+ <% end %>
53
+ <div class="flex items-center gap-3 text-xs text-muted-foreground">
54
+ <span class="inline-flex items-center gap-1.5">
55
+ <span class="inline-block h-2 w-2 shrink-0 rounded-full" style="background:rgb(37 99 235)" aria-hidden="true"></span>
56
+ <%= t("ruby_cms.admin.analytics.views", default: "Views") %>
57
+ </span>
58
+ <span class="inline-flex items-center gap-1.5">
59
+ <span class="inline-block h-2 w-2 shrink-0 rounded-full" style="background:rgb(96 165 250)" aria-hidden="true"></span>
60
+ <%= t("ruby_cms.admin.analytics.visitors", default: "Visitors") %>
61
+ </span>
62
+ </div>
63
+ <% if has_data %>
64
+ <div class="rcms-chart-mode-toggle inline-flex rounded-lg border border-border/60 bg-muted/30 p-0.5 text-[11px] font-medium"
65
+ role="group"
66
+ data-rcms-chart="<%= chart_id %>"
67
+ aria-label="<%= t('ruby_cms.admin.analytics.chart_display', default: 'Chart display') %>">
68
+ <button type="button"
69
+ class="rcms-mode-btn px-2 py-1 rounded-md transition-colors text-muted-foreground hover:text-foreground"
70
+ data-mode="bars"
71
+ aria-pressed="true">
72
+ <%= t("ruby_cms.admin.analytics.chart_bars", default: "Bars") %>
73
+ </button>
74
+ <button type="button"
75
+ class="rcms-mode-btn px-2 py-1 rounded-md transition-colors text-muted-foreground hover:text-foreground"
76
+ data-mode="line"
77
+ aria-pressed="false">
78
+ <%= t("ruby_cms.admin.analytics.chart_line", default: "Line") %>
79
+ </button>
80
+ </div>
81
+ <% end %>
15
82
  </div>
16
83
  </div>
17
84
 
18
- <div class="px-4 py-4">
19
- <% if @daily_activity.present? && @daily_activity.any? %>
20
- <%
21
- daily_arr = @daily_activity.is_a?(Hash) ? @daily_activity.to_a : @daily_activity
22
- max_views = daily_arr.map { |_k, v| v.to_i }.max || 0
23
- max_visitors = (@daily_visitors || {}).values.map(&:to_i).max || 0
24
- max_value = [max_views, max_visitors].max
25
- max_value = 1 if max_value.zero?
26
- chart_h = 160
27
- %>
28
-
29
- <div class="grid grid-cols-[2.75rem_1fr] gap-2">
30
- <div class="flex flex-col justify-between text-xs text-muted-foreground tabular-nums" style="height:<%= chart_h %>px">
31
- <% [100, 75, 50, 25, 0].each do |pct| %>
32
- <div class="text-right"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
33
- <% end %>
85
+ <%# ── Chart body ── %>
86
+ <div class="min-w-0 px-4 py-4">
87
+ <% if has_data %>
88
+ <div id="<%= chart_id %>" class="relative min-w-0 select-none">
89
+
90
+ <%# Hover info card %>
91
+ <div id="<%= chart_id %>-tip"
92
+ role="tooltip"
93
+ class="pointer-events-none absolute z-[100] hidden min-w-[10rem] max-w-[16rem]">
34
94
  </div>
35
95
 
36
- <div class="relative">
37
- <div class="absolute inset-0 flex flex-col justify-between pointer-events-none">
38
- <% 5.times do %><div class="h-px bg-border/30"></div><% end %>
96
+ <div class="w-full min-w-0 overflow-x-auto overflow-y-visible overscroll-x-contain">
97
+ <div class="min-w-0" style="width:max(100%,<%= plot_scroll_min_w %>px)">
98
+ <div class="grid min-w-0 gap-2" style="grid-template-columns:2.75rem minmax(0,1fr);">
99
+
100
+ <%# Y-axis %>
101
+ <div class="flex flex-col justify-between text-right text-xs text-muted-foreground tabular-nums" style="height:<%= chart_h %>px; padding-bottom:<%= x_axis_h %>px;">
102
+ <% [100, 75, 50, 25, 0].each do |pct| %>
103
+ <div class="leading-none"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
104
+ <% end %>
39
105
  </div>
40
106
 
41
- <div class="flex items-end gap-1" style="height:<%= chart_h %>px">
42
- <% daily_arr.each do |date, views_count| %>
43
- <% visitors_count = (@daily_visitors || {})[date] || 0 %>
44
- <% views_h = if views_count.to_i.positive?
45
- [((views_count.to_f / max_value) * chart_h).round, 2].max
46
- else
47
- 0
48
- end %>
49
- <% visitors_h = if visitors_count.to_i.positive?
50
- [((visitors_count.to_f / max_value) * chart_h).round, 2].max
51
- else
52
- 0
53
- end %>
54
- <div class="flex flex-col items-center gap-1.5 flex-1 min-w-0 group">
55
- <div class="w-full flex items-end justify-center gap-px" title="<%= format_chart_date(date) %> Views: <%= number_with_delimiter(views_count) %>, Visitors: <%= number_with_delimiter(visitors_count) %>">
56
- <div
57
- class="flex-1 rounded-t transition-colors group-hover:opacity-90"
58
- style="height:<%= views_h %>px; max-width:8px; background-color:hsl(var(--primary) / 0.75);"
59
- ></div>
60
- <div
61
- class="flex-1 rounded-t transition-colors group-hover:opacity-90"
62
- style="height:<%= visitors_h %>px; max-width:8px; background-color:rgb(16 185 129 / 0.75);"
63
- ></div>
107
+ <%# Plot area %>
108
+ <div class="relative min-w-0" style="height:<%= chart_h %>px;">
109
+
110
+ <%# Grid lines %>
111
+ <div class="absolute inset-0 flex flex-col justify-between pointer-events-none" style="padding-bottom:<%= x_axis_h %>px;">
112
+ <% 5.times do %><div style="border-top:1px solid hsl(var(--border,214 32% 91%) / 0.3);"></div><% end %>
113
+ </div>
114
+
115
+ <%# Line chart (toggled) %>
116
+ <div class="rcms-line-layer absolute left-0 right-0 pointer-events-none z-[1] hidden"
117
+ style="height:<%= bar_area_h %>px; top:0;"
118
+ aria-hidden="true">
119
+ <svg class="w-full h-full overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
120
+ <polyline class="rcms-line-views" fill="none" stroke="rgb(37 99 235)" stroke-width="1.25" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" points="<%= line_pts_views %>"/>
121
+ <polyline class="rcms-line-visitors" fill="none" stroke="rgb(96 165 250)" stroke-width="1.25" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" points="<%= line_pts_visitors %>"/>
122
+ </svg>
123
+ </div>
124
+
125
+ <%# Bars %>
126
+ <div class="rcms-bars-layer flex items-end gap-px relative z-[2]" style="height:<%= chart_h %>px;">
127
+ <% daily_arr.each do |date, views_count| %>
128
+ <%
129
+ visitors_count = (@daily_visitors || {})[date].to_i
130
+ vh = views_count.to_i.positive? ? [((views_count.to_f / max_value) * bar_area_h).round, 2].max : 0
131
+ uh = visitors_count.positive? ? [((visitors_count.to_f / max_value) * bar_area_h).round, 2].max : 0
132
+ label = format_chart_date(date)
133
+ short = format_chart_date_axis(date)
134
+ v_pct = total_views.positive? ? (100.0 * views_count.to_i / total_views) : 0.0
135
+ u_pct = total_visitors.positive? ? (100.0 * visitors_count / total_visitors) : 0.0
136
+ %>
137
+ <div class="rcms-bar-group flex flex-col items-center flex-1 min-w-0 cursor-default"
138
+ data-label="<%= label %>"
139
+ data-views="<%= number_with_delimiter(views_count.to_i) %>"
140
+ data-visitors="<%= number_with_delimiter(visitors_count) %>"
141
+ data-views-pct="<%= sprintf('%.1f', v_pct) %>"
142
+ data-visitors-pct="<%= sprintf('%.1f', u_pct) %>"
143
+ data-max-scale="<%= number_with_delimiter(max_value) %>">
144
+ <%# Bar pair %>
145
+ <div class="w-full flex items-end justify-center gap-px rcms-bar-stack" style="height:<%= bar_area_h %>px;">
146
+ <div class="rcms-bar flex-1 rounded-t-sm"
147
+ style="height:<%= vh %>px; max-width:10px; background:rgb(37 99 235 / 0.88); transition:opacity .12s,filter .12s;"></div>
148
+ <div class="rcms-bar flex-1 rounded-t-sm"
149
+ style="height:<%= uh %>px; max-width:10px; background:rgb(96 165 250 / 0.88); transition:opacity .12s,filter .12s;"></div>
150
+ </div>
151
+ <%# X label (month view uses single day number via format_chart_date_axis) %>
152
+ <div class="text-muted-foreground w-full px-px text-center leading-none rcms-x-label tabular-nums"
153
+ style="font-size:10px; min-height:<%= x_axis_h %>px; max-height:<%= x_axis_h %>px; padding-top:6px;"><%= short %></div>
64
154
  </div>
65
- <div class="text-[10px] text-muted-foreground truncate w-full text-center"><%= format_chart_date_short(date) %></div>
66
- </div>
67
- <% end %>
155
+ <% end %>
156
+ </div>
157
+
158
+ </div>
159
+ </div>
68
160
  </div>
69
161
  </div>
70
162
  </div>
163
+
164
+ <script>
165
+ (function() {
166
+ var BLUE600 = "rgb(37 99 235)";
167
+ var BLUE400 = "rgb(96 165 250)";
168
+ var MODE_KEY = "ruby_cms.analytics.dailyChartMode";
169
+
170
+ function init() {
171
+ var chart = document.getElementById("<%= chart_id %>");
172
+ if (!chart || chart.__rcmsInit) return;
173
+ chart.__rcmsInit = true;
174
+
175
+ var tip = document.getElementById("<%= chart_id %>-tip");
176
+ var groups = chart.querySelectorAll(".rcms-bar-group");
177
+ var allBars = chart.querySelectorAll(".rcms-bar");
178
+ var lineLayer = chart.querySelector(".rcms-line-layer");
179
+ var barsLayer = chart.querySelector(".rcms-bars-layer");
180
+ var linePolys = lineLayer ? lineLayer.querySelectorAll("polyline") : [];
181
+ var toggles = document.querySelectorAll('.rcms-chart-mode-toggle[data-rcms-chart="<%= chart_id %>"] .rcms-mode-btn');
182
+
183
+ function setMode(mode) {
184
+ var isLine = mode === "line";
185
+ if (lineLayer) lineLayer.classList.toggle("hidden", !isLine);
186
+ if (barsLayer) {
187
+ barsLayer.querySelectorAll(".rcms-bar-stack").forEach(function(el) {
188
+ el.style.visibility = isLine ? "hidden" : "visible";
189
+ });
190
+ }
191
+ linePolys.forEach(function(p) {
192
+ p.style.opacity = isLine ? "1" : "0";
193
+ });
194
+ toggles.forEach(function(btn) {
195
+ var active = btn.getAttribute("data-mode") === mode;
196
+ btn.setAttribute("aria-pressed", active ? "true" : "false");
197
+ btn.classList.toggle("bg-background", active);
198
+ btn.classList.toggle("text-foreground", active);
199
+ btn.classList.toggle("shadow-sm", active);
200
+ });
201
+ try { localStorage.setItem(MODE_KEY, mode); } catch (e) {}
202
+ }
203
+
204
+ try {
205
+ var saved = localStorage.getItem(MODE_KEY);
206
+ if (saved === "line" || saved === "bars") setMode(saved);
207
+ else setMode("bars");
208
+ } catch (e) {
209
+ setMode("bars");
210
+ }
211
+
212
+ toggles.forEach(function(btn) {
213
+ btn.addEventListener("click", function() {
214
+ setMode(btn.getAttribute("data-mode") || "bars");
215
+ });
216
+ });
217
+
218
+ groups.forEach(function(group) {
219
+ group.addEventListener("mouseenter", function() {
220
+ var activeMode = lineLayer && !lineLayer.classList.contains("hidden") ? "line" : "bars";
221
+ if (activeMode === "bars") {
222
+ allBars.forEach(function(b) { b.style.opacity = "0.35"; b.style.filter = ""; });
223
+ group.querySelectorAll(".rcms-bar").forEach(function(b) {
224
+ b.style.opacity = "1";
225
+ b.style.filter = "brightness(1.08)";
226
+ });
227
+ } else {
228
+ linePolys.forEach(function(p) { p.style.opacity = "0.35"; });
229
+ }
230
+
231
+ var label = group.dataset.label || "";
232
+ var views = group.dataset.views || "0";
233
+ var visitors = group.dataset.visitors || "0";
234
+ var vp = group.dataset.viewsPct || "0";
235
+ var up = group.dataset.visitorsPct || "0";
236
+ var scale = group.dataset.maxScale || "";
237
+
238
+ var modeStr = activeMode === "line" ? "Line chart" : "Bar chart";
239
+ tip.innerHTML =
240
+ '<div class="overflow-hidden rounded-xl border border-slate-200/90 bg-white text-xs text-slate-800 shadow-[0_10px_38px_-12px_rgba(15,23,42,0.28)]">' +
241
+ '<div class="border-b border-slate-100 bg-gradient-to-br from-sky-50 via-white to-white px-3 py-2">' +
242
+ '<p class="text-[13px] font-semibold leading-snug tracking-tight text-slate-900">' + escapeHtml(label) + "</p>" +
243
+ '<p class="mt-1 text-[10px] leading-relaxed text-slate-500">' +
244
+ "Scale 0–" + escapeHtml(scale) + " · " + modeStr +
245
+ "</p>" +
246
+ "</div>" +
247
+ '<div class="divide-y divide-slate-100/90 bg-white">' +
248
+ '<div class="flex gap-2.5 px-3 py-2">' +
249
+ '<span class="mt-0.5 h-2 w-2 shrink-0 rounded-full shadow-sm ring-2 ring-white" style="background:' + BLUE600 + ';box-shadow:0 0 0 1px rgba(15,23,42,0.06)"></span>' +
250
+ '<div class="min-w-0 flex-1">' +
251
+ '<p class="text-[10px] font-semibold uppercase tracking-wider text-slate-400">Views</p>' +
252
+ '<p class="mt-0.5 text-sm font-semibold tabular-nums text-slate-900">' + escapeHtml(views) +
253
+ ' <span class="text-[11px] font-medium text-slate-400">(' + escapeHtml(vp) + "%)</span></p>" +
254
+ "</div>" +
255
+ "</div>" +
256
+ '<div class="flex gap-2.5 px-3 py-2">' +
257
+ '<span class="mt-0.5 h-2 w-2 shrink-0 rounded-full shadow-sm ring-2 ring-white" style="background:' + BLUE400 + ';box-shadow:0 0 0 1px rgba(15,23,42,0.06)"></span>' +
258
+ '<div class="min-w-0 flex-1">' +
259
+ '<p class="text-[10px] font-semibold uppercase tracking-wider text-slate-400">Visitors</p>' +
260
+ '<p class="mt-0.5 text-sm font-semibold tabular-nums text-slate-900">' + escapeHtml(visitors) +
261
+ ' <span class="text-[11px] font-medium text-slate-400">(' + escapeHtml(up) + "%)</span></p>" +
262
+ "</div>" +
263
+ "</div>" +
264
+ "</div>" +
265
+ "</div>";
266
+
267
+ tip.classList.remove("hidden");
268
+ requestAnimationFrame(function() {
269
+ position(group, tip, chart);
270
+ });
271
+ });
272
+
273
+ group.addEventListener("mouseleave", function() {
274
+ allBars.forEach(function(b) { b.style.opacity = ""; b.style.filter = ""; });
275
+ var isLine = lineLayer && !lineLayer.classList.contains("hidden");
276
+ linePolys.forEach(function(p) {
277
+ p.style.opacity = isLine ? "1" : "0";
278
+ });
279
+ tip.classList.add("hidden");
280
+ });
281
+ });
282
+ }
283
+
284
+ function escapeHtml(s) {
285
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
286
+ }
287
+
288
+ function position(group, tip, container) {
289
+ var cr = container.getBoundingClientRect();
290
+ var gr = group.getBoundingClientRect();
291
+ var gap = 6;
292
+ var pad = 4;
293
+ var tw = tip.offsetWidth || 160;
294
+ var th = tip.offsetHeight || 72;
295
+ var cx = gr.left - cr.left + gr.width / 2;
296
+ var top = gr.top - cr.top - th - gap;
297
+
298
+ if (top < pad) {
299
+ top = gr.bottom - cr.top + gap;
300
+ }
301
+ var maxTop = container.clientHeight - th - pad;
302
+ if (top > maxTop) {
303
+ top = Math.max(pad, maxTop);
304
+ }
305
+
306
+ var left = Math.round(cx - tw / 2);
307
+ left = Math.max(pad, Math.min(left, container.clientWidth - tw - pad));
308
+
309
+ tip.style.position = "absolute";
310
+ tip.style.left = left + "px";
311
+ tip.style.top = Math.round(top) + "px";
312
+ }
313
+
314
+ if (document.readyState === "loading") {
315
+ document.addEventListener("DOMContentLoaded", init);
316
+ } else {
317
+ init();
318
+ }
319
+ document.addEventListener("turbo:load", init);
320
+ document.addEventListener("turbo:render", init);
321
+ })();
322
+ </script>
323
+
71
324
  <% else %>
72
325
  <div class="flex flex-col items-center justify-center h-44 gap-2">
73
- <svg class="w-8 h-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/></svg>
326
+ <svg class="w-8 h-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
327
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/>
328
+ </svg>
74
329
  <p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.no_daily_data", default: "No daily activity data") %></p>
75
330
  </div>
76
331
  <% end %>
@@ -1,65 +1,313 @@
1
- <div class="rounded-xl border border-border/60 bg-white shadow-sm overflow-hidden">
2
- <div class="flex items-center gap-3 px-4 py-3 border-b border-border/40">
3
- <div class="rounded-md bg-emerald-50 p-1.5 flex-shrink-0">
4
- <svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
1
+ <%
2
+ chart_id = "rcms-hourly-#{SecureRandom.hex(4)}"
3
+ hourly_arr = @hourly_activity.is_a?(Hash) ? @hourly_activity.to_a : (@hourly_activity || [])
4
+ has_data = hourly_arr.present? && hourly_arr.any?
5
+
6
+ grouped = hourly_arr.each_slice(3).map do |group|
7
+ hours = group.map(&:first)
8
+ total = group.sum { |_, c| c.to_i }
9
+ [hours, total]
10
+ end
11
+
12
+ max_value = grouped.map { |_, c| c.to_i }.max.to_f
13
+ max_value = 1.0 unless max_value.positive? && max_value.finite?
14
+ chart_h = 180
15
+ bar_area_h = chart_h - 20
16
+
17
+ peak = grouped.max_by { |_, c| c }
18
+ peak_label = peak ? "#{peak[0].first}:00–#{peak[0].last}:59" : nil
19
+ peak_count = peak ? peak[1] : 0
20
+ n_slots = [grouped.size.to_f, 1.0].max
21
+ total_slot_views = grouped.sum { |_, c| c.to_i }
22
+ line_pts_hourly = grouped.each_with_index.map { |(hours, count), i|
23
+ vx = ((i + 0.5) / n_slots) * 100.0
24
+ vy = 100.0 - (max_value.positive? ? (count.to_f / max_value * 100.0) : 0.0)
25
+ "#{vx.round(4)},#{vy.round(4)}"
26
+ }.join(" ")
27
+ plot_scroll_min_w = [grouped.size * 32 + 56, 280].max
28
+ %>
29
+
30
+ <div class="min-w-0 rounded-xl border border-border/60 bg-white shadow-sm">
31
+
32
+ <%# ── Header ── %>
33
+ <div class="flex items-center justify-between gap-3 px-4 py-3 border-b border-border/40">
34
+ <div class="flex items-center gap-3">
35
+ <div class="rounded-md bg-sky-50 p-1.5 flex-shrink-0">
36
+ <svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
37
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
38
+ </svg>
39
+ </div>
40
+ <div>
41
+ <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.hourly_activity", default: "Hourly Activity") %></p>
42
+ <p class="text-xs text-muted-foreground"><%= t("ruby_cms.admin.analytics.views_by_hour", default: "Views by hour of day") %></p>
43
+ </div>
5
44
  </div>
6
- <div>
7
- <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.hourly_activity", default: "Hourly Activity") %></p>
8
- <p class="text-xs text-muted-foreground"><%= t("ruby_cms.admin.analytics.views_by_hour", default: "Views by hour of day") %></p>
45
+
46
+ <div class="flex items-center gap-2 sm:gap-3 flex-shrink-0 flex-wrap justify-end">
47
+ <% if has_data && peak_label %>
48
+ <div class="hidden sm:flex items-center gap-1.5 rounded-full bg-sky-50 border border-sky-100 px-2.5 py-1">
49
+ <svg class="w-3 h-3 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
50
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
51
+ </svg>
52
+ <span class="text-xs font-medium text-sky-800">Peak <%= peak_label %></span>
53
+ <span class="text-xs font-semibold tabular-nums text-blue-900"><%= number_with_delimiter(peak_count) %></span>
54
+ </div>
55
+ <% end %>
56
+ <% if has_data %>
57
+ <div class="rcms-chart-mode-toggle inline-flex rounded-lg border border-border/60 bg-muted/30 p-0.5 text-[11px] font-medium"
58
+ role="group"
59
+ data-rcms-chart="<%= chart_id %>"
60
+ aria-label="<%= t('ruby_cms.admin.analytics.chart_display', default: 'Chart display') %>">
61
+ <button type="button"
62
+ class="rcms-mode-btn px-2 py-1 rounded-md transition-colors text-muted-foreground hover:text-foreground"
63
+ data-mode="bars"
64
+ aria-pressed="true">
65
+ <%= t("ruby_cms.admin.analytics.chart_bars", default: "Bars") %>
66
+ </button>
67
+ <button type="button"
68
+ class="rcms-mode-btn px-2 py-1 rounded-md transition-colors text-muted-foreground hover:text-foreground"
69
+ data-mode="line"
70
+ aria-pressed="false">
71
+ <%= t("ruby_cms.admin.analytics.chart_line", default: "Line") %>
72
+ </button>
73
+ </div>
74
+ <% end %>
9
75
  </div>
10
76
  </div>
11
77
 
12
- <div class="px-4 py-4">
13
- <% if @hourly_activity.present? && @hourly_activity.any? %>
14
- <%
15
- hourly_arr = @hourly_activity.is_a?(Hash) ? @hourly_activity.to_a : @hourly_activity
16
- grouped = hourly_arr.each_slice(3).map do |group|
17
- hours = group.map(&:first)
18
- total = group.sum { |_, c| c.to_i }
19
- [hours, total]
20
- end
21
- max_value = grouped.map { |_, c| c.to_i }.max.to_f
22
- max_value = 1.0 unless max_value.positive? && max_value.finite?
23
- chart_h = 160
24
- %>
25
-
26
- <div class="grid grid-cols-[2.75rem_1fr] gap-2">
27
- <div class="flex flex-col justify-between text-xs text-muted-foreground tabular-nums" style="height:<%= chart_h %>px">
28
- <% [100, 75, 50, 25, 0].each do |pct| %>
29
- <div class="text-right"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
30
- <% end %>
78
+ <%# ── Chart body ── %>
79
+ <div class="min-w-0 px-4 py-4">
80
+ <% if has_data %>
81
+ <div id="<%= chart_id %>" class="relative min-w-0 select-none">
82
+
83
+ <%# Hover info card %>
84
+ <div id="<%= chart_id %>-tip"
85
+ role="tooltip"
86
+ class="pointer-events-none absolute z-[100] hidden min-w-[10rem] max-w-[16rem]">
31
87
  </div>
32
88
 
33
- <div class="relative">
34
- <div class="absolute inset-0 flex flex-col justify-between pointer-events-none">
35
- <% 5.times do %><div class="h-px bg-border/30"></div><% end %>
89
+ <div class="w-full min-w-0 overflow-x-auto overflow-y-visible overscroll-x-contain">
90
+ <div class="min-w-0" style="width:max(100%,<%= plot_scroll_min_w %>px)">
91
+ <div class="grid min-w-0 gap-2" style="grid-template-columns:2.75rem minmax(0,1fr);">
92
+
93
+ <%# Y-axis %>
94
+ <div class="flex flex-col justify-between text-right text-xs text-muted-foreground tabular-nums" style="height:<%= chart_h %>px; padding-bottom:20px;">
95
+ <% [100, 75, 50, 25, 0].each do |pct| %>
96
+ <div class="leading-none"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
97
+ <% end %>
36
98
  </div>
37
99
 
38
- <div class="flex items-end gap-1.5" style="height:<%= chart_h %>px">
39
- <% grouped.each do |hours, count| %>
40
- <% ratio = count.to_f / max_value %>
41
- <% ratio = 0.0 unless ratio.finite? %>
42
- <% h = if count.to_i.positive?
43
- [[(ratio * chart_h).round, 2].max, chart_h].min
44
- else
45
- 0
46
- end %>
47
- <div class="flex flex-col items-center gap-1.5 flex-1 min-w-0 group">
48
- <div class="w-full flex items-end justify-center" title="<%= hours.size > 1 ? "#{hours.first}:00–#{hours.last}:00" : "#{hours.first}:00" %> — <%= number_with_delimiter(count) %> views">
49
- <div
50
- class="w-full rounded-t transition-colors group-hover:opacity-90"
51
- style="height:<%= h %>px; max-width:12px; background-color:rgb(16 185 129 / 0.75);"
52
- ></div>
100
+ <%# Plot area %>
101
+ <div class="relative min-w-0" style="height:<%= chart_h %>px;">
102
+
103
+ <%# Grid lines %>
104
+ <div class="absolute inset-0 flex flex-col justify-between pointer-events-none" style="padding-bottom:20px;">
105
+ <% 5.times do %><div style="border-top:1px solid hsl(var(--border,214 32% 91%) / 0.3);"></div><% end %>
106
+ </div>
107
+
108
+ <%# Line chart %>
109
+ <div class="rcms-line-layer absolute left-0 right-0 pointer-events-none z-[1] hidden"
110
+ style="height:<%= bar_area_h %>px; top:0;"
111
+ aria-hidden="true">
112
+ <svg class="w-full h-full overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
113
+ <polyline class="rcms-line-hourly" fill="none" stroke="rgb(37 99 235)" stroke-width="1.25" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" points="<%= line_pts_hourly %>"/>
114
+ </svg>
115
+ </div>
116
+
117
+ <%# Bars %>
118
+ <div class="rcms-bars-layer flex items-end gap-1 relative z-[2]" style="height:<%= chart_h %>px;">
119
+ <% grouped.each do |hours, count| %>
120
+ <%
121
+ ratio = count.to_f / max_value
122
+ ratio = 0.0 unless ratio.finite?
123
+ h = count.to_i.positive? ? [[(ratio * bar_area_h).round, 2].max, bar_area_h].min : 0
124
+ range = hours.size > 1 ? "#{hours.first}:00 – #{hours.last}:59" : "#{hours.first}:00"
125
+ short = hours.size > 1 ? "#{hours.first}–#{hours.last}h" : "#{hours.first}h"
126
+ is_peak = peak && hours == peak[0]
127
+ pct_of_day = total_slot_views.positive? ? (100.0 * count.to_i / total_slot_views) : 0.0
128
+ %>
129
+ <div class="rcms-bar-group flex flex-col items-center flex-1 min-w-0 cursor-default"
130
+ data-label="<%= range %>"
131
+ data-views="<%= number_with_delimiter(count) %>"
132
+ data-pct-of-day="<%= sprintf('%.1f', pct_of_day) %>"
133
+ data-is-peak="<%= is_peak ? '1' : '0' %>"
134
+ data-max-scale="<%= number_with_delimiter(max_value.round) %>"
135
+ data-total-views="<%= number_with_delimiter(total_slot_views) %>">
136
+ <div class="w-full flex items-end justify-center rcms-bar-stack" style="height:<%= bar_area_h %>px;">
137
+ <div class="rcms-bar w-full rounded-t-sm"
138
+ style="height:<%= h %>px; max-width:14px; background:rgb(37 99 235 / <%= is_peak ? '1' : '0.82' %>); transition:opacity .12s,filter .12s;"></div>
139
+ </div>
140
+ <div class="text-muted-foreground truncate w-full text-center leading-none"
141
+ style="font-size:10px; height:20px; padding-top:5px;"><%= short %></div>
53
142
  </div>
54
- <div class="text-[10px] text-muted-foreground truncate w-full text-center"><%= hours.size > 1 ? "#{hours.first}–#{hours.last}h" : "#{hours.first}h" %></div>
55
- </div>
56
- <% end %>
143
+ <% end %>
144
+ </div>
145
+
146
+ </div>
147
+ </div>
57
148
  </div>
58
149
  </div>
59
150
  </div>
151
+
152
+ <script>
153
+ (function() {
154
+ var BLUE = "rgb(37 99 235)";
155
+ var MODE_KEY = "ruby_cms.analytics.hourlyChartMode";
156
+
157
+ function escapeHtml(s) {
158
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
159
+ }
160
+
161
+ function init() {
162
+ var chart = document.getElementById("<%= chart_id %>");
163
+ if (!chart || chart.__rcmsInit) return;
164
+ chart.__rcmsInit = true;
165
+
166
+ var tip = document.getElementById("<%= chart_id %>-tip");
167
+ var groups = chart.querySelectorAll(".rcms-bar-group");
168
+ var allBars = chart.querySelectorAll(".rcms-bar");
169
+ var lineLayer = chart.querySelector(".rcms-line-layer");
170
+ var barsLayer = chart.querySelector(".rcms-bars-layer");
171
+ var linePolys = lineLayer ? lineLayer.querySelectorAll("polyline") : [];
172
+ var toggles = document.querySelectorAll('.rcms-chart-mode-toggle[data-rcms-chart="<%= chart_id %>"] .rcms-mode-btn');
173
+
174
+ function setMode(mode) {
175
+ var isLine = mode === "line";
176
+ if (lineLayer) lineLayer.classList.toggle("hidden", !isLine);
177
+ if (barsLayer) {
178
+ barsLayer.querySelectorAll(".rcms-bar-stack").forEach(function(el) {
179
+ el.style.visibility = isLine ? "hidden" : "visible";
180
+ });
181
+ }
182
+ linePolys.forEach(function(p) {
183
+ p.style.opacity = isLine ? "1" : "0";
184
+ });
185
+ toggles.forEach(function(btn) {
186
+ var active = btn.getAttribute("data-mode") === mode;
187
+ btn.setAttribute("aria-pressed", active ? "true" : "false");
188
+ btn.classList.toggle("bg-background", active);
189
+ btn.classList.toggle("text-foreground", active);
190
+ btn.classList.toggle("shadow-sm", active);
191
+ });
192
+ try { localStorage.setItem(MODE_KEY, mode); } catch (e) {}
193
+ }
194
+
195
+ try {
196
+ var saved = localStorage.getItem(MODE_KEY);
197
+ if (saved === "line" || saved === "bars") setMode(saved);
198
+ else setMode("bars");
199
+ } catch (e) {
200
+ setMode("bars");
201
+ }
202
+
203
+ toggles.forEach(function(btn) {
204
+ btn.addEventListener("click", function() {
205
+ setMode(btn.getAttribute("data-mode") || "bars");
206
+ });
207
+ });
208
+
209
+ groups.forEach(function(group) {
210
+ group.addEventListener("mouseenter", function() {
211
+ var isLine = lineLayer && !lineLayer.classList.contains("hidden");
212
+ if (!isLine) {
213
+ allBars.forEach(function(b) { b.style.opacity = "0.35"; b.style.filter = ""; });
214
+ group.querySelectorAll(".rcms-bar").forEach(function(b) {
215
+ b.style.opacity = "1";
216
+ b.style.filter = "brightness(1.08)";
217
+ });
218
+ } else {
219
+ linePolys.forEach(function(p) { p.style.opacity = "0.35"; });
220
+ }
221
+
222
+ var label = group.dataset.label || "";
223
+ var views = group.dataset.views || "0";
224
+ var pct = group.dataset.pctOfDay || "0";
225
+ var isPk = group.dataset.isPeak === "1";
226
+ var scale = group.dataset.maxScale || "";
227
+ var total = group.dataset.totalViews || "";
228
+ var modeLabel = isLine ? "Line chart" : "Bar chart";
229
+
230
+ tip.innerHTML =
231
+ '<div class="overflow-hidden rounded-xl border border-slate-200/90 bg-white text-xs text-slate-800 shadow-[0_10px_38px_-12px_rgba(15,23,42,0.28)]">' +
232
+ '<div class="border-b border-slate-100 bg-gradient-to-br from-sky-50 via-white to-white px-3 py-2">' +
233
+ '<p class="text-[13px] font-semibold leading-snug tracking-tight text-slate-900">' + escapeHtml(label) + "</p>" +
234
+ '<p class="mt-1 text-[10px] leading-relaxed text-slate-500">' +
235
+ "Scale 0–" + escapeHtml(scale) + " · " + modeLabel + " · Day total " + escapeHtml(total) + " views" +
236
+ "</p>" +
237
+ "</div>" +
238
+ '<div class="bg-white px-3 py-2.5">' +
239
+ '<div class="flex gap-2.5">' +
240
+ '<span class="mt-0.5 h-2 w-2 shrink-0 rounded-full shadow-sm ring-2 ring-white" style="background:' + BLUE + ';box-shadow:0 0 0 1px rgba(15,23,42,0.06)"></span>' +
241
+ '<div class="min-w-0 flex-1">' +
242
+ '<p class="text-[10px] font-semibold uppercase tracking-wider text-slate-400">This block</p>' +
243
+ '<p class="mt-0.5 text-sm font-semibold tabular-nums text-slate-900">' + escapeHtml(views) +
244
+ ' <span class="text-[11px] font-medium text-slate-400">(' + escapeHtml(pct) + "% of day)</span></p>" +
245
+ "</div>" +
246
+ "</div>" +
247
+ "</div>" +
248
+ (isPk
249
+ ? '<div class="border-t border-sky-100/80 bg-gradient-to-r from-sky-50/90 to-blue-50/50 px-3 py-1.5 text-center text-[10px] font-semibold text-sky-900">Peak interval for this period</div>'
250
+ : "") +
251
+ "</div>";
252
+
253
+ tip.classList.remove("hidden");
254
+ requestAnimationFrame(function() {
255
+ position(group, tip, chart);
256
+ });
257
+ });
258
+
259
+ group.addEventListener("mouseleave", function() {
260
+ allBars.forEach(function(b) { b.style.opacity = ""; b.style.filter = ""; });
261
+ var lineOn = lineLayer && !lineLayer.classList.contains("hidden");
262
+ linePolys.forEach(function(p) {
263
+ p.style.opacity = lineOn ? "1" : "0";
264
+ });
265
+ tip.classList.add("hidden");
266
+ });
267
+ });
268
+ }
269
+
270
+ function position(group, tip, container) {
271
+ var cr = container.getBoundingClientRect();
272
+ var gr = group.getBoundingClientRect();
273
+ var gap = 6;
274
+ var pad = 4;
275
+ var tw = tip.offsetWidth || 140;
276
+ var th = tip.offsetHeight || 64;
277
+ var cx = gr.left - cr.left + gr.width / 2;
278
+ var top = gr.top - cr.top - th - gap;
279
+
280
+ if (top < pad) {
281
+ top = gr.bottom - cr.top + gap;
282
+ }
283
+ var maxTop = container.clientHeight - th - pad;
284
+ if (top > maxTop) {
285
+ top = Math.max(pad, maxTop);
286
+ }
287
+
288
+ var left = Math.round(cx - tw / 2);
289
+ left = Math.max(pad, Math.min(left, container.clientWidth - tw - pad));
290
+
291
+ tip.style.position = "absolute";
292
+ tip.style.left = left + "px";
293
+ tip.style.top = Math.round(top) + "px";
294
+ }
295
+
296
+ if (document.readyState === "loading") {
297
+ document.addEventListener("DOMContentLoaded", init);
298
+ } else {
299
+ init();
300
+ }
301
+ document.addEventListener("turbo:load", init);
302
+ document.addEventListener("turbo:render", init);
303
+ })();
304
+ </script>
305
+
60
306
  <% else %>
61
307
  <div class="flex flex-col items-center justify-center h-44 gap-2">
62
- <svg class="w-8 h-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
308
+ <svg class="w-8 h-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
309
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
310
+ </svg>
63
311
  <p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.no_hourly_data", default: "No hourly activity data") %></p>
64
312
  </div>
65
313
  <% end %>
@@ -1,53 +1,80 @@
1
- <div class="flex h-full flex-col rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
- <div class="flex items-center justify-between gap-3 border-b border-gray-100 px-5 py-3">
3
- <div>
4
- <p class="text-sm font-semibold text-gray-900">Analytics</p>
5
- <p class="text-xs text-gray-500">Last 7 days.</p>
1
+ <%# Analytics same outer shell as sibling cards; softer interior (no box-in-box borders) %>
2
+ <div class="flex h-full min-w-0 flex-col overflow-hidden rounded-lg border border-gray-200/80 bg-white shadow-sm">
3
+ <%# Header — aligned with Quick actions / Recent errors %>
4
+ <div class="flex items-center justify-between gap-2 border-b border-gray-100 px-5 py-3">
5
+ <div class="flex min-w-0 items-center gap-3">
6
+ <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sky-50 text-sky-600">
7
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
8
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/>
9
+ </svg>
10
+ </div>
11
+ <div class="min-w-0">
12
+ <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.dashboard.analytics_title", default: "Analytics") %></p>
13
+ <p class="text-xs text-gray-500"><%= t("ruby_cms.admin.dashboard.analytics_subtitle", default: "Last 7 days") %></p>
14
+ </div>
6
15
  </div>
7
- <%= link_to "View all", ruby_cms_admin_analytics_path, class: "text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" %>
16
+ <%= link_to t("ruby_cms.admin.dashboard.analytics_view_all", default: "View all"),
17
+ ruby_cms_admin_analytics_path,
18
+ class: "shrink-0 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900" %>
8
19
  </div>
9
20
 
10
21
  <% if @dashboard_analytics_stats.present? %>
11
- <div class="grid grid-cols-2 gap-x-4 gap-y-3 px-5 py-4">
12
- <div>
13
- <p class="text-xs font-medium text-gray-500">Page views</p>
14
- <p class="mt-1 text-2xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@dashboard_analytics_stats[:total_page_views].to_i) %></p>
15
- </div>
16
- <div>
17
- <p class="text-xs font-medium text-gray-500">Unique visitors</p>
18
- <p class="mt-1 text-2xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@dashboard_analytics_stats[:unique_visitors].to_i) %></p>
19
- </div>
20
- <div>
21
- <p class="text-xs font-medium text-gray-500">Sessions</p>
22
- <p class="mt-1 text-2xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@dashboard_analytics_stats[:total_sessions].to_i) %></p>
23
- </div>
24
- <div>
25
- <p class="text-xs font-medium text-gray-500">Avg / day</p>
26
- <p class="mt-1 text-2xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@dashboard_analytics_stats[:avg_daily_views].to_i) %></p>
27
- </div>
22
+ <%# KPIs no inner ring/border (rings can render as a harsh outline); same padding rhythm as sibling cards %>
23
+ <div class="px-5 py-4">
24
+ <div class="grid grid-cols-2 gap-x-4 gap-y-5">
25
+ <%
26
+ kpi = [
27
+ { key: :total_page_views, label: t("ruby_cms.admin.analytics.total_page_views", default: "Page views"), icon: "eye", cls: "text-sky-600" },
28
+ { key: :unique_visitors, label: t("ruby_cms.admin.analytics.unique_visitors", default: "Unique visitors"), icon: "users", cls: "text-blue-600" },
29
+ { key: :total_sessions, label: t("ruby_cms.admin.analytics.total_sessions", default: "Sessions"), icon: "sessions", cls: "text-indigo-600" },
30
+ { key: :avg_daily_views, label: t("ruby_cms.admin.dashboard.avg_per_day", default: "Avg / day"), icon: "calendar", cls: "text-violet-600" }
31
+ ]
32
+ %>
33
+ <% kpi.each do |item| %>
34
+ <div class="min-w-0 pl-1">
35
+ <div class="flex items-center gap-2 text-gray-500">
36
+ <% case item[:icon]
37
+ when "eye" %>
38
+ <svg class="h-4 w-4 shrink-0 <%= item[:cls] %>" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
39
+ <% when "users" %>
40
+ <svg class="h-4 w-4 shrink-0 <%= item[:cls] %>" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
41
+ <% when "sessions" %>
42
+ <svg class="h-4 w-4 shrink-0 <%= item[:cls] %>" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
43
+ <% else %>
44
+ <svg class="h-4 w-4 shrink-0 <%= item[:cls] %>" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
45
+ <% end %>
46
+ <span class="text-[11px] font-medium leading-tight text-gray-600"><%= item[:label] %></span>
47
+ </div>
48
+ <p class="mt-1.5 text-xl font-semibold tabular-nums tracking-tight text-gray-900"><%= number_with_delimiter(@dashboard_analytics_stats[item[:key]].to_i) %></p>
49
+ </div>
50
+ <% end %>
51
+ </div>
28
52
  </div>
29
53
 
30
54
  <% top = @dashboard_analytics_stats[:popular_pages] %>
31
55
  <% if top.present? %>
32
- <div class="flex-1 border-t border-gray-100">
33
- <p class="px-5 pt-3 text-xs text-gray-500">Popular pages</p>
34
- <div class="divide-y divide-gray-100">
35
- <% top.to_a.first(4).each do |page_name, count| %>
36
- <div class="flex items-center justify-between gap-2 px-5 py-2">
37
- <span class="min-w-0 truncate text-sm font-medium text-gray-900" title="<%= page_name %>"><%= page_name.presence || "(empty)" %></span>
38
- <span class="shrink-0 text-sm tabular-nums text-gray-500"><%= number_with_delimiter(count.to_i) %></span>
39
- </div>
56
+ <div class="flex-1 border-t border-gray-100 px-5 pb-4 pt-3">
57
+ <p class="mb-2 text-xs font-medium text-gray-500"><%= t("ruby_cms.admin.dashboard.popular_pages", default: "Popular pages") %></p>
58
+ <ul class="space-y-0.5">
59
+ <% top.to_a.first(4).each_with_index do |(page_name, count), i| %>
60
+ <li class="group flex items-center justify-between gap-2 rounded-lg py-2 pl-2 pr-1 transition-colors hover:bg-gray-50">
61
+ <div class="flex min-w-0 items-baseline gap-2">
62
+ <span class="w-4 shrink-0 text-right text-[11px] font-medium tabular-nums text-gray-400"><%= i + 1 %>.</span>
63
+ <span class="truncate text-sm text-gray-900" title="<%= page_name %>"><%= page_name.presence || "(empty)" %></span>
64
+ </div>
65
+ <span class="shrink-0 text-sm font-medium tabular-nums text-gray-500 group-hover:text-gray-700"><%= number_with_delimiter(count.to_i) %></span>
66
+ </li>
40
67
  <% end %>
41
- </div>
68
+ </ul>
42
69
  </div>
43
70
  <% end %>
44
71
  <% else %>
45
- <div class="flex flex-1 flex-col items-center justify-center px-5 py-8 text-center">
46
- <div class="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-600 ring-1 ring-inset ring-gray-200">
72
+ <div class="flex flex-1 flex-col items-center justify-center px-5 py-10 text-center">
73
+ <div class="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-500">
47
74
  <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
48
75
  </div>
49
- <p class="text-sm font-medium text-gray-900">Analytics unavailable</p>
50
- <p class="mt-1 text-xs text-gray-500">Could not load data.</p>
76
+ <p class="text-sm font-medium text-gray-900"><%= t("ruby_cms.admin.dashboard.analytics_unavailable", default: "Analytics unavailable") %></p>
77
+ <p class="mt-1 text-xs text-gray-500"><%= t("ruby_cms.admin.dashboard.analytics_unavailable_hint", default: "Could not load data.") %></p>
51
78
  </div>
52
79
  <% end %>
53
80
  </div>
@@ -5,18 +5,9 @@
5
5
  ) do %>
6
6
  <div class="space-y-5">
7
7
 
8
- <%# Stats compact, all four on one row from md upward %>
9
- <div class="grid grid-cols-2 gap-3 md:grid-cols-4">
10
- <% @stats_blocks.each do |block| %>
11
- <div class="flex flex-col">
12
- <%= render_dashboard_block(block) %>
13
- </div>
14
- <% end %>
15
- </div>
16
-
17
- <%# Built-in row: quick actions | recent errors | analytics — always equal-width columns %>
8
+ <%# Main row: three equal columns quick actions | recent errors | analytics %>
18
9
  <% if @primary_main_blocks.any? %>
19
- <div class="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-3 my-4">
10
+ <div class="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-3">
20
11
  <% @primary_main_blocks.each do |block| %>
21
12
  <div class="flex min-w-0 flex-col">
22
13
  <%= render_dashboard_block(block) %>
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyCms
4
- VERSION = "0.2.0.9"
4
+ VERSION = "0.2.1.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.9
4
+ version: 0.2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Codebyjob