Skip to content

Commit c973dc8

Browse files
committed
feat: user metrics
1 parent 8b42019 commit c973dc8

File tree

2 files changed

+165
-3
lines changed

2 files changed

+165
-3
lines changed

lib/algora/analytics/metrics.ex

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
defmodule Algora.Analytics.Metrics do
2+
@moduledoc false
3+
4+
import Ecto.Query
5+
6+
alias Algora.Accounts.User
7+
alias Algora.Repo
8+
9+
@type interval :: :daily | :weekly | :monthly
10+
@type period_metrics :: %{
11+
org_signups: non_neg_integer(),
12+
org_returns: non_neg_integer(),
13+
dev_signups: non_neg_integer(),
14+
dev_returns: non_neg_integer()
15+
}
16+
17+
@doc """
18+
Returns user metrics for the last n periods with the given interval.
19+
Organizations are users who are members of an org where their handle differs from the org handle.
20+
Developers are all other users.
21+
"""
22+
@spec get_user_metrics(pos_integer(), interval()) :: [{DateTime.t(), period_metrics()}]
23+
def get_user_metrics(n_periods, interval) do
24+
period_start = period_start_date(n_periods, interval)
25+
interval_str = interval_to_string(interval)
26+
27+
# Generate periods using SQL
28+
periods_query =
29+
from(
30+
p in fragment(
31+
"""
32+
SELECT generate_series(
33+
date_trunc(?, ?::timestamp),
34+
date_trunc(?, now()),
35+
(?||' '||?)::interval
36+
) as period_start
37+
""",
38+
^interval_str,
39+
^period_start,
40+
^interval_str,
41+
^"1",
42+
^interval_str
43+
),
44+
select: %{period_start: fragment("period_start")}
45+
)
46+
47+
# Base query for all users with org membership info
48+
base_query =
49+
from u in User,
50+
where: not is_nil(u.handle),
51+
select: %{
52+
inserted_at: fragment("date_trunc(?, ?)", ^interval_str, u.inserted_at),
53+
last_active_at: fragment("date_trunc(?, ?)", ^interval_str, u.last_active_at),
54+
is_org:
55+
fragment(
56+
"""
57+
EXISTS (SELECT 1
58+
FROM members m
59+
INNER JOIN users o ON m.org_id = o.id
60+
WHERE m.user_id = ? AND o.id != m.user_id
61+
)
62+
""",
63+
u.id
64+
)
65+
}
66+
67+
# Get signups per period
68+
signups =
69+
from q in subquery(base_query),
70+
right_join: p in subquery(periods_query),
71+
on: q.inserted_at == p.period_start,
72+
group_by: p.period_start,
73+
select: {
74+
p.period_start,
75+
%{
76+
org_signups: coalesce(count(fragment("CASE WHEN ? IS TRUE THEN 1 END", q.is_org)), 0),
77+
dev_signups: coalesce(count(fragment("CASE WHEN ? IS NOT TRUE THEN 1 END", q.is_org)), 0)
78+
}
79+
}
80+
81+
# Get returns per period
82+
returns =
83+
from q in subquery(base_query),
84+
right_join: p in subquery(periods_query),
85+
on: q.last_active_at == p.period_start,
86+
group_by: p.period_start,
87+
select: {
88+
p.period_start,
89+
%{
90+
org_returns: coalesce(count(fragment("CASE WHEN ? IS TRUE THEN 1 END", q.is_org)), 0),
91+
dev_returns: coalesce(count(fragment("CASE WHEN ? IS NOT TRUE THEN 1 END", q.is_org)), 0)
92+
}
93+
}
94+
95+
# Combine results
96+
signups = signups |> Repo.all() |> Map.new()
97+
returns = returns |> Repo.all() |> Map.new()
98+
99+
# Merge metrics
100+
periods = Repo.all(periods_query)
101+
102+
Enum.map(periods, fn %{period_start: date} ->
103+
signup_metrics = Map.get(signups, date, %{org_signups: 0, dev_signups: 0})
104+
return_metrics = Map.get(returns, date, %{org_returns: 0, dev_returns: 0})
105+
{date, Map.merge(signup_metrics, return_metrics)}
106+
end)
107+
end
108+
109+
def period_start_date(n_periods, interval) do
110+
now = DateTime.utc_now()
111+
112+
case interval do
113+
:daily -> DateTime.add(now, -n_periods, :day)
114+
:weekly -> DateTime.add(now, -n_periods * 7, :day)
115+
:monthly -> DateTime.add(now, -n_periods * 30, :day)
116+
end
117+
end
118+
119+
def generate_periods(n_periods, interval) do
120+
now = DateTime.utc_now()
121+
interval_str = interval_to_string(interval)
122+
123+
0..(n_periods - 1)
124+
|> Enum.map(fn offset ->
125+
case interval do
126+
:daily -> DateTime.add(now, -offset, :day)
127+
:weekly -> DateTime.add(now, -offset * 7, :day)
128+
:monthly -> DateTime.add(now, -offset * 30, :day)
129+
end
130+
end)
131+
|> Enum.map(&DateTime.truncate(&1, :second))
132+
# Truncate to the start of the period to match the SQL date_trunc
133+
|> Enum.map(fn dt ->
134+
dt
135+
|> DateTime.to_naive()
136+
|> NaiveDateTime.beginning_of_period(String.to_atom(interval_str))
137+
|> DateTime.from_naive!("Etc/UTC")
138+
end)
139+
end
140+
141+
defp interval_to_string(:daily), do: "day"
142+
defp interval_to_string(:weekly), do: "week"
143+
defp interval_to_string(:monthly), do: "month"
144+
end

