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? %> + +
+| <%= t(".table_header.episodes") %> | -<%= t(".table_header.published_at") %> | -<%= t(".table_header.downloads") %> | -<%= t(".table_header.all_time") %> | -
| - - - <%= 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 %> | -
<%= local_date(episode.published_at, format: :short) %>
+<%= local_date(ep.published_at, format: :short) %>
-<%= t(".no_published") %>
<% end %> <% else %> -<%= t(".no_published") %>
+ <% if @recently_published.present? %> + <% @recently_published.each do |ep| %> +<%= local_date(ep.published_at, format: :short) %>
+<%= t(".no_published") %>
+ <% end %> <% end %>