From 9dcd896b6284f8aed6d7aff28432c62d8a8c4095 Mon Sep 17 00:00:00 2001 From: bluebird42 <2034043+bluebird42@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:46:19 +0200 Subject: [PATCH] Add period selection and engagement totals to the data overview The overview now covers 24 hours, 7 days, 30 days or 12 months instead of fixed today/month/year counts. Views, watch time, average watch time per view and average completion are compared with the period before, and a chart shows views and watch time per hour, day or month. Device and browser breakdowns follow the selected period. A view is one playback session of one video with at least one second watched; times are in UTC. The public space data API is unchanged. Co-Authored-By: Claude Opus 5.5 --- lib/mave_core/analytics/overview.ex | 320 +++++++++++++++ .../dashboard_components/video_detail.ex | 22 +- lib/mave_core_web/dashboard_routes.ex | 11 +- .../live/dashboard/data/index.ex | 221 +++------- .../dashboard/data/overview_components.ex | 376 ++++++++++++++++++ .../video_detail/video_metric_card.story.exs | 13 +- test/mave_core/analytics/overview_test.exs | 95 +++++ .../live/dashboard/data/index_test.exs | 80 ++-- 8 files changed, 949 insertions(+), 189 deletions(-) create mode 100644 lib/mave_core/analytics/overview.ex create mode 100644 lib/mave_core_web/live/dashboard/data/overview_components.ex create mode 100644 test/mave_core/analytics/overview_test.exs diff --git a/lib/mave_core/analytics/overview.ex b/lib/mave_core/analytics/overview.ex new file mode 100644 index 0000000..4135cc7 --- /dev/null +++ b/lib/mave_core/analytics/overview.ex @@ -0,0 +1,320 @@ +defmodule MaveCore.Analytics.Overview do + @moduledoc """ + Space-wide engagement statistics for the dashboard data page. + + A view is one playback session of one video with at least one second + watched. Statistics cover a selectable period and are compared with the + period of equal length right before it. Times are in UTC. + """ + + alias Ecto.Adapters.SQL + alias MaveCore.ClickHouseRepo + alias MaveCore.SharedStorageSpace + + @periods ~w(24h 7d 30d 12m) + @default_period "30d" + @min_watch_ms 1_000 + @clickhouse_identifier ~r/^[A-Za-z_][A-Za-z0-9_]*$/ + + def periods, do: @periods + def default_period, do: @default_period + + @doc """ + Returns the overview for `period` (one of `periods/0`). + + {:ok, + %{ + period: "30d", + bucket: :day, + current: %{views: 120, watch_ms: 3_600_000, avg_watch_ms: 30_000, avg_completion: 0.58}, + previous: %{views: 100, ...}, + series: [%{start: ~U[...], views: 4, watch_ms: 120_000}, ...], + devices: [%{device: "desktop", views: 80}, ...], + browsers: [%{browser: "Chrome", views: 60}, ...] + }} + """ + def space_overview(space_hash, period, now \\ DateTime.utc_now()) + + def space_overview(space_hash, period, now) when is_binary(space_hash) and period in @periods do + window = window(period, now) + + with {:ok, space_hash} <- parse_space_hash(space_hash) do + if SharedStorageSpace.hash?(space_hash) do + {:ok, empty(period, now)} + else + query_overview(space_hash, period, window) + end + end + end + + def space_overview(_space_hash, _period, _now), do: {:error, :invalid_request} + + @doc false + def window(period, %DateTime{} = now) do + now = DateTime.truncate(now, :second) + + {bucket, from} = + case period do + "24h" -> {:hour, now |> start_of(:hour) |> DateTime.add(-23, :hour)} + "7d" -> {:day, now |> start_of(:day) |> DateTime.add(-6, :day)} + "30d" -> {:day, now |> start_of(:day) |> DateTime.add(-29, :day)} + "12m" -> {:month, now |> start_of(:month) |> shift_months(-11)} + end + + length = DateTime.diff(now, from) + + %{ + bucket: bucket, + from: from, + to: now, + previous_from: DateTime.add(from, -length), + buckets: buckets(bucket, from, now) + } + end + + defp query_overview(space_hash, period, window) do + sql = """ + /* mave_perf:spaces.overview */ + #{views_cte(space_hash, window)} + SELECT + 'total' AS kind, + if(started_at >= #{datetime_literal(window.from)}, 'current', 'previous') AS key, + toUInt64(count()) AS views, + toInt64(sum(watch_ms)) AS watch_ms, + toFloat64(ifNotFinite(avgIf(completion, video_duration > 0), 0)) AS value + FROM views + GROUP BY key + UNION ALL + SELECT + 'series' AS kind, + formatDateTime(toDateTime(#{bucket_sql(window.bucket)}(started_at, 'UTC'), 'UTC'), '%Y-%m-%d %H:%i:%S', 'UTC') AS key, + toUInt64(count()) AS views, + toInt64(sum(watch_ms)) AS watch_ms, + toFloat64(0) AS value + FROM views + WHERE started_at >= #{datetime_literal(window.from)} + GROUP BY key + UNION ALL + #{dimension_sql("device", "device", window)} + UNION ALL + #{dimension_sql("browser", "browser", window)} + """ + + query_clickhouse(sql, {:error, :analytics_unavailable}, fn rows -> + {:ok, build_overview(rows, period, window)} + end) + end + + # One row per (video, session) with at least one second watched. + defp views_cte(space_hash, window) do + """ + WITH ordered AS ( + SELECT + embed_hash, + session_id, + timestamp, + name, + video_time, + duration, + device, + browser, + leadInFrame(timestamp) OVER ( + PARTITION BY embed_hash, session_id + ORDER BY timestamp + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS next_ts, + leadInFrame(name) OVER ( + PARTITION BY embed_hash, session_id + ORDER BY timestamp + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS next_name + FROM #{clickhouse_database_name()}.events + PREWHERE space_hash = #{string_literal(space_hash)} + AND timestamp >= #{datetime_literal(window.previous_from)} + AND timestamp < #{datetime_literal(window.to)} + WHERE name IN ('play', 'pause') + ), + views AS ( + SELECT + embed_hash, + session_id, + min(timestamp) AS started_at, + sumIf( + greatest(toInt64(0), toUnixTimestamp64Milli(next_ts) - toUnixTimestamp64Milli(timestamp)), + name = 'play' AND next_name = 'pause' + ) AS watch_ms, + max(duration) AS video_duration, + max(video_time) AS furthest, + if(video_duration > 0, least(1, furthest / video_duration), 0) AS completion, + nullIf(argMin(device, timestamp), '') AS device, + nullIf(argMin(browser, timestamp), '') AS browser + FROM ordered + GROUP BY embed_hash, session_id + HAVING watch_ms >= #{@min_watch_ms} + ) + """ + end + + defp dimension_sql(kind, column, window) do + """ + SELECT + #{string_literal(kind)} AS kind, + ifNull(#{column}, '') AS key, + toUInt64(count()) AS views, + toInt64(0) AS watch_ms, + toFloat64(0) AS value + FROM views + WHERE started_at >= #{datetime_literal(window.from)} + GROUP BY key + ORDER BY views DESC + LIMIT 50 + """ + end + + defp build_overview(rows, period, window) do + grouped = Enum.group_by(rows, &hd/1, &tl/1) + + totals = + Map.new(Map.get(grouped, "total", []), fn [key | values] -> {key, totals(values)} end) + + series = + Map.new(Map.get(grouped, "series", []), fn [key, views, watch_ms, _] -> + {key, {views, watch_ms}} + end) + + %{ + period: period, + bucket: window.bucket, + current: Map.get(totals, "current", totals([0, 0, 0.0])), + previous: Map.get(totals, "previous", totals([0, 0, 0.0])), + series: + Enum.map(window.buckets, fn start -> + {views, watch_ms} = Map.get(series, bucket_key(start), {0, 0}) + %{start: start, views: to_int(views), watch_ms: to_int(watch_ms)} + end), + devices: dimension_rows(grouped, "device", :device), + browsers: dimension_rows(grouped, "browser", :browser) + } + end + + defp totals([views, watch_ms, completion]) do + views = to_int(views) + watch_ms = to_int(watch_ms) + + %{ + views: views, + watch_ms: watch_ms, + avg_watch_ms: if(views > 0, do: div(watch_ms, views), else: 0), + avg_completion: to_float(completion) + } + end + + defp dimension_rows(grouped, kind, field) do + grouped + |> Map.get(kind, []) + |> Enum.map(fn [key, views, _watch_ms, _value] -> + %{field => if(key in [nil, ""], do: "Unknown", else: key), views: to_int(views)} + end) + |> Enum.sort_by(& &1.views, :desc) + end + + @doc "An overview without views, for spaces that cannot view statistics." + def empty(period, now \\ DateTime.utc_now()) when period in @periods do + window = window(period, now) + + %{ + period: period, + bucket: window.bucket, + current: totals([0, 0, 0.0]), + previous: totals([0, 0, 0.0]), + series: Enum.map(window.buckets, &%{start: &1, views: 0, watch_ms: 0}), + devices: [], + browsers: [] + } + end + + defp buckets(bucket, from, to) do + from + |> Stream.iterate(&next_bucket(&1, bucket)) + |> Enum.take_while(&(DateTime.compare(&1, to) != :gt)) + end + + defp next_bucket(start, :hour), do: DateTime.add(start, 1, :hour) + defp next_bucket(start, :day), do: DateTime.add(start, 1, :day) + defp next_bucket(start, :month), do: shift_months(start, 1) + + defp start_of(%DateTime{} = dt, :hour), do: %{dt | minute: 0, second: 0, microsecond: {0, 0}} + defp start_of(%DateTime{} = dt, :day), do: %{start_of(dt, :hour) | hour: 0} + defp start_of(%DateTime{} = dt, :month), do: %{start_of(dt, :day) | day: 1} + + defp shift_months(%DateTime{year: year, month: month} = dt, months) do + index = year * 12 + month - 1 + months + %{dt | year: div(index, 12), month: rem(index, 12) + 1} + end + + defp bucket_sql(:hour), do: "toStartOfHour" + defp bucket_sql(:day), do: "toStartOfDay" + defp bucket_sql(:month), do: "toStartOfMonth" + + # Matches the formatted bucket start returned by ClickHouse. + defp bucket_key(%DateTime{} = start) do + start |> DateTime.to_naive() |> NaiveDateTime.to_string() + end + + defp parse_space_hash(space_hash) do + space_hash = String.trim(space_hash) + + if String.match?(space_hash, ~r/^[A-Za-z0-9]{5}$/), + do: {:ok, space_hash}, + else: {:error, :invalid_space_hash} + end + + defp to_int(nil), do: 0 + defp to_int(v) when is_integer(v), do: v + defp to_int(v) when is_float(v), do: trunc(v) + defp to_int(%Decimal{} = v), do: Decimal.to_integer(v) + + defp to_int(v) when is_binary(v) do + case Integer.parse(v) do + {i, _} -> i + _ -> 0 + end + end + + defp to_float(v) when is_float(v), do: v + defp to_float(v) when is_integer(v), do: v * 1.0 + defp to_float(%Decimal{} = v), do: Decimal.to_float(v) + defp to_float(_v), do: 0.0 + + defp clickhouse_database_name do + case ClickHouseRepo.config()[:database] || "mave_metrics" do + db_name when is_binary(db_name) -> + if db_name =~ @clickhouse_identifier, do: db_name, else: "mave_metrics" + + _ -> + "mave_metrics" + end + end + + # sobelow_skip ["SQL.Query"] + defp query_clickhouse(sql, default, mapper) do + case SQL.query(ClickHouseRepo, sql, []) do + {:ok, %{rows: rows}} -> mapper.(rows) + _ -> default + end + end + + defp datetime_literal(dt) do + "parseDateTime64BestEffort(#{string_literal(DateTime.to_iso8601(dt))})" + end + + defp string_literal(value) do + escaped = + value + |> to_string() + |> String.replace("\\", "\\\\") + |> String.replace("'", "\\'") + + "'#{escaped}'" + end +end diff --git a/lib/mave_core_web/components/dashboard_components/video_detail.ex b/lib/mave_core_web/components/dashboard_components/video_detail.ex index da5bd1f..c046212 100644 --- a/lib/mave_core_web/components/dashboard_components/video_detail.ex +++ b/lib/mave_core_web/components/dashboard_components/video_detail.ex @@ -541,6 +541,9 @@ defmodule MaveCoreWeb.DashboardComponents.VideoDetail do attr :label, :string, required: true attr :value, :string, required: true attr :loading, :boolean, default: false + attr :unit, :string, default: nil, doc: "text above the value; defaults to \"views\"" + attr :delta, :string, default: nil, doc: "optional comparison shown below the value" + attr :delta_tone, :atom, values: [:up, :down, :flat], default: :flat def video_metric_card(assigns) do ~H""" @@ -549,8 +552,11 @@ defmodule MaveCoreWeb.DashboardComponents.VideoDetail do aria-busy={to_string(@loading)} > <.section_title label={@label} /> -
views
-
+
{@unit || gettext("views")}
+
{gettext("Loading")} {@value}
+
+ {@delta} +
""" end diff --git a/lib/mave_core_web/dashboard_routes.ex b/lib/mave_core_web/dashboard_routes.ex index 460aaa8..d23b357 100644 --- a/lib/mave_core_web/dashboard_routes.ex +++ b/lib/mave_core_web/dashboard_routes.ex @@ -20,11 +20,11 @@ defmodule MaveCoreWeb.DashboardRoutes do end def data_path(space_or_user, opts \\ []) do - path_for(space_or_user, "/data", [], opts) + path_for(space_or_user, "/data", period_params(opts), opts) end def data_top_list_path(space_or_user, opts \\ []) do - path_for(space_or_user, "/data/top-list", [], opts) + path_for(space_or_user, "/data/top-list", period_params(opts), opts) end def settings_path(space_or_user, tab \\ nil, opts \\ []) do @@ -58,6 +58,13 @@ defmodule MaveCoreWeb.DashboardRoutes do |> maybe_put_param(:page, page > 1, page) end + defp period_params(opts) do + case Keyword.get(opts, :period) do + period when is_binary(period) and period != "" -> [period: period] + _ -> [] + end + end + defp maybe_put_param(params, _key, false, _value), do: params defp maybe_put_param(params, key, true, value), do: Keyword.put(params, key, value) diff --git a/lib/mave_core_web/live/dashboard/data/index.ex b/lib/mave_core_web/live/dashboard/data/index.ex index d079462..1c363d8 100644 --- a/lib/mave_core_web/live/dashboard/data/index.ex +++ b/lib/mave_core_web/live/dashboard/data/index.ex @@ -9,8 +9,10 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do @top_list_query_size 50 alias MaveCore.Analytics + alias MaveCore.Analytics.Overview alias MaveCore.Embeds alias MaveCore.UsageLimits + alias MaveCoreWeb.Dashboard.Data.OverviewComponents alias MaveCoreWeb.DashboardRoutes alias MaveCoreWeb.Formatter alias Phoenix.LiveView.AsyncResult @@ -21,6 +23,7 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do |> assign(:page_title, gettext("Data")) |> assign(:settings_open, false) |> assign(:route_scope, :unscoped) + |> assign(:period, Overview.default_period()) |> assign(:show_all_devices, false) |> assign(:show_all_browsers, false) |> assign(:data_access_notice, nil) @@ -30,6 +33,7 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do def handle_params(params, _url, socket) do space = socket.assigns.current_space + socket = assign(socket, :period, period(params)) socket = case UsageLimits.can_view_space_data?(space) do @@ -56,9 +60,11 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do end defp start_tab_fetch(socket, _overview, space) do + period = socket.assigns.period + socket |> assign_async_defaults() - |> start_async(:fetch_stats, fn -> Analytics.space_data(space.hash) end) + |> start_async(:fetch_overview, fn -> Overview.space_overview(space.hash, period) end) end defp fetch_top_videos(space) do @@ -74,34 +80,22 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do end end - def handle_async(:fetch_stats, {:ok, {:ok, result}}, socket) do - views_by_month = Enum.map(result.views_by_month, &Map.put(&1, :count, &1.views)) - max_views = Enum.max_by(views_by_month, & &1.count, fn -> %{count: 0} end).count - max_month = max_views + max_views / 3 - + def handle_async(:fetch_overview, {:ok, {:ok, overview}}, socket) do {:noreply, socket - |> assign(:views_today, AsyncResult.ok(socket.assigns.views_today, result.views.today)) - |> assign( - :views_this_month, - AsyncResult.ok(socket.assigns.views_this_month, result.views.month) - ) - |> assign( - :views_this_year, - AsyncResult.ok(socket.assigns.views_this_year, result.views.year) - ) - |> assign(:views_per_month, AsyncResult.ok(socket.assigns.views_per_month, views_by_month)) - |> assign(:devices, AsyncResult.ok(socket.assigns.devices, result.devices)) - |> assign(:browsers, AsyncResult.ok(socket.assigns.browsers, result.browsers)) - |> assign(:max_month, AsyncResult.ok(socket.assigns.max_month, max_month)) + |> assign(:overview, AsyncResult.ok(socket.assigns.overview, overview)) |> assign(:show_all_devices, false) |> assign(:show_all_browsers, false)} end - def handle_async(:fetch_stats, {:ok, {:error, _reason}}, socket), - do: {:noreply, assign_failed(socket)} - - def handle_async(:fetch_stats, {:exit, _reason}, socket), do: {:noreply, assign_failed(socket)} + def handle_async(:fetch_overview, _result, socket) do + {:noreply, + assign( + socket, + :overview, + AsyncResult.failed(socket.assigns.overview, :analytics_unavailable) + )} + end def handle_async(:fetch_top_videos, {:ok, {:ok, top_videos}}, socket) do {:noreply, assign(socket, :top_videos, AsyncResult.ok(socket.assigns.top_videos, top_videos))} @@ -132,23 +126,38 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do <.menubar> <.menubar_item icon="hero-chart-bar-mini" - path={DashboardRoutes.data_path(@current_space, scope: @route_scope)} + path={ + DashboardRoutes.data_path(@current_space, + scope: @route_scope, + period: period_param(@period) + ) + } active={@live_action != :top_list} > {gettext("Overview")} <.menubar_item icon="hero-trophy-mini" - path={DashboardRoutes.data_top_list_path(@current_space, scope: @route_scope)} + path={ + DashboardRoutes.data_top_list_path(@current_space, + scope: @route_scope, + period: period_param(@period) + ) + } active={@live_action == :top_list} > {gettext("Top list")}
+
{@data_access_notice}
<.info_hover position="left"> @@ -168,27 +177,7 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do route_scope={@route_scope} /> <% else %> -
- <.subtitle label={gettext("Numbers")} icon="hero-hashtag" /> -
- -
- <.video_metric_card - label={gettext("Today")} - value={metric_value(@views_today)} - loading={@views_today.loading} - /> - <.video_metric_card - label={gettext("This month")} - value={metric_value(@views_this_month)} - loading={@views_this_month.loading} - /> - <.video_metric_card - label={gettext("This year")} - value={metric_value(@views_this_year)} - loading={@views_this_year.loading} - /> -
+ <.divider /> @@ -196,71 +185,7 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do <.subtitle label={gettext("History")} icon="hero-chart-bar" />
- <.video_data_section> -
- <.async_result :let={views_per_month} assign={@views_per_month}> - <:loading> -
-
- <%= for width <- [34, 42, 30, 38, 46, 32, 40, 28, 44, 34, 40, 30] do %> -
- -
- <% end %> -
-
- <%= for height <- [42, 67, 52, 78, 58, 86, 70, 47, 62, 38, 55, 31] do %> -
- -
- <% end %> -
- {gettext("Loading data")} -
- - -
- <%= for month <- views_per_month do %> -
{format_number(month.count)}
- <% end %> -
-
- <%= for month <- views_per_month do %> -
-
-
-
-
-
- <% end %> -
- -
- <%= for month <- ~w(jan feb mar apr may jun jul aug sep oct nov dec)a do %> -
{month}
- <% end %> -
-
- + <.divider /> @@ -271,8 +196,8 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do
<.source_panel title={gettext("Device")} - assign={@devices} - rows={visible_devices(@devices, @show_all_devices)} + assign={overview_field(@overview, :devices)} + rows={visible_devices(overview_field(@overview, :devices), @show_all_devices)} toggle="toggle_show_all_devices" expanded={@show_all_devices} key_name={:device} @@ -281,8 +206,8 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do <.source_panel title={gettext("Browser")} - assign={@browsers} - rows={visible_browsers(@browsers, @show_all_browsers)} + assign={overview_field(@overview, :browsers)} + rows={visible_browsers(overview_field(@overview, :browsers), @show_all_browsers)} toggle="toggle_show_all_browsers" expanded={@show_all_browsers} key_name={:browser} @@ -478,12 +403,28 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do defp route_scope(_params), do: :unscoped - defp metric_value(%AsyncResult{loading: true}), do: "..." + defp period(%{"period" => period}) do + if period in Overview.periods(), do: period, else: Overview.default_period() + end + + defp period(_params), do: Overview.default_period() + + defp period_path(assigns, period) do + DashboardRoutes.data_path(assigns.current_space, + scope: assigns.route_scope, + period: period_param(period) + ) + end + + # Keeps URLs for the default period free of a query string. + defp period_param(period) do + if period == Overview.default_period(), do: nil, else: period + end - defp metric_value(%AsyncResult{ok?: true, result: result}) when is_integer(result), - do: format_number(result) + defp overview_field(%AsyncResult{ok?: true, result: overview} = result, field), + do: %{result | result: Map.fetch!(overview, field)} - defp metric_value(_), do: "0" + defp overview_field(%AsyncResult{} = result, _field), do: result defp format_number(number), do: Formatter.format_number(number) @@ -505,50 +446,20 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do defp assign_async_defaults(socket) do socket |> assign(:top_videos, AsyncResult.loading()) - |> assign(:max_month, AsyncResult.loading()) - |> assign(:devices, AsyncResult.loading()) - |> assign(:browsers, AsyncResult.loading()) - |> assign(:views_today, AsyncResult.loading()) - |> assign(:views_this_month, AsyncResult.loading()) - |> assign(:views_this_year, AsyncResult.loading()) - |> assign(:views_per_month, AsyncResult.loading()) + |> assign(:overview, AsyncResult.loading()) end defp assign_empty_stats(socket) do socket - |> assign(:max_month, AsyncResult.ok(AsyncResult.loading(), 0)) - |> assign(:devices, AsyncResult.ok(AsyncResult.loading(), [])) - |> assign(:browsers, AsyncResult.ok(AsyncResult.loading(), [])) - |> assign(:views_today, AsyncResult.ok(AsyncResult.loading(), 0)) - |> assign(:views_this_month, AsyncResult.ok(AsyncResult.loading(), 0)) - |> assign(:views_this_year, AsyncResult.ok(AsyncResult.loading(), 0)) - |> assign(:views_per_month, AsyncResult.ok(AsyncResult.loading(), empty_months())) + |> assign( + :overview, + AsyncResult.ok(AsyncResult.loading(), Overview.empty(socket.assigns.period)) + ) |> assign(:top_videos, AsyncResult.ok(AsyncResult.loading(), [])) |> assign(:show_all_devices, false) |> assign(:show_all_browsers, false) end - defp empty_months do - Enum.map(1..12, fn _month -> %{count: 0, views: 0} end) - end - - defp assign_failed(socket) do - failed = AsyncResult.failed(AsyncResult.loading(), :analytics_unavailable) - - socket - |> assign(:views_today, failed) - |> assign(:views_this_month, failed) - |> assign(:views_this_year, failed) - |> assign(:views_per_month, failed) - |> assign(:devices, failed) - |> assign(:browsers, failed) - |> assign(:max_month, failed) - end - - defp calc_height(0, _max), do: 0 - defp calc_height(_count, 0), do: 0 - defp calc_height(count, max), do: 100 * count / max - defp data_access_notice(reason) do :view_space_data |> UsageLimits.restriction_copy(reason) diff --git a/lib/mave_core_web/live/dashboard/data/overview_components.ex b/lib/mave_core_web/live/dashboard/data/overview_components.ex new file mode 100644 index 0000000..c300e9f --- /dev/null +++ b/lib/mave_core_web/live/dashboard/data/overview_components.ex @@ -0,0 +1,376 @@ +defmodule MaveCoreWeb.Dashboard.Data.OverviewComponents do + @moduledoc false + + use MaveCoreWeb, :html + import MaveCoreWeb.DashboardComponents, except: [icon: 1] + + alias MaveCoreWeb.Formatter + alias Phoenix.LiveView.AsyncResult + + @period_labels [ + {"24h", "24 hours"}, + {"7d", "7 days"}, + {"30d", "30 days"}, + {"12m", "12 months"} + ] + + @chart_width 960 + @chart_height 200 + @chart_left 44 + @chart_right 8 + @chart_top 12 + @chart_bottom 26 + + attr :period, :string, required: true + attr :path, :any, required: true, doc: "function that builds the path for a period" + + def period_switch(assigns) do + assigns = assign(assigns, :periods, @period_labels) + + ~H""" + + """ + end + + attr :overview, AsyncResult, required: true + attr :period, :string, required: true + + def kpis(assigns) do + assigns = + assign(assigns, + loading: assigns.overview.loading != nil, + data: assigns.overview.ok? && assigns.overview.result + ) + + ~H""" +
+ <.video_metric_card + label={gettext("Views")} + value={kpi_value(@data, :views)} + loading={@loading} + delta={kpi_delta(@data, :views, @period)} + delta_tone={kpi_tone(@data, :views)} + /> + <.video_metric_card + label={gettext("Watch time")} + unit={watch_time_unit(@data)} + value={kpi_value(@data, :watch_ms)} + loading={@loading} + delta={kpi_delta(@data, :watch_ms, @period)} + delta_tone={kpi_tone(@data, :watch_ms)} + /> + <.video_metric_card + label={gettext("Average per view")} + unit={gettext("watched")} + value={kpi_value(@data, :avg_watch_ms)} + loading={@loading} + delta={kpi_delta(@data, :avg_watch_ms, @period)} + delta_tone={kpi_tone(@data, :avg_watch_ms)} + /> + <.video_metric_card + label={gettext("Average completion")} + unit={gettext("of each video")} + value={kpi_value(@data, :avg_completion)} + loading={@loading} + delta={kpi_delta(@data, :avg_completion, @period)} + delta_tone={kpi_tone(@data, :avg_completion)} + /> +
+ """ + end + + attr :overview, AsyncResult, required: true + + def trend(assigns) do + ~H""" + <.video_data_section> + <.async_result :let={overview} assign={@overview}> + <:loading> +
+ + {gettext("Loading data")} +
+ + <:failed> +
+ {gettext("Data is unavailable")} +
+ + <.trend_chart chart={chart(overview)} /> + + + """ + end + + attr :chart, :map, required: true + + defp trend_chart(assigns) do + ~H""" +
+
+ + {gettext("views")} + + + {gettext("watch time")} + +
+ + + + + {tick.label} + + + + {bar.title} + + + + {label.text} + + +

