diff --git a/app/controllers/concerns/metrics_utils.rb b/app/controllers/concerns/metrics_utils.rb index 38b85c21c..6564e626d 100644 --- a/app/controllers/concerns/metrics_utils.rb +++ b/app/controllers/concerns/metrics_utils.rb @@ -6,11 +6,22 @@ module MetricsUtils def check_clickhouse unless clickhouse_connected? render partial: "metrics/error_card", locals: { - metrics_path: params[:action] + metrics_path: params[:action], + card_type: card_type(params[:action].to_sym) } end end + def card_type(action) + blank_type = %i[episode_sparkline] + + if blank_type.include?(action) + "blank" + else + "error" + end + end + def generate_date_range(date_start, date_end, interval) start_range = date_start.to_datetime.utc.send(:"beginning_of_#{interval.downcase}") end_range = date_end.to_datetime.utc.send(:"beginning_of_#{interval.downcase}") diff --git a/app/controllers/podcast_metrics_controller.rb b/app/controllers/podcast_metrics_controller.rb index be5be971f..cce288a7d 100644 --- a/app/controllers/podcast_metrics_controller.rb +++ b/app/controllers/podcast_metrics_controller.rb @@ -2,14 +2,33 @@ class PodcastMetricsController < ApplicationController include MetricsUtils before_action :set_podcast - before_action :check_clickhouse, except: %i[show] - before_action :set_date_range, except: %i[dropdays] - before_action :set_uniques, only: %i[show uniques] - before_action :set_dropday_range, only: %i[show dropdays] + # before_action :check_clickhouse, except: %i[show] def show end + def episode_sparkline + @episode = Episode.find_by(guid: metrics_params[:episode_id]) + @prev_episode = Episode.find_by(guid: metrics_params[:prev_episode_id]) + + @episode_trend = calculate_episode_trend(@episode, @prev_episode) + + @sparkline_downloads = + Rollups::HourlyDownload + .where(episode_id: @episode[:guid], hour: (publish_hour(@episode)..publish_hour(@episode) + 6.months)) + .final + .select(:episode_id, "DATE_TRUNC('DAY', hour) AS hour", "SUM(count) AS count") + .group(:episode_id, "DATE_TRUNC('DAY', hour) AS hour") + .order(Arel.sql("DATE_TRUNC('DAY', hour) ASC")) + .load_async + + render partial: "metrics/episode_sparkline", locals: { + episode: @episode, + downloads: @sparkline_downloads, + episode_trend: @episode_trend + } + end + def downloads @downloads_within_date_range = Rollups::HourlyDownload @@ -195,46 +214,39 @@ def set_podcast render_not_found(e) end - def set_date_range - @date_start = metrics_params[:date_start] - @date_end = metrics_params[:date_end] - @interval = metrics_params[:interval] - @date_range = generate_date_range(@date_start, @date_end, @interval) + def metrics_params + params + .permit(:podcast_id, :episode_id, :prev_episode_id) end - def set_uniques - @uniques_selection = uniques_params[:uniques_selection] - end + def calculate_episode_trend(episode, prev_episode) + return nil unless episode.first_rss_published_at.present? && prev_episode.present? + return nil if (episode.first_rss_published_at + 1.day) > Time.now - def set_dropday_range - @dropday_range = dropdays_params[:dropday_range] - end + ep_dropday_sum = episode_dropday_query(episode) + previous_ep_dropday_sum = episode_dropday_query(prev_episode) - def metrics_params - params - .permit(:podcast_id, :date_start, :date_end, :interval) - .with_defaults( - date_start: 30.days.ago.utc_date, - date_end: Date.utc_today, - interval: "DAY" - ) + return nil if ep_dropday_sum <= 0 || previous_ep_dropday_sum <= 0 + + ((ep_dropday_sum.to_f / previous_ep_dropday_sum.to_f) - 1).round(3) end - def uniques_params - params - .permit(:uniques_selection) - .with_defaults( - uniques_selection: "last_7_rolling" - ) - .merge(metrics_params) + def episode_dropday_query(ep) + lowerbound = publish_hour(ep) + upperbound = lowerbound + 24.hours + + Rollups::HourlyDownload + .where(episode_id: ep[:guid], hour: (lowerbound...upperbound)) + .final + .load_async + .sum(:count) end - def dropdays_params - params - .permit(:dropday_range, :interval) - .with_defaults( - dropday_range: 7 - ) - .merge(metrics_params) + def publish_hour(episode) + if episode.first_rss_published_at.present? + episode.first_rss_published_at.beginning_of_hour + else + episode.published_at.beginning_of_hour + end end end diff --git a/app/controllers/podcasts_controller.rb b/app/controllers/podcasts_controller.rb index ce42e0c0b..030b0c073 100644 --- a/app/controllers/podcasts_controller.rb +++ b/app/controllers/podcasts_controller.rb @@ -17,7 +17,12 @@ def index def show authorize @podcast - @recently_published = @podcast.episodes.published.dropdate_desc.limit(3) + @recently_published_episodes = @podcast.episodes.published.dropdate_desc.limit(4) + @trend_episodes = @podcast.default_feed.episodes.published.dropdate_desc.where.not(first_rss_published_at: nil).offset(1).limit(4) + @episode_trend_pairs = episode_trend_pairs(@recently_published_episodes, @trend_episodes) + + # @recently_published is used for the prod branch + @recently_published = @recently_published_episodes[0..2] @next_scheduled = @podcast.episodes.draft_or_scheduled.dropdate_asc.limit(3) @metrics_jwt = prx_jwt @@ -189,4 +194,24 @@ def compose_message(podcast) def sub_escapes(text) text.gsub(/[&<>]/, "&" => "&", "<" => "<", ">" => ">") end + + def episode_trend_pairs(episodes, trend_episodes) + paired_trend_episodes = [] + + episodes.map.with_index do |ep, i| + if ep.in_default_feed? + paired_trend_episodes << trend_episodes[paired_trend_episodes.length] + + { + episode: ep, + prev_episode: paired_trend_episodes.last + } + else + { + episode: ep, + prev_episode: nil + } + end + end + end end diff --git a/app/helpers/metrics_helper.rb b/app/helpers/metrics_helper.rb index 89f499a95..6fb22a364 100644 --- a/app/helpers/metrics_helper.rb +++ b/app/helpers/metrics_helper.rb @@ -12,6 +12,40 @@ def sum_rollups(rollups) rollups.sum(&:count) end + def parse_trend(trend) + return if trend.blank? + + if trend > 0 + { + percent: "+#{trend * 100}%", + color: modified_trend_color(trend, "text-success"), + direction: modified_trend_direction(trend, "up") + } + elsif trend < 0 + { + percent: "-#{trend * -100}%", + color: modified_trend_color(trend, "text-danger"), + direction: modified_trend_direction(trend, "down") + } + end + end + + def modified_trend_direction(trend, direction) + if trend.abs > 0.05 + "trending_#{direction}" + else + "trending_flat" + end + end + + def modified_trend_color(trend, color) + if trend.abs > 0.05 + color + else + "text-secondary" + end + end + def interval_options Rollups::HourlyDownload::INTERVALS.map { |i| [I18n.t(".helpers.label.metrics.interval.#{i.downcase}"), i] } end diff --git a/app/javascript/controllers/apex_downloads_controller.js b/app/javascript/controllers/apex_downloads_controller.js deleted file mode 100644 index de97863aa..000000000 --- a/app/javascript/controllers/apex_downloads_controller.js +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import { buildDateTimeChart, buildDownloadsSeries, dynamicBarAndAreaType } from "util/apex" - -export default class extends Controller { - static values = { - id: String, - downloads: Object, - dateRange: Array, - interval: String, - } - - static targets = ["chart"] - - connect() { - const series = buildDownloadsSeries(this.downloadsValue, this.dateRangeValue) - const title = `Downloads by ${this.intervalValue.toLowerCase()}` - const chart = buildDateTimeChart( - this.idValue, - series, - this.chartTarget, - dynamicBarAndAreaType(this.dateRangeValue), - title - ) - - chart.render() - } -} diff --git a/app/javascript/controllers/apex_sparkline_controller.js b/app/javascript/controllers/apex_sparkline_controller.js new file mode 100644 index 000000000..85f111b2e --- /dev/null +++ b/app/javascript/controllers/apex_sparkline_controller.js @@ -0,0 +1,34 @@ +import { Controller } from "@hotwired/stimulus" +import { buildSparklineChart, destroyChart } from "util/apex" + +export default class extends Controller { + static values = { + id: String, + downloads: Array, + } + + static targets = ["chart"] + + connect() { + const seriesData = this.downloadsValue.map((rollup) => { + return { + x: rollup.hour, + y: rollup.count, + } + }) + const series = [ + { + name: "Downloads", + data: seriesData, + }, + ] + + const chart = buildSparklineChart(this.idValue, series, this.chartTarget) + + chart.render() + } + + disconnect() { + destroyChart(this.idValue) + } +} diff --git a/app/javascript/util/apex.js b/app/javascript/util/apex.js index 9bb483245..5e8ac0acf 100644 --- a/app/javascript/util/apex.js +++ b/app/javascript/util/apex.js @@ -105,6 +105,48 @@ export const AREA_TYPE = { }, } +export const SPARKLINE_TYPE = { + chart: { + height: "100%", + sparkline: { + enabled: true, + }, + animations: { + enabled: true, + }, + type: "area", + stacked: false, + }, + options: { + xaxis: { + type: "datetime", + }, + tooltip: { + enabled: false, + }, + fill: { + colors: ["#F3EAFF"], + type: "gradient", + gradient: { + type: "diagonal1", + gradientToColors: ["#DBFBFB"], + opacityFrom: 1, + opacityTo: 1, + }, + }, + stroke: { + width: 1, + colors: ["#00000000"], + }, + }, +} + +export function buildSparklineChart(id, series, target) { + const options = Object.assign({ series: series }, DEFAULT_OPTIONS, SPARKLINE_TYPE.options) + Object.assign(options.chart, { id: id }, SPARKLINE_TYPE.chart) + return new ApexCharts(target, options) +} + export function buildDateTimeChart(id, series, target, type, title = "") { const options = Object.assign({ series: series }, DEFAULT_OPTIONS, DATETIME_OPTIONS, type.options) Object.assign(options.chart, { id: id }, type.chart) @@ -176,3 +218,7 @@ function addYaxisTitle(yaxis, title = "") { function addXaxisTitle(xaxis, title = "") { Object.assign(xaxis, { title: { text: title } }) } + +export function destroyChart(chartId) { + ApexCharts.exec(chartId, "destroy") +} diff --git a/app/views/metrics/_episode_sparkline.html.erb b/app/views/metrics/_episode_sparkline.html.erb new file mode 100644 index 000000000..2a17d8e85 --- /dev/null +++ b/app/views/metrics/_episode_sparkline.html.erb @@ -0,0 +1,14 @@ +<%= turbo_frame_tag "episode_sparkline" do %> + <% trend = parse_trend(episode_trend) %> + +

