diff --git a/lib/mave_core/analytics.ex b/lib/mave_core/analytics.ex index ec1406f..577bbf6 100644 --- a/lib/mave_core/analytics.ex +++ b/lib/mave_core/analytics.ex @@ -98,6 +98,85 @@ defmodule MaveCore.Analytics do }} end + @doc """ + Returns the space's most viewed video embeds this year as + `%{embed_hash: hash, views: count}` maps, ordered by views. + """ + def space_top_videos(space_hash, limit \\ 25) + + def space_top_videos(space_hash, limit) + when is_binary(space_hash) and is_integer(limit) and limit > 0 do + with {:ok, space_hash} <- parse_space_hash(space_hash) do + if SharedStorageSpace.hash?(space_hash) do + {:ok, []} + else + do_space_top_videos(space_hash, limit) + end + end + end + + def space_top_videos(_space_hash, _limit), do: {:error, :invalid_space_hash} + + defp do_space_top_videos(space_hash, limit) do + now = DateTime.utc_now() + year_from = DateTime.new!(Date.new!(now.year, 1, 1), ~T[00:00:00], "Etc/UTC") + + db_name = clickhouse_database_name() + threshold = @min_watch_seconds * 1000 + + sql = """ + /* mave_perf:spaces.top_videos */ + WITH ordered AS ( + SELECT + embed_hash, + session_id, + timestamp, + name, + 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 #{db_name}.events + PREWHERE space_hash = #{string_literal(space_hash)} + AND timestamp >= #{datetime_literal(year_from)} + AND timestamp < #{datetime_literal(now)} + WHERE name IN ('play', 'pause') + ), + session_aggregates AS ( + SELECT + embed_hash, + session_id, + sumIf( + greatest(toInt64(0), toUnixTimestamp64Milli(next_ts) - toUnixTimestamp64Milli(timestamp)), + name = 'play' AND next_name = 'pause' + ) AS watch_ms + FROM ordered + GROUP BY embed_hash, session_id + ) + SELECT + embed_hash, + countIf(watch_ms >= #{threshold}) AS views + FROM session_aggregates + GROUP BY embed_hash + HAVING views > 0 + ORDER BY views DESC, embed_hash ASC + LIMIT #{limit} + """ + + query_clickhouse(sql, {:error, :analytics_unavailable}, fn rows -> + {:ok, + Enum.map(rows, fn [embed_hash, views] -> + %{embed_hash: embed_hash, views: to_int(views)} + end)} + end) + end + def space_monthly_watch_time(space_hash) when is_binary(space_hash) do with {:ok, space_hash} <- parse_space_hash(space_hash) do if SharedStorageSpace.hash?(space_hash) do diff --git a/lib/mave_core/embeds.ex b/lib/mave_core/embeds.ex index 94eb515..0398a2e 100644 --- a/lib/mave_core/embeds.ex +++ b/lib/mave_core/embeds.ex @@ -795,6 +795,26 @@ defmodule MaveCore.Embeds do defp maybe_broadcast_embed_updated(result, _payload), do: result + @doc """ + Maps embed hashes of the space's video embeds to their dashboard id and + display name. Deleted embeds and unknown hashes are left out. + """ + @spec dashboard_videos_by_hash(Space.t(), [String.t()]) :: %{ + String.t() => %{id: String.t(), name: String.t()} + } + def dashboard_videos_by_hash(%Space{}, []), do: %{} + + def dashboard_videos_by_hash(%Space{id: space_id}, hashes) when is_list(hashes) do + Embed + |> where([embed], embed.space_id == ^space_id and embed.hash in ^hashes) + |> where([embed], embed.type == :video and is_nil(embed.deleted_at)) + |> preload(asset: [:current_video]) + |> Repo.all() + |> Map.new(fn embed -> + {embed.hash, %{id: dashboard_embed_id(embed), name: embed_name(embed)}} + end) + end + @spec dashboard_embed_id(Embed.t()) :: String.t() def dashboard_embed_id(%Embed{id: id}) when is_binary(id), do: id diff --git a/lib/mave_core_web/dashboard_routes.ex b/lib/mave_core_web/dashboard_routes.ex index b773050..460aaa8 100644 --- a/lib/mave_core_web/dashboard_routes.ex +++ b/lib/mave_core_web/dashboard_routes.ex @@ -23,6 +23,10 @@ defmodule MaveCoreWeb.DashboardRoutes do path_for(space_or_user, "/data", [], opts) end + def data_top_list_path(space_or_user, opts \\ []) do + path_for(space_or_user, "/data/top-list", [], opts) + end + def settings_path(space_or_user, tab \\ nil, opts \\ []) do suffix = case tab do diff --git a/lib/mave_core_web/live/dashboard/data/index.ex b/lib/mave_core_web/live/dashboard/data/index.ex index c64fced..d079462 100644 --- a/lib/mave_core_web/live/dashboard/data/index.ex +++ b/lib/mave_core_web/live/dashboard/data/index.ex @@ -4,8 +4,14 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do use MaveCoreWeb, :live_view import MaveCoreWeb.DashboardComponents, except: [icon: 1] + @top_list_size 25 + # Deleted videos keep their analytics, so query extra rows to fill the list. + @top_list_query_size 50 + alias MaveCore.Analytics + alias MaveCore.Embeds alias MaveCore.UsageLimits + alias MaveCoreWeb.DashboardRoutes alias MaveCoreWeb.Formatter alias Phoenix.LiveView.AsyncResult @@ -14,6 +20,7 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do socket |> assign(:page_title, gettext("Data")) |> assign(:settings_open, false) + |> assign(:route_scope, :unscoped) |> assign(:show_all_devices, false) |> assign(:show_all_browsers, false) |> assign(:data_access_notice, nil) @@ -21,17 +28,16 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do |> assign_async_defaults()} end - def handle_params(_params, _url, socket) do - space_hash = socket.assigns.current_space.hash + def handle_params(params, _url, socket) do + space = socket.assigns.current_space socket = - case UsageLimits.can_view_space_data?(socket.assigns.current_space) do + case UsageLimits.can_view_space_data?(space) do :ok -> socket |> assign(:data_access_notice, nil) |> assign(:data_access_help, nil) - |> assign_async_defaults() - |> start_async(:fetch_stats, fn -> Analytics.space_data(space_hash) end) + |> start_tab_fetch(socket.assigns.live_action, space) {:error, reason} -> socket @@ -40,7 +46,32 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do |> assign_empty_stats() end - {:noreply, socket} + {:noreply, assign(socket, :route_scope, route_scope(params))} + end + + defp start_tab_fetch(socket, :top_list, space) do + socket + |> assign(:top_videos, AsyncResult.loading()) + |> start_async(:fetch_top_videos, fn -> fetch_top_videos(space) end) + end + + defp start_tab_fetch(socket, _overview, space) do + socket + |> assign_async_defaults() + |> start_async(:fetch_stats, fn -> Analytics.space_data(space.hash) end) + end + + defp fetch_top_videos(space) do + with {:ok, rows} <- Analytics.space_top_videos(space.hash, @top_list_query_size) do + videos = Embeds.dashboard_videos_by_hash(space, Enum.map(rows, & &1.embed_hash)) + + top_videos = + for %{embed_hash: hash, views: views} <- rows, + Map.has_key?(videos, hash), + do: Map.put(videos[hash], :views, views) + + {:ok, Enum.take(top_videos, @top_list_size)} + end end def handle_async(:fetch_stats, {:ok, {:ok, result}}, socket) do @@ -72,6 +103,19 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do def handle_async(:fetch_stats, {:exit, _reason}, socket), do: {:noreply, assign_failed(socket)} + 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))} + end + + def handle_async(:fetch_top_videos, _result, socket) do + {:noreply, + assign( + socket, + :top_videos, + AsyncResult.failed(socket.assigns.top_videos, :analytics_unavailable) + )} + end + def handle_event("toggle_show_all_devices", _, socket) do {:noreply, assign(socket, :show_all_devices, !socket.assigns.show_all_devices)} end @@ -83,143 +127,169 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do def render(assigns) do ~H"""
- <.title title={gettext("Data")}> -
-
{@data_access_notice}
- <.info_hover position="left"> - {@data_access_help} - -
- +
+
+ <.menubar> + <.menubar_item + icon="hero-chart-bar-mini" + path={DashboardRoutes.data_path(@current_space, scope: @route_scope)} + active={@live_action != :top_list} + > + {gettext("Overview")} + + <.menubar_item + icon="hero-trophy-mini" + path={DashboardRoutes.data_top_list_path(@current_space, scope: @route_scope)} + active={@live_action == :top_list} + > + {gettext("Top list")} + +
+
+
{@data_access_notice}
+ <.info_hover position="left"> + {@data_access_help} + +
+ +
-
-
- <.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} +
+ <%= if @live_action == :top_list do %> + <.top_list + top_videos={@top_videos} + current_space={@current_space} + route_scope={@route_scope} /> - <.video_metric_card - label={gettext("This year")} - value={metric_value(@views_this_year)} - loading={@views_this_year.loading} - /> -
+ <% else %> +
+ <.subtitle label={gettext("Numbers")} icon="hero-hashtag" /> +
- <.divider /> +
+ <.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} + /> +
-
- <.subtitle label={gettext("History")} icon="hero-chart-bar" /> -
+ <.divider /> - <.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 %> -
-
@@ -229,6 +299,74 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do """ end + attr :top_videos, AsyncResult, required: true + attr :current_space, :any, required: true + attr :route_scope, :atom, required: true + + defp top_list(assigns) do + ~H""" +
+ <.subtitle label={gettext("Most viewed this year")} icon="hero-trophy" /> +
+ + <.async_result :let={top_videos} assign={@top_videos}> + <:loading> +
+ <.video_data_section> + <%= for width <- [46, 38, 52, 30, 42, 35, 48, 33] do %> +
+ +
+ +
+ +
+ <% end %> + {gettext("Loading data")} + +
+ + + <:failed> + <.video_data_section> +
+ {gettext("Data is unavailable")} +
+ + + + <.video_data_section> +
+ <.icon name="hero-trophy" class="h-6 w-6 mb-2 opacity-50" /> +
{gettext("No data yet")}
+
+
+ <.link + :for={{video, rank} <- Enum.with_index(top_videos, 1)} + navigate={DashboardRoutes.video_path(@current_space, video.id, :all, scope: @route_scope)} + class="flex items-center gap-4 px-3 py-2.5 border-b border-stone-100 last:border-none hover:bg-stone-50 active:bg-blue-50" + > +
{rank}
+
{video.name}
+
+ {format_number(video.views)} +
+ +
+ + + """ + end + attr :title, :string, required: true attr :assign, :any, required: true attr :rows, :list, required: true @@ -335,6 +473,11 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do """ end + defp route_scope(%{"space_id" => space_id}) when is_binary(space_id) and space_id != "", + do: :scoped + + defp route_scope(_params), do: :unscoped + defp metric_value(%AsyncResult{loading: true}), do: "..." defp metric_value(%AsyncResult{ok?: true, result: result}) when is_integer(result), @@ -361,6 +504,7 @@ 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()) @@ -379,6 +523,7 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do |> 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(:top_videos, AsyncResult.ok(AsyncResult.loading(), [])) |> assign(:show_all_devices, false) |> assign(:show_all_browsers, false) end diff --git a/lib/mave_core_web/router.ex b/lib/mave_core_web/router.ex index 0487296..964b865 100644 --- a/lib/mave_core_web/router.ex +++ b/lib/mave_core_web/router.ex @@ -258,6 +258,7 @@ defmodule MaveCoreWeb.Router do live "/videos", Dashboard.Videos.Index, :index live "/videos/:id", Dashboard.Videos.Show, :show live "/data", Dashboard.Data.Index, :index + live "/data/top-list", Dashboard.Data.Index, :top_list live "/settings", Dashboard.Settings.Index, :general live "/settings/developer", Dashboard.Settings.Index, :developer live "/settings/team", Dashboard.Settings.Index, :team @@ -287,6 +288,7 @@ defmodule MaveCoreWeb.Router do live "/:space_id/videos", Dashboard.Videos.Index, :index live "/:space_id/videos/:id", Dashboard.Videos.Show, :show live "/:space_id/data", Dashboard.Data.Index, :index + live "/:space_id/data/top-list", Dashboard.Data.Index, :top_list live "/:space_id/settings", Dashboard.Settings.Index, :general live "/:space_id/settings/developer", Dashboard.Settings.Index, :developer live "/:space_id/settings/team", Dashboard.Settings.Index, :team 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 5999d7c..c2a4de2 100644 --- a/test/mave_core_web/live/dashboard/data/index_test.exs +++ b/test/mave_core_web/live/dashboard/data/index_test.exs @@ -5,6 +5,8 @@ defmodule MaveCoreWeb.Live.Dashboard.Data.IndexTest do alias MaveCore.Accounts alias MaveCore.ClickHouseRepo + alias MaveCore.Embeds + alias MaveCore.Repo alias MaveCoreWeb.Dashboard.Data.Index alias Phoenix.LiveView.AsyncResult @@ -52,6 +54,9 @@ defmodule MaveCoreWeb.Live.Dashboard.Data.IndexTest do test "/data formats analytics counts with dot thousands separators" do html = %{ + live_action: :index, + route_scope: :unscoped, + current_space: %{id: "space-id"}, data_access_notice: nil, data_access_help: nil, views_today: async_ok(12_345), @@ -85,6 +90,9 @@ defmodule MaveCoreWeb.Live.Dashboard.Data.IndexTest do html = %{ + live_action: :index, + route_scope: :unscoped, + current_space: %{id: "space-id"}, data_access_notice: nil, data_access_help: nil, views_today: loading, @@ -109,6 +117,69 @@ defmodule MaveCoreWeb.Live.Dashboard.Data.IndexTest do refute html =~ "..." end + test "/data shows overview and top list tabs", %{conn: conn} do + {conn, _space} = authenticated_conn(conn) + + {:ok, view, html} = live(conn, "/data") + + assert html =~ "Overview" + assert html =~ "Top list" + assert has_element?(view, ~s(a[href="/data/top-list"]), "Top list") + + view |> element(~s(a[href="/data/top-list"])) |> render_click() + assert_patch(view, "/data/top-list") + end + + test "/data/top-list ranks the space's videos by views", %{conn: conn, db_name: db_name} do + {conn, space} = authenticated_conn(conn) + {:ok, popular} = Embeds.create_video_embed(space) + {:ok, quiet} = Embeds.create_video_embed(space) + {:ok, deleted} = Embeds.create_video_embed(space) + {:ok, _deleted} = Repo.update(Ecto.Changeset.change(deleted, deleted_at: DateTime.utc_now())) + + rows = + [ + {popular.hash, 1}, + {popular.hash, 2}, + {quiet.hash, 3}, + {deleted.hash, 4}, + {deleted.hash, 5}, + {deleted.hash, 6} + ] + |> Enum.map_join(",\n", fn {embed_hash, session} -> + session_id = "00000000-0000-0000-0000-00000000200#{session}" + + """ + (addSeconds(now64(6), -30), 'play', '#{session_id}', '#{space.hash}', '#{embed_hash}', 0.0, 10, 'https://example.com', 'Chrome', '100', 'Mac', '14', 'Desktop', 'Apple'), + (addSeconds(now64(6), -27), 'pause', '#{session_id}', '#{space.hash}', '#{embed_hash}', 3.0, 10, 'https://example.com', 'Chrome', '100', 'Mac', '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 + #{rows} + """) + + {:ok, view, _html} = live(conn, "/data/top-list") + _ = render_async(view, 5_000) + + links = + view + |> render() + |> LazyHTML.from_fragment() + |> LazyHTML.query("#data-top-list a") + |> Enum.map(&LazyHTML.attribute(&1, "href")) + |> List.flatten() + + assert links == [ + "/videos/#{Embeds.dashboard_embed_id(popular)}", + "/videos/#{Embeds.dashboard_embed_id(quiet)}" + ] + end + defp authenticated_conn(conn, space_hash \\ nil) do email = "data-index-#{System.unique_integer([:positive])}@example.com" {:ok, user} = Accounts.create_user(email)