+ {gettext("No views in this period yet")} +

+
+ """ + end + + @doc false + def chart(%{series: series, bucket: bucket}) do + count = max(length(series), 1) + plot_width = @chart_width - @chart_left - @chart_right + plot_height = @chart_height - @chart_top - @chart_bottom + slot = plot_width / count + max_views = series |> Enum.map(& &1.views) |> Enum.max(fn -> 0 end) |> nice_max() + max_watch = series |> Enum.map(& &1.watch_ms) |> Enum.max(fn -> 0 end) + y = fn value, max -> @chart_top + plot_height * (1 - value / max) end + last_index = count - 1 + + bars = + series + |> Enum.with_index() + |> Enum.map(fn {point, index} -> + top = y.(point.views, max_views) + + %{ + x: Float.round(@chart_left + index * slot + slot * 0.15, 2), + y: Float.round(top, 2), + width: Float.round(slot * 0.7, 2), + height: Float.round(@chart_top + plot_height - top, 2), + views: point.views, + last: index == last_index, + title: + "#{bucket_label(point.start, bucket, :long)} · #{format_number(point.views)} #{gettext("views")} · #{format_watch_time(point.watch_ms)}" + } + end) + + line = + if max_watch > 0 do + series + |> Enum.with_index() + |> Enum.map_join(" ", fn {point, index} -> + "#{Float.round(@chart_left + index * slot + slot / 2, 2)},#{Float.round(y.(point.watch_ms, max_watch * 1.1), 2)}" + end) + else + "" + end + + %{ + width: @chart_width, + height: @chart_height, + left: @chart_left, + right: @chart_right, + ticks: + Enum.map([0, 0.5, 1], fn share -> + value = round(max_views * share) + %{y: Float.round(y.(value, max_views), 2), label: format_number(value)} + end), + bars: bars, + line: line, + labels: axis_labels(series, bucket, slot), + empty?: Enum.all?(series, &(&1.views == 0)) + } + end + + defp axis_labels(series, bucket, slot) do + every = max(1, div(length(series), 6)) + last_index = length(series) - 1 + + series + |> Enum.with_index() + |> Enum.filter(fn {_point, index} -> rem(last_index - index, every) == 0 end) + |> Enum.map(fn {point, index} -> + %{ + x: Float.round(@chart_left + index * slot + slot / 2, 2), + text: bucket_label(point.start, bucket, :short) + } + end) + end + + defp bucket_label(start, :hour, _style), do: Calendar.strftime(start, "%H:00") + defp bucket_label(start, :day, :short), do: Calendar.strftime(start, "%-d %b") + defp bucket_label(start, :day, :long), do: Calendar.strftime(start, "%a %-d %b") + defp bucket_label(start, :month, :short), do: Calendar.strftime(start, "%b") + defp bucket_label(start, :month, :long), do: Calendar.strftime(start, "%B %Y") + + # Rounds the axis maximum up to 1, 2 or 5 times a power of ten. + defp nice_max(value) when value <= 4, do: 4 + + defp nice_max(value) do + magnitude = :math.pow(10, floor(:math.log10(value))) + step = Enum.find([1, 2, 5, 10], &(&1 * magnitude >= value)) + round(step * magnitude) + end + + defp kpi_value(false, _key), do: "0" + defp kpi_value(%{current: current}, :views), do: format_number(current.views) + defp kpi_value(%{current: current}, :watch_ms), do: watch_time_number(current.watch_ms) + defp kpi_value(%{current: current}, :avg_watch_ms), do: format_duration(current.avg_watch_ms) + + defp kpi_value(%{current: current}, :avg_completion), + do: "#{round(current.avg_completion * 100)}%" + + defp watch_time_unit(%{current: %{watch_ms: watch_ms}}) when watch_ms >= 3_600_000, + do: gettext("hours") + + defp watch_time_unit(_data), do: gettext("minutes") + + defp watch_time_number(watch_ms) when watch_ms >= 3_600_000, + do: format_number(round(watch_ms / 3_600_000)) + + defp watch_time_number(watch_ms), do: format_number(round(watch_ms / 60_000)) + + defp format_watch_time(watch_ms) when watch_ms >= 3_600_000, + do: "#{format_number(Float.round(watch_ms / 3_600_000, 1))} h" + + defp format_watch_time(watch_ms), do: "#{format_number(round(watch_ms / 60_000))} min" + + defp format_duration(ms) do + seconds = div(ms, 1000) + {hours, rest} = {div(seconds, 3600), rem(seconds, 3600)} + {minutes, seconds} = {div(rest, 60), rem(rest, 60)} + padded = &String.pad_leading(Integer.to_string(&1), 2, "0") + + if hours > 0, + do: "#{hours}:#{padded.(minutes)}:#{padded.(seconds)}", + else: "#{minutes}:#{padded.(seconds)}" + end + + defp kpi_delta(false, _key, _period), do: nil + + defp kpi_delta(%{current: current, previous: previous}, :avg_completion, period) do + if previous.views == 0 do + nil + else + points = Float.round((current.avg_completion - previous.avg_completion) * 100, 1) + "#{arrow(points)} #{format_number(abs(points))} pt #{versus(period)}" + end + end + + defp kpi_delta(%{current: current, previous: previous}, key, period) do + case Map.fetch!(previous, key) do + 0 -> + nil + + before -> + change = Float.round((Map.fetch!(current, key) - before) / before * 100, 1) + "#{arrow(change)} #{format_number(abs(change))}% #{versus(period)}" + end + end + + defp kpi_tone(false, _key), do: :flat + + defp kpi_tone(%{current: current, previous: previous}, key) do + case {previous.views, Map.fetch!(current, key) - Map.fetch!(previous, key)} do + {0, _} -> :flat + {_, diff} when diff > 0 -> :up + {_, diff} when diff < 0 -> :down + _ -> :flat + end + end + + defp arrow(change) when change > 0, do: "▲" + defp arrow(change) when change < 0, do: "▼" + defp arrow(_change), do: "±" + + defp versus("24h"), do: gettext("vs previous 24 hours") + defp versus("7d"), do: gettext("vs previous 7 days") + defp versus("30d"), do: gettext("vs previous 30 days") + defp versus("12m"), do: gettext("vs previous 12 months") + + defp period_label("24 hours"), do: gettext("24 hours") + defp period_label("7 days"), do: gettext("7 days") + defp period_label("30 days"), do: gettext("30 days") + defp period_label("12 months"), do: gettext("12 months") + + defp format_number(number), do: Formatter.format_number(number) +end diff --git a/storybook/dashboard/video_detail/video_metric_card.story.exs b/storybook/dashboard/video_detail/video_metric_card.story.exs index d486094..4eba436 100644 --- a/storybook/dashboard/video_detail/video_metric_card.story.exs +++ b/storybook/dashboard/video_detail/video_metric_card.story.exs @@ -4,7 +4,7 @@ defmodule Storybook.Dashboard.VideoDetail.VideoMetricCard do def function, do: &MaveCoreWeb.DashboardComponents.video_metric_card/1 def description do - "Metric card used by the video detail analytics summary." + "Metric card used by the video detail and data page analytics summaries." end def variations do @@ -15,6 +15,17 @@ defmodule Storybook.Dashboard.VideoDetail.VideoMetricCard do label: "today", value: "7.4K" } + }, + %Variation{ + id: :with_comparison, + description: "With a unit and a comparison to the previous period", + attributes: %{ + label: "Watch time", + unit: "hours", + value: "126", + delta: "▲ 5,9% vs previous 30 days", + delta_tone: :up + } } ] end diff --git a/test/mave_core/analytics/overview_test.exs b/test/mave_core/analytics/overview_test.exs new file mode 100644 index 0000000..5953eda --- /dev/null +++ b/test/mave_core/analytics/overview_test.exs @@ -0,0 +1,95 @@ +defmodule MaveCore.Analytics.OverviewTest do + use ExUnit.Case, async: false + + alias MaveCore.Analytics.Overview + alias MaveCore.ClickHouseRepo + + @now ~U[2026-10-12 15:30:00Z] + + setup do + config = Application.get_env(:mave_core, MaveCore.ClickHouseRepo) + db_name = config[:database] || "mave_metrics" + ClickHouseRepo.query("TRUNCATE TABLE IF EXISTS #{db_name}.events") + + {:ok, db_name: db_name} + end + + test "window covers the period up to now and an equal period before it" do + assert %{bucket: :day, from: ~U[2026-09-13 00:00:00Z], to: @now, buckets: buckets} = + window = Overview.window("30d", @now) + + assert length(buckets) == 30 + assert List.last(buckets) == ~U[2026-10-12 00:00:00Z] + assert DateTime.diff(window.from, window.previous_from) == DateTime.diff(@now, window.from) + + assert %{bucket: :hour, buckets: hours} = Overview.window("24h", @now) + assert length(hours) == 24 + assert List.last(hours) == ~U[2026-10-12 15:00:00Z] + + assert %{bucket: :month, from: ~U[2025-11-01 00:00:00Z], buckets: months} = + Overview.window("12m", @now) + + assert length(months) == 12 + end + + test "counts views, watch time and completion per video session", %{db_name: db_name} do + now = DateTime.utc_now() + + insert_events(db_name, [ + # Current period: two sessions on one video, one on another. + {-3_600, "play", 1, "AAAAAAAAAA", 0.0}, + {-3_540, "pause", 1, "AAAAAAAAAA", 60.0}, + {-1_800, "play", 2, "AAAAAAAAAA", 0.0}, + {-1_770, "pause", 2, "AAAAAAAAAA", 30.0}, + {-600, "play", 3, "BBBBBBBBBB", 0.0}, + {-480, "pause", 3, "BBBBBBBBBB", 120.0}, + # Under one second watched: not a view. + {-300, "play", 4, "BBBBBBBBBB", 0.0}, + {-299.5, "pause", 4, "BBBBBBBBBB", 0.5}, + # Previous period (more than 24 hours ago). + {-30 * 3_600, "play", 5, "AAAAAAAAAA", 0.0}, + {-30 * 3_600 + 12, "pause", 5, "AAAAAAAAAA", 12.0} + ]) + + assert {:ok, overview} = Overview.space_overview("space", "24h", now) + + assert overview.current.views == 3 + assert overview.current.watch_ms == 210_000 + assert overview.current.avg_watch_ms == 70_000 + # Completion: 60/120, 30/120 and 120/120. + assert_in_delta overview.current.avg_completion, (0.5 + 0.25 + 1.0) / 3, 0.001 + + assert overview.previous.views == 1 + assert overview.previous.watch_ms == 12_000 + + assert length(overview.series) == 24 + assert Enum.sum(Enum.map(overview.series, & &1.views)) == 3 + assert [%{device: "Desktop", views: 3}] = overview.devices + end + + test "rejects unknown periods" do + assert {:error, :invalid_request} = Overview.space_overview("space", "5y") + end + + defp insert_events(db_name, events) do + now = DateTime.utc_now() + + values = + Enum.map_join(events, ",\n", fn {offset, name, session, embed_hash, video_time} -> + timestamp = + now |> DateTime.add(round(offset * 1000), :millisecond) |> DateTime.to_iso8601() + + session_id = "00000000-0000-0000-0000-#{String.pad_leading("#{session}", 12, "0")}" + + "(parseDateTime64BestEffort('#{timestamp}'), '#{name}', '#{session_id}', 'space', '#{embed_hash}', #{video_time}, 120, 'https://example.com/page', 'Chrome', '120', 'Mac OS', '14', 'Desktop', 'Apple')" + end) + + {:ok, _} = + ClickHouseRepo.query(""" + INSERT INTO #{db_name}.events + (timestamp, name, session_id, space_hash, embed_hash, video_time, duration, source_url, browser, browser_version, os, os_version, device, device_brand) + VALUES + #{values} + """) + end +end diff --git a/test/mave_core_web/live/dashboard/data/index_test.exs b/test/mave_core_web/live/dashboard/data/index_test.exs index c2a4de2..bed4187 100644 --- a/test/mave_core_web/live/dashboard/data/index_test.exs +++ b/test/mave_core_web/live/dashboard/data/index_test.exs @@ -4,6 +4,7 @@ defmodule MaveCoreWeb.Live.Dashboard.Data.IndexTest do import Phoenix.LiveViewTest alias MaveCore.Accounts + alias MaveCore.Analytics.Overview alias MaveCore.ClickHouseRepo alias MaveCore.Embeds alias MaveCore.Repo @@ -39,38 +40,68 @@ defmodule MaveCoreWeb.Live.Dashboard.Data.IndexTest do _ = render_async(view, 5_000) html = render(view) - assert html =~ "Data" - assert html =~ "Today" - assert html =~ "This month" - assert html =~ "This year" + assert html =~ "Views" + assert html =~ "Watch time" + assert html =~ "Average per view" + assert html =~ "Average completion" assert html =~ "History" assert html =~ "Sources" assert html =~ "Device" assert html =~ "Browser" - assert html =~ "grid grid-cols-3 gap-4 font-sans" + assert html =~ ~s(id="data-trend") + # The default 30-day period includes both views; the earlier one is two days old. + assert view |> element("#data-kpis") |> render() =~ ~r/>\s*2\s* element(~s(#data-period-switch a[href="/data?period=24h"])) |> render_click() + assert_patch(view, "/data?period=24h") + _ = render_async(view, 5_000) + + kpis = view |> element("#data-kpis") |> render() + # One view in the last 24 hours against one view 30 hours ago. + assert kpis =~ "vs previous 24 hours" + assert has_element?(view, ~s(#data-period-switch a[aria-current="page"]), "24 hours") + assert has_element?(view, ~s(a[href="/data/top-list?period=24h"])) + end + test "/data formats analytics counts with dot thousands separators" do html = %{ live_action: :index, route_scope: :unscoped, current_space: %{id: "space-id"}, + period: "30d", data_access_notice: nil, data_access_help: nil, - views_today: async_ok(12_345), - views_this_month: async_ok(67_890), - views_this_year: async_ok(123_456), - views_per_month: - async_ok([ - %{count: 12_345, views: 12_345}, - %{count: 67_890, views: 67_890} - | empty_months(10) - ]), - devices: async_ok([%{device: "desktop", views: 12_345}]), - browsers: async_ok([%{browser: "chrome", views: 67_890}]), - max_month: async_ok(100_000), + overview: + async_ok(%{ + Overview.empty("30d") + | current: %{views: 123_456, watch_ms: 0, avg_watch_ms: 0, avg_completion: 0.0}, + devices: [%{device: "desktop", views: 12_345}], + browsers: [%{browser: "chrome", views: 67_890}] + }), show_all_devices: false, show_all_browsers: false } @@ -93,22 +124,17 @@ defmodule MaveCoreWeb.Live.Dashboard.Data.IndexTest do live_action: :index, route_scope: :unscoped, current_space: %{id: "space-id"}, + period: "30d", data_access_notice: nil, data_access_help: nil, - views_today: loading, - views_this_month: loading, - views_this_year: loading, - views_per_month: loading, - devices: loading, - browsers: loading, - max_month: loading, + overview: loading, show_all_devices: false, show_all_browsers: false } |> Index.render() |> rendered_to_string() - assert html =~ ~s(id="data-history-loading") + assert html =~ ~s(id="data-trend-loading") assert html =~ ~s(id="data-device-sources-loading") assert html =~ ~s(id="data-browser-sources-loading") assert html =~ ~s(aria-label="Loading history") @@ -210,8 +236,4 @@ defmodule MaveCoreWeb.Live.Dashboard.Data.IndexTest do end defp async_ok(result), do: AsyncResult.ok(AsyncResult.loading(), result) - - defp empty_months(count) do - Enum.map(1..count, fn _month -> %{count: 0, views: 0} end) - end end