Skip to content

Commit 0b75d10

Browse files
committed
Basic statistics
This commit adds some statistic collection tables, a script that reruns these statistics and a "/stats" web endpoint with some graph. These are just a first prototype and subject to change, but already provide some valuable insights about the participants of the mailing list.
1 parent 99f3304 commit 0b75d10

20 files changed

Lines changed: 3356 additions & 1 deletion

app/assets/stylesheets/application.postcss.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@
1717
@import "components/notes.css";
1818
@import "components/activities.css";
1919
@import "components/profile.css";
20+
@import "components/stats.css";

app/assets/stylesheets/base/root.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ body {
2727
border-right: var(--border-width) solid var(--color-border);
2828
min-height: calc(100vh - var(--nav-height));
2929
background-color: var(--color-bg-sidebar);
30+
align-self: stretch;
3031
}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
.stats-page {
2+
display: flex;
3+
flex-direction: column;
4+
gap: var(--spacing-6);
5+
}
6+
7+
.stats-intro {
8+
color: var(--color-text-muted);
9+
}
10+
11+
.stats-sidebar {
12+
display: flex;
13+
flex-direction: column;
14+
gap: var(--spacing-3);
15+
position: sticky;
16+
top: var(--spacing-6);
17+
}
18+
19+
.stats-controls {
20+
display: flex;
21+
gap: var(--spacing-2);
22+
flex-direction: column;
23+
background: transparent;
24+
border: 0;
25+
border-radius: 0;
26+
padding: 0;
27+
}
28+
29+
.stats-toc {
30+
background: transparent;
31+
border: 0;
32+
border-radius: 0;
33+
padding: 0;
34+
font-size: var(--font-size-sm);
35+
}
36+
37+
.stats-toc ul {
38+
list-style: none;
39+
padding: 0;
40+
margin: 0;
41+
display: flex;
42+
flex-direction: column;
43+
gap: 4px;
44+
}
45+
46+
.stats-toc a {
47+
color: var(--color-text-primary);
48+
text-decoration: none;
49+
}
50+
51+
.stats-toc a:hover {
52+
text-decoration: underline;
53+
}
54+
55+
.stats-sidebar-section {
56+
background: var(--color-bg-card);
57+
border: var(--border-width) solid var(--color-border);
58+
border-radius: var(--border-radius-md);
59+
padding: var(--spacing-3);
60+
}
61+
62+
.stats-sidebar-summary {
63+
display: flex;
64+
align-items: center;
65+
gap: var(--spacing-2);
66+
cursor: pointer;
67+
list-style: none;
68+
font-weight: var(--font-weight-semibold);
69+
}
70+
71+
.stats-sidebar-summary::-webkit-details-marker {
72+
display: none;
73+
}
74+
75+
.stats-sidebar-summary::before {
76+
content: "▾";
77+
font-size: var(--font-size-sm);
78+
color: var(--color-text-muted);
79+
}
80+
81+
.stats-sidebar-section:not([open]) .stats-sidebar-summary::before {
82+
content: "▸";
83+
}
84+
85+
.stats-controls label {
86+
display: flex;
87+
align-items: center;
88+
justify-content: space-between;
89+
gap: var(--spacing-2);
90+
font-size: var(--font-size-sm);
91+
width: 100%;
92+
}
93+
94+
.stats-controls .label-text {
95+
flex: 1 1 auto;
96+
}
97+
98+
.stats-controls select,
99+
.stats-controls input[type="date"] {
100+
width: auto;
101+
min-width: 140px;
102+
}
103+
104+
.stats-controls select {
105+
padding: var(--spacing-2);
106+
border-radius: var(--border-radius-sm);
107+
border: var(--border-width) solid var(--color-border);
108+
background: var(--color-bg-input);
109+
color: var(--color-text-primary);
110+
}
111+
112+
.stats-controls input[type="date"] {
113+
padding: var(--spacing-2);
114+
border-radius: var(--border-radius-sm);
115+
border: var(--border-width) solid var(--color-border);
116+
background: var(--color-bg-input);
117+
color: var(--color-text-primary);
118+
}
119+
120+
.stats-controls button {
121+
width: 100%;
122+
}
123+
124+
.stats-series {
125+
background: var(--color-bg-card);
126+
border: var(--border-width) solid var(--color-border);
127+
border-radius: var(--border-radius-md);
128+
padding: var(--spacing-2);
129+
}
130+
131+
.series-grid {
132+
display: flex;
133+
flex-wrap: wrap;
134+
gap: var(--spacing-1) var(--spacing-2);
135+
margin-top: var(--spacing-1);
136+
}
137+
138+
.series-grid label {
139+
display: inline-flex;
140+
align-items: center;
141+
gap: 6px;
142+
margin: 0;
143+
font-size: var(--font-size-sm);
144+
line-height: 1.2;
145+
white-space: nowrap;
146+
}
147+
148+
.stats-charts {
149+
display: flex;
150+
flex-direction: column;
151+
gap: var(--spacing-6);
152+
}
153+
154+
.stats-chart-summary {
155+
display: flex;
156+
align-items: center;
157+
gap: var(--spacing-3);
158+
cursor: pointer;
159+
list-style: none;
160+
}
161+
162+
.stats-chart-summary::-webkit-details-marker {
163+
display: none;
164+
}
165+
166+
.stats-chart-summary::before {
167+
content: "▾";
168+
font-size: var(--font-size-sm);
169+
color: var(--color-text-muted);
170+
}
171+
172+
.stats-chart-block:not([open]) .stats-chart-summary::before {
173+
content: "▸";
174+
}
175+
176+
.stats-chart-body {
177+
margin-top: var(--spacing-2);
178+
}
179+
180+
.stats-charts h2 {
181+
margin-bottom: 0;
182+
}
183+
184+
.stats-heatmap-note {
185+
margin: 0 0 var(--spacing-2) 0;
186+
color: var(--color-text-muted);
187+
font-size: var(--font-size-sm);
188+
}
189+
190+
#stats-chart-main,
191+
#stats-chart-depth,
192+
#stats-chart-longevity,
193+
#stats-chart-longevity-heatmap,
194+
#stats-chart-participant-lifetime,
195+
#stats-chart-new-participant-rate,
196+
#stats-chart-participant-retention,
197+
#stats-chart-attachments,
198+
#stats-chart-new-topic-attachments,
199+
#stats-chart-retention-milestones,
200+
#stats-chart-message-breakdown,
201+
#stats-chart-created-depth,
202+
#stats-chart-retention-heatmap,
203+
#stats-chart-retention-intensity-heatmap {
204+
background: var(--color-bg-card);
205+
border: var(--border-width) solid var(--color-border);
206+
border-radius: var(--border-radius-md);
207+
padding: var(--spacing-2);
208+
}
209+
210+
.stats-explainer,
211+
.stats-editor {
212+
background: var(--color-bg-card);
213+
border: var(--border-width) solid var(--color-border);
214+
border-radius: var(--border-radius-md);
215+
padding: var(--spacing-3);
216+
}
217+
218+
.stats-explainer summary,
219+
.stats-editor summary {
220+
font-weight: var(--font-weight-semibold);
221+
cursor: pointer;
222+
}
223+
224+
.stats-explainer-body {
225+
margin-top: var(--spacing-3);
226+
display: flex;
227+
flex-direction: column;
228+
gap: var(--spacing-2);
229+
color: var(--color-text-muted);
230+
}
231+
232+
#stats-editor {
233+
width: 100%;
234+
min-height: 240px;
235+
margin-top: var(--spacing-3);
236+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
237+
font-size: 12px;
238+
padding: var(--spacing-3);
239+
border-radius: var(--border-radius-sm);
240+
border: var(--border-width) solid var(--color-border);
241+
background: var(--color-bg-input);
242+
color: var(--color-text-primary);
243+
}
244+
245+
.editor-actions {
246+
margin-top: var(--spacing-2);
247+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
class StatsController < ApplicationController
2+
skip_before_action :require_authentication, raise: false
3+
4+
def show
5+
end
6+
7+
def data
8+
granularity = params[:granularity].to_s.presence_in(%w[day week month]) || "week"
9+
range_param = params[:range].to_s.presence_in(%w[last_30 last_90 last_365 last_3650 after_2000 all_time custom]) || "last_90"
10+
end_date = Date.current
11+
start_date =
12+
if range_param == "custom"
13+
parse_date(params[:from]) || end_date - 89.days
14+
elsif range_param == "after_2000"
15+
Date.new(2000, 1, 1)
16+
elsif range_param == "all_time"
17+
stats_model, = models_for(granularity)
18+
stats_model.minimum(:interval_start) || end_date - 89.days
19+
else
20+
range_days = range_param.delete_prefix("last_").to_i
21+
end_date - (range_days - 1).days
22+
end
23+
end_date = parse_date(params[:to]) || end_date if range_param == "custom"
24+
25+
stats_model, hist_model = models_for(granularity)
26+
27+
intervals = stats_model.where(interval_start: start_date..end_date).order(:interval_start)
28+
histogram = hist_model.where(interval_start: start_date..end_date).order(:interval_start, :bucket)
29+
retention_period = params[:retention_granularity].to_s == "quarter" ? 3 : 1
30+
retention_segment = params[:retention_segment].to_s == "replied_to_others" ? "replied_to_others" : "all"
31+
retention = StatsRetentionMonthly
32+
.where(
33+
period_months: retention_period,
34+
segment: retention_segment,
35+
cohort_start: start_date.beginning_of_month..end_date.beginning_of_month
36+
)
37+
.order(:cohort_start, :months_since)
38+
retention_milestones = StatsRetentionMilestone
39+
.where(
40+
period_months: retention_period,
41+
segment: retention_segment,
42+
cohort_start: start_date.beginning_of_month..end_date.beginning_of_month
43+
)
44+
.order(:cohort_start, :horizon_months)
45+
46+
render json: {
47+
granularity: granularity,
48+
range: range_param,
49+
from: start_date,
50+
to: end_date,
51+
intervals: intervals.as_json(except: [:id, :created_at, :updated_at]),
52+
longevity_histogram: histogram.as_json(except: [:id, :created_at, :updated_at]),
53+
retention_heatmap: retention.as_json(except: [:id, :created_at, :updated_at]),
54+
retention_milestones: retention_milestones.as_json(except: [:id, :created_at, :updated_at])
55+
}
56+
end
57+
58+
private
59+
60+
def models_for(granularity)
61+
case granularity
62+
when "day"
63+
[StatsDaily, StatsLongevityDaily]
64+
when "week"
65+
[StatsWeekly, StatsLongevityWeekly]
66+
when "month"
67+
[StatsMonthly, StatsLongevityMonthly]
68+
else
69+
[StatsWeekly, StatsLongevityWeekly]
70+
end
71+
end
72+
73+
def parse_date(value)
74+
Date.iso8601(value.to_s)
75+
rescue ArgumentError
76+
nil
77+
end
78+
end

app/models/stats_daily.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class StatsDaily < ApplicationRecord
2+
self.table_name = "stats_daily"
3+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class StatsLongevityDaily < ApplicationRecord
2+
self.table_name = "stats_longevity_daily"
3+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class StatsLongevityMonthly < ApplicationRecord
2+
self.table_name = "stats_longevity_monthly"
3+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class StatsLongevityWeekly < ApplicationRecord
2+
self.table_name = "stats_longevity_weekly"
3+
end

app/models/stats_monthly.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class StatsMonthly < ApplicationRecord
2+
self.table_name = "stats_monthly"
3+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class StatsRetentionMilestone < ApplicationRecord
2+
self.table_name = "stats_retention_milestones"
3+
end

0 commit comments

Comments
 (0)