Skip to content

Commit cc30a2f

Browse files
authored
Merge pull request #1386 from PRX/feat/episode_sparklines
Feat/episode sparklines
2 parents 0d45114 + 84330c3 commit cc30a2f

File tree

12 files changed

+249
-128
lines changed

12 files changed

+249
-128
lines changed

app/controllers/concerns/metrics_utils.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,22 @@ module MetricsUtils
66
def check_clickhouse
77
unless clickhouse_connected?
88
render partial: "metrics/error_card", locals: {
9-
metrics_path: params[:action]
9+
metrics_path: params[:action],
10+
card_type: card_type(params[:action].to_sym)
1011
}
1112
end
1213
end
1314

15+
def card_type(action)
16+
blank_type = %i[episode_sparkline]
17+
18+
if blank_type.include?(action)
19+
"blank"
20+
else
21+
"error"
22+
end
23+
end
24+
1425
def generate_date_range(date_start, date_end, interval)
1526
start_range = date_start.to_datetime.utc.send(:"beginning_of_#{interval.downcase}")
1627
end_range = date_end.to_datetime.utc.send(:"beginning_of_#{interval.downcase}")

app/controllers/podcast_metrics_controller.rb

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,33 @@ class PodcastMetricsController < ApplicationController
22
include MetricsUtils
33

44
before_action :set_podcast
5-
before_action :check_clickhouse, except: %i[show]
6-
before_action :set_date_range, except: %i[dropdays]
7-
before_action :set_uniques, only: %i[show uniques]
8-
before_action :set_dropday_range, only: %i[show dropdays]
5+
# before_action :check_clickhouse, except: %i[show]
96

107
def show
118
end
129

10+
def episode_sparkline
11+
@episode = Episode.find_by(guid: metrics_params[:episode_id])
12+
@prev_episode = Episode.find_by(guid: metrics_params[:prev_episode_id])
13+
14+
@episode_trend = calculate_episode_trend(@episode, @prev_episode)
15+
16+
@sparkline_downloads =
17+
Rollups::HourlyDownload
18+
.where(episode_id: @episode[:guid], hour: (publish_hour(@episode)..publish_hour(@episode) + 6.months))
19+
.final
20+
.select(:episode_id, "DATE_TRUNC('DAY', hour) AS hour", "SUM(count) AS count")
21+
.group(:episode_id, "DATE_TRUNC('DAY', hour) AS hour")
22+
.order(Arel.sql("DATE_TRUNC('DAY', hour) ASC"))
23+
.load_async
24+
25+
render partial: "metrics/episode_sparkline", locals: {
26+
episode: @episode,
27+
downloads: @sparkline_downloads,
28+
episode_trend: @episode_trend
29+
}
30+
end
31+
1332
def downloads
1433
@downloads_within_date_range =
1534
Rollups::HourlyDownload
@@ -195,46 +214,39 @@ def set_podcast
195214
render_not_found(e)
196215
end
197216

198-
def set_date_range
199-
@date_start = metrics_params[:date_start]
200-
@date_end = metrics_params[:date_end]
201-
@interval = metrics_params[:interval]
202-
@date_range = generate_date_range(@date_start, @date_end, @interval)
217+
def metrics_params
218+
params
219+
.permit(:podcast_id, :episode_id, :prev_episode_id)
203220
end
204221

205-
def set_uniques
206-
@uniques_selection = uniques_params[:uniques_selection]
207-
end
222+
def calculate_episode_trend(episode, prev_episode)
223+
return nil unless episode.first_rss_published_at.present? && prev_episode.present?
224+
return nil if (episode.first_rss_published_at + 1.day) > Time.now
208225

209-
def set_dropday_range
210-
@dropday_range = dropdays_params[:dropday_range]
211-
end
226+
ep_dropday_sum = episode_dropday_query(episode)
227+
previous_ep_dropday_sum = episode_dropday_query(prev_episode)
212228