+ <%= trend[:percent] if trend.present? %> + +

+
+
+
+<% end %> diff --git a/app/views/metrics/_episodes_card.html.erb b/app/views/metrics/_episodes_card.html.erb index 437644bc2..790142215 100644 --- a/app/views/metrics/_episodes_card.html.erb +++ b/app/views/metrics/_episodes_card.html.erb @@ -1,50 +1,8 @@ -<% chart_id = SecureRandom.uuid %> - -<%= turbo_frame_tag "episodes" do %> - <%= form_with(url: url, method: :get, id: form_id, data: {controller: "click", click_debounce_value: 200}) do |form| %> -
-
-
-
-
- - - - - - - - - - - <% episode_rollups.each_with_index do |er, i| %> - <% sum = sum_rollups(er[:rollups]) %> - <% total = sum_rollups(er[:totals]) %> - <% title = er[:episode][:title] %> - - - - - - - <% end %> - -
<%= t(".table_header.episodes") %><%= t(".table_header.published_at") %><%= t(".table_header.downloads") %><%= t(".table_header.all_time") %>
- - circle - <%= link_to title, episode_metrics_path(episode_id: er[:episode][:guid], date_start: date_start, date_end: date_end, interval: interval), data: {turbo_frame: "_top"} %> - <%= er[:episode][:published_at].strftime("%Y-%m-%d") %><% if sum %><%= sum %><% else %>—<% end %><% if total %><%= total %><% else %>—<% end %>
-
-
- - <%= form.date_field :date_start, class: "d-none", data: {apex_nav_target: "startDate", action: "change->click#submit"}, value: date_start %> - <%= form.date_field :date_end, class: "d-none", data: {apex_nav_target: "endDate", action: "change->click#submit"}, value: date_end %> - <%= form.select :interval, interval_options, {selected: interval}, class: "d-none", data: {apex_nav_target: "interval", action: "change->click#submit", path: "episodes"} %> - <%= form.submit class: "d-none", data: {click_target: "submit"} %> +
+

