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 +4 -4
- data/CHANGELOG.md +4 -0
- data/app/controllers/ruby_cms/admin/analytics_controller.rb +11 -1
- data/app/controllers/ruby_cms/admin/dashboard_controller.rb +2 -3
- data/app/javascript/controllers/ruby_cms/index.js +0 -1
- data/app/views/ruby_cms/admin/analytics/index.html.erb +8 -8
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +306 -51
- data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +296 -48
- data/app/views/ruby_cms/admin/dashboard/blocks/_analytics_overview.html.erb +63 -36
- data/app/views/ruby_cms/admin/dashboard/index.html.erb +2 -11
- data/lib/ruby_cms/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d3c084a1a0a48bcb56bfe935ed7005b29c967617575e4ee284adbdfe5d020b8
|
|
4
|
+
data.tar.gz: 8d2b6780c2195cb72fb571e9c532e1b20092132ba96699fc918e407c820a8b59
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
30
|
-
|
|
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) }
|
|
@@ -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-
|
|
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="
|
|
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:
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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="
|
|
37
|
-
<div class="
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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, "&").replace(/</g, "<").replace(/"/g, """);
|
|
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"
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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="
|
|
34
|
-
<div class="
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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, "&").replace(/</g, "<").replace(/"/g, """);
|
|
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"
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 "
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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="
|
|
34
|
-
<
|
|
35
|
-
<% top.to_a.first(4).
|
|
36
|
-
<
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
</
|
|
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-
|
|
46
|
-
<div class="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-
|
|
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"
|
|
50
|
-
<p class="mt-1 text-xs text-gray-500"
|
|
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
|
-
<%#
|
|
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
|
|
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) %>
|
data/lib/ruby_cms/version.rb
CHANGED