213-
def metrics_params
214-
params
215-
.permit(:podcast_id, :date_start, :date_end, :interval)
216-
.with_defaults(
217-
date_start: 30.days.ago.utc_date,
218-
date_end: Date.utc_today,
219-
interval: "DAY"
220-
)
229+
return nil if ep_dropday_sum <= 0 || previous_ep_dropday_sum <= 0
230+
231+
((ep_dropday_sum.to_f / previous_ep_dropday_sum.to_f) - 1).round(3)
221232
end
222233

223-
def uniques_params
224-
params
225-
.permit(:uniques_selection)
226-
.with_defaults(
227-
uniques_selection: "last_7_rolling"
228-
)
229-
.merge(metrics_params)
234+
def episode_dropday_query(ep)
235+
lowerbound = publish_hour(ep)
236+
upperbound = lowerbound + 24.hours
237+
238+
Rollups::HourlyDownload
239+
.where(episode_id: ep[:guid], hour: (lowerbound...upperbound))
240+
.final
241+
.load_async
242+
.sum(:count)
230243
end
231244

232-
def dropdays_params
233-
params
234-
.permit(:dropday_range, :interval)
235-
.with_defaults(
236-
dropday_range: 7
237-
)
238-
.merge(metrics_params)
245+
def publish_hour(episode)
246+
if episode.first_rss_published_at.present?
247+
episode.first_rss_published_at.beginning_of_hour
248+
else
249+
episode.published_at.beginning_of_hour
250+
end
239251
end
240252
end

app/controllers/podcasts_controller.rb

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ def index
1717
def show
1818
authorize @podcast
1919

20-
@recently_published = @podcast.episodes.published.dropdate_desc.limit(3)
20+
@recently_published_episodes = @podcast.episodes.published.dropdate_desc.limit(4)
21+
@trend_episodes = @podcast.default_feed.episodes.published.dropdate_desc.where.not(first_rss_published_at: nil).offset(1).limit(4)
22+
@episode_trend_pairs = episode_trend_pairs(@recently_published_episodes, @trend_episodes)
23+
24+
# @recently_published is used for the prod branch
25+
@recently_published = @recently_published_episodes[0..2]
2126
@next_scheduled = @podcast.episodes.draft_or_scheduled.dropdate_asc.limit(3)
2227

2328
@metrics_jwt = prx_jwt
@@ -189,4 +194,24 @@ def compose_message(podcast)
189194
def sub_escapes(text)
190195
text.gsub(/[&<>]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;")
191196
end
197+
198+
def episode_trend_pairs(episodes, trend_episodes)
199+
paired_trend_episodes = []
200+
201+
episodes.map.with_index do |ep, i|
202+
if ep.in_default_feed?
203+
paired_trend_episodes << trend_episodes[paired_trend_episodes.length]
204+
205+
{
206+
episode: ep,
207+
prev_episode: paired_trend_episodes.last
208+
}
209+
else
210+
{
211+
episode: ep,
212+
prev_episode: nil
213+
}
214+
end
215+
end
216+
end
192217
end

app/helpers/metrics_helper.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,40 @@ def sum_rollups(rollups)
1212
rollups.sum(&:count)
1313
end
1414

15+
def parse_trend(trend)
16+
return if trend.blank?
17+
18+
if trend > 0
19+
{
20+
percent: "+#{trend * 100}%",
21+
color: modified_trend_color(trend, "text-success"),
22+
direction: modified_trend_direction(trend, "up")
23+
}
24+
elsif trend < 0
25+
{
26+
percent: "-#{trend * -100}%",
27+
color: modified_trend_color(trend, "text-danger"),
28+
direction: modified_trend_direction(trend, "down")
29+
}
30+
end
31+
end
32+
33+
def modified_trend_direction(trend, direction)
34+
if trend.abs > 0.05
35+
"trending_#{direction}"
36+
else
37+
"trending_flat"
38+
end
39+
end
40+
41+
def modified_trend_color(trend, color)
42+
if trend.abs > 0.05
43+
color
44+
else
45+
"text-secondary"
46+
end
47+
end
48+
1549
def interval_options
1650
Rollups::HourlyDownload::INTERVALS.map { |i| [I18n.t(".helpers.label.metrics.interval.#{i.downcase}"), i] }
1751
end

app/javascript/controllers/apex_downloads_controller.js

Lines changed: 0 additions & 27 deletions
This file was deleted.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
import { buildSparklineChart, destroyChart } from "util/apex"
3+
4+
export default class extends Controller {
5+
static values = {
6+
id: String,
7+
downloads: Array,
8+
}
9+
10+
static targets = ["chart"]
11+
12+
connect() {
13+
const seriesData = this.downloadsValue.map((rollup) => {
14+
return {
15+
x: rollup.hour,
16+
y: rollup.count,
17+
}
18+
})
19+
const series = [
20+
{
21+
name: "Downloads",
22+
data: seriesData,
23+
},
24+
]
25+
26+
const chart = buildSparklineChart(this.idValue, series, this.chartTarget)
27+
28+
chart.render()
29+
}
30+
31+
disconnect() {
32+
destroyChart(this.idValue)
33+
}
34+
}

app/javascript/util/apex.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,48 @@ export const AREA_TYPE = {
105105
},
106106
}
107107