<%= local_date(episode.published_at, format: :short) %>

+
+

<%= link_to episode.title, episode_path(episode) %>

+
+ <%= turbo_frame_tag "episode_sparkline", src: episode_sparkline_podcast_metrics_path(podcast_id: podcast.id, episode_id: episode.guid, prev_episode_id: prev_episode.guid), loading: "lazy" do %> <% end %> -<% end %> +
diff --git a/app/views/metrics/_error_card.html.erb b/app/views/metrics/_error_card.html.erb index 4756f7ddb..9f2430792 100644 --- a/app/views/metrics/_error_card.html.erb +++ b/app/views/metrics/_error_card.html.erb @@ -1,5 +1,8 @@ -<%= turbo_frame_tag metrics_path do %> -
- Clickhouse not connected -
+<% if card_type == "error" %> + <%= turbo_frame_tag metrics_path do %> +
+ Clickhouse not connected +
+ <% end %> +<% elsif card_type == "blank" %> <% end %> diff --git a/app/views/podcasts/show.html.erb b/app/views/podcasts/show.html.erb index 81c535ee6..7ff996645 100644 --- a/app/views/podcasts/show.html.erb +++ b/app/views/podcasts/show.html.erb @@ -41,17 +41,27 @@ <% end %>
- <% if @recently_published.present? %> - <% @recently_published.each do |ep| %> -
-

<%= local_date(ep.published_at, format: :short) %>

-
-

<%= link_to ep.title, episode_path(ep) %>

-
-
+ <% if Rails.env.development? %> + <% if @episode_trend_pairs.present? %> + <% @episode_trend_pairs.each do |pair| %> + <%= render "metrics/episodes_card", podcast: @podcast, episode: pair[:episode], prev_episode: pair[:prev_episode] %> + <% end %> + <% else %> +

<%= t(".no_published") %>

<% end %> <% else %> -

<%= t(".no_published") %>

+ <% if @recently_published.present? %> + <% @recently_published.each do |ep| %> +
+

<%= local_date(ep.published_at, format: :short) %>

+
+

<%= link_to ep.title, episode_path(ep) %>

+
+
+ <% end %> + <% else %> +

<%= t(".no_published") %>

+ <% end %> <% end %>
diff --git a/config/routes.rb b/config/routes.rb index 599d59c1c..fd2ff600b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,7 @@ resources :placements_preview, only: [:show] get "rollups_demo", to: "podcasts#rollups_demo" resource :metrics, only: [:show], controller: :podcast_metrics do + get "episode_sparkline" get "downloads" get "uniques" get "episodes"