lib/algora_web/live/admin/admin_live.ex

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ defmodule AlgoraWeb.Admin.AdminLive do
1717
funnel_data = Analytics.get_funnel_data()
1818
:ok = Activities.subscribe()
1919

20+
# Get user metrics for the last 30 days
21+
user_metrics = Analytics.Metrics.get_user_metrics(30, :daily)
22+
2023
mainthing = Mainthings.get_latest()
2124

2225
notes_changeset =
@@ -33,7 +36,8 @@ defmodule AlgoraWeb.Admin.AdminLive do
3336
|> assign(:timezone, timezone)
3437
|> assign(:analytics, analytics)
3538
|> assign(:funnel_data, funnel_data)
36-
|> assign(:selected_period, "30d")
39+
|> assign(:user_metrics, user_metrics)
40+
|> assign(:selected_period, :daily)
3741
|> assign(:notes_form, to_form(notes_changeset))
3842
|> assign(:notes_preview, (mainthing && Markdown.render_unsafe(mainthing.content)) || "")
3943
|> assign(:mainthing, mainthing)
@@ -216,6 +220,20 @@ defmodule AlgoraWeb.Admin.AdminLive do
216220
</div>
217221
</section>
218222
223+
<section id="user-metrics" class="scroll-mt-16">
224+
<div class="mb-4">
225+
<h1 class="text-2xl font-bold">User Activity</h1>
226+
</div>
227+
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
228+
<%= for {date, metrics} <- Enum.take(@user_metrics, 1) do %>
229+
<.stat_card title="Organization Signups" value={metrics.org_signups} subtitle="Last 24h" />
230+
<.stat_card title="Organization Returns" value={metrics.org_returns} subtitle="Last 24h" />
231+
<.stat_card title="Developer Signups" value={metrics.dev_signups} subtitle="Last 24h" />
232+
<.stat_card title="Developer Returns" value={metrics.dev_returns} subtitle="Last 24h" />
233+
<% end %>
234+
</div>
235+
</section>
236+
219237
<section id="customers" class="scroll-mt-16">
220238
<div class="mb-4">
221239
<h1 class="text-2xl font-bold">Customers</h1>
@@ -328,8 +346,8 @@ defmodule AlgoraWeb.Admin.AdminLive do
328346

329347
@impl true
330348
def handle_event("select_period", %{"period" => period}, socket) do
331-
{:ok, analytics} = Analytics.get_company_analytics(period)
332-
funnel_data = Analytics.get_funnel_data(period)
349+
{:ok, analytics} = Analytics.get_company_analytics()
350+
funnel_data = Analytics.get_funnel_data()
333351

334352
{:noreply,
335353
socket

0 commit comments

Comments
 (0)