108+
export const SPARKLINE_TYPE = {
109+
chart: {
110+
height: "100%",
111+
sparkline: {
112+
enabled: true,
113+
},
114+
animations: {
115+
enabled: true,
116+
},
117+
type: "area",
118+
stacked: false,
119+
},
120+
options: {
121+
xaxis: {
122+
type: "datetime",
123+
},
124+
tooltip: {
125+
enabled: false,
126+
},
127+
fill: {
128+
colors: ["#F3EAFF"],
129+
type: "gradient",
130+
gradient: {
131+
type: "diagonal1",
132+
gradientToColors: ["#DBFBFB"],
133+
opacityFrom: 1,
134+
opacityTo: 1,
135+
},
136+
},
137+
stroke: {
138+
width: 1,
139+
colors: ["#00000000"],
140+
},
141+
},
142+
}
143+
144+
export function buildSparklineChart(id, series, target) {
145+
const options = Object.assign({ series: series }, DEFAULT_OPTIONS, SPARKLINE_TYPE.options)
146+
Object.assign(options.chart, { id: id }, SPARKLINE_TYPE.chart)
147+
return new ApexCharts(target, options)
148+
}
149+
108150
export function buildDateTimeChart(id, series, target, type, title = "") {
109151
const options = Object.assign({ series: series }, DEFAULT_OPTIONS, DATETIME_OPTIONS, type.options)
110152
Object.assign(options.chart, { id: id }, type.chart)
@@ -176,3 +218,7 @@ function addYaxisTitle(yaxis, title = "") {
176218
function addXaxisTitle(xaxis, title = "") {
177219
Object.assign(xaxis, { title: { text: title } })
178220
}
221+
222+
export function destroyChart(chartId) {
223+
ApexCharts.exec(chartId, "destroy")
224+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<%= turbo_frame_tag "episode_sparkline" do %>
2+
<% trend = parse_trend(episode_trend) %>
3+
4+
<p class="d-flex align-items-center <%= trend[:color] if trend.present? %>">
5+
<span class="z-1"><%= trend[:percent] if trend.present? %></span>
6+
<span class="material-icons ms-1 z-1" aria-hidden="true"><%= trend[:direction] if trend.present? %></span>
7+
</p>
8+
<div class="position-absolute top-50 start-50 translate-middle w-100 h-100 z-0"
9+
data-controller="apex-sparkline"
10+
data-apex-sparkline-id-value="<%= episode.guid %>"
11+
data-apex-sparkline-downloads-value="<%= downloads.to_json %>">
12+
<div data-apex-sparkline-target="chart"></div>
13+
</div>
14+
<% end %>

0 commit comments

Comments
 (0)