Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion app/controllers/concerns/metrics_utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
78 changes: 41 additions & 37 deletions app/controllers/podcast_metrics_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,32 @@ class PodcastMetricsController < ApplicationController

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]

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])

@alltime_downloads =
Rollups::HourlyDownload
.where(episode_id: @episode[:guid], hour: (@episode.first_rss_published_at..Date.utc_today))
.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

@episode_trend = calculate_episode_trend(@episode, @prev_episode)

render partial: "metrics/episode_sparkline", locals: {
episode: @episode,
downloads: @alltime_downloads,
episode_trend: @episode_trend
}
end

def downloads
@downloads_within_date_range =
Rollups::HourlyDownload
Expand Down Expand Up @@ -195,46 +214,31 @@ 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 if (episode.first_rss_published_at + 1.day) > Date.utc_today
return nil unless episode.present? && prev_episode.present?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 guards here: first, don't calculate a trend if the episode just dropped and it hasn't been 24 hours. second, don't calculate a trend if there isn't a previous episode to calculate from.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be Time.now, not Date.utc_today?

If it's first_rss_published_at is 12/2 9:18 UTC... are we waiting until 12/3 00:00 UTC or 12/3 9:18 UTC to show it?


def set_dropday_range
@dropday_range = dropdays_params[:dropday_range]
end
dropday_downloads = episode_dropday_query(episode)
previous_ep_dropday = 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"
)
end
ep_sum = dropday_downloads.sum(&:count)
prev_ep_sum = previous_ep_dropday.sum(&:count)

def uniques_params
params
.permit(:uniques_selection)
.with_defaults(
uniques_selection: "last_7_rolling"
)
.merge(metrics_params)
((ep_sum.to_f / prev_ep_sum.to_f) - 1).round(2)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct math/thinking? the staging data numbers seem a bit outlandish (> 10% changes) because of how extreme the differences seem, but i'm not sure if this looks tamer in prod.

Copy link
Contributor Author

@radical-ube radical-ube Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2025-11-25 at 3 43 42 PM

i think i'm still missing some guards to prevent weird edge cases like this. maybe something around the case where one of the dropday queries doesn't return anything?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a chance we can load this against a prod db? This way we might be able to see the edge cases a little easier.

end

def dropdays_params
params
.permit(:dropday_range, :interval)
.with_defaults(
dropday_range: 7
)
.merge(metrics_params)
def episode_dropday_query(ep)
Rollups::HourlyDownload
.where(episode_id: ep[:guid], hour: (ep.first_rss_published_at..(ep.first_rss_published_at + 1.day)))
.final
.select(:episode_id, "DATE_TRUNC('HOUR', hour) AS hour", "SUM(count) AS count")
.group(:episode_id, "DATE_TRUNC('HOUR', hour) AS hour")
.order(Arel.sql("DATE_TRUNC('HOUR', hour) ASC"))
.load_async
end
end
16 changes: 15 additions & 1 deletion app/controllers/podcasts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ def index
def show
authorize @podcast

@recently_published = @podcast.episodes.published.dropdate_desc.limit(3)
@recently_published_episodes = @podcast.episodes.published.dropdate_desc.limit(5)
@episode_trend_pairs = episode_trend_pairs(@recently_published_episodes)
@recently_published = @recently_published_episodes[0..2]
@next_scheduled = @podcast.episodes.draft_or_scheduled.dropdate_asc.limit(3)

@metrics_jwt = prx_jwt
Expand Down Expand Up @@ -189,4 +191,16 @@ def compose_message(podcast)
def sub_escapes(text)
text.gsub(/[&<>]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;")
end

def episode_trend_pairs(episodes)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trying not to do extra querying in podcast_metrics_controller so i pair episodes with their previous episode here.

pairs = []
episodes.each_with_index do |ep, i|
pairs << {
episode: ep,
prev_episode: episodes[i + 1]
}
end

pairs.slice(0, pairs.length - 1)
end
end
34 changes: 34 additions & 0 deletions app/helpers/metrics_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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).round(2)}%",
color: modified_trend_color(trend, "text-success"),
direction: modified_trend_direction(trend, "up")
}
elsif trend < 0
{
percent: "-#{(trend * -100).round(2)}%",
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
Expand Down
33 changes: 20 additions & 13 deletions app/javascript/controllers/apex_downloads_controller.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
import { Controller } from "@hotwired/stimulus"
import { buildDateTimeChart, buildDownloadsSeries, dynamicBarAndAreaType } from "util/apex"
import { buildSparklineChart, destroyChart } from "util/apex"

export default class extends Controller {
static values = {
id: String,
downloads: Object,
dateRange: Array,
interval: String,
downloads: Array,
}

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
)
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)
}
}
46 changes: 46 additions & 0 deletions app/javascript/util/apex.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
14 changes: 14 additions & 0 deletions app/views/metrics/_episode_sparkline.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<%= turbo_frame_tag "episode_sparkline" do %>
<% trend = parse_trend(episode_trend) %>

<p class="d-flex align-items-center <%= trend[:color] if trend.present? %>">
<span class="z-1"><%= trend[:percent] if trend.present? %></span>
<span class="material-icons ms-1 z-1" aria-hidden="true"><%= trend[:direction] if trend.present? %></span>
</p>
<div class="position-absolute top-50 start-50 translate-middle w-100 h-100 z-0"
data-controller="apex-downloads"
data-apex-downloads-id-value="<%= episode.guid %>"
data-apex-downloads-downloads-value="<%= downloads.to_json %>">
<div data-apex-downloads-target="chart"></div>
Copy link
Contributor Author

@radical-ube radical-ube Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the sparkline chart is positioned/translated to fill the episode card. it has the tooltip removed and also z-index 0 so that it is not interactable and doesn't interfere with clicking the episode link.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functionally great! Thanks for disabling the interactions for this.

Have you attempted updating the styling of the chart? Adding the gradient from the design file?:

Screenshot 2025-11-24 at 4 32 32 PM ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is currently a vertical gradient on the chart. i can make the border line transparent if that's helpful in seeing it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to making the the border line transparent.

Can we change the colors in the gradient?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what colors do you want?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chart-colors

#F3EAFF #DBFBFB

The light-blue would represent the higher value

</div>
<% end %>
56 changes: 7 additions & 49 deletions app/views/metrics/_episodes_card.html.erb
Original file line number Diff line number Diff line change
@@ -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| %>
<div class="row d-flex flex-wrap gx-4 h-100">
<div class="col metrics-chart"
data-controller="apex-episodes"
data-apex-episodes-id-value="<%= chart_id %>"
data-apex-episodes-selected-episodes-value="<%= episode_rollups.to_json %>"
data-apex-episodes-date-range-value="<%= date_range.to_json %>"
data-apex-episodes-interval-value="<%= interval %>">
<div data-apex-episodes-target="chart"></div>
</div>
<div class="col align-items-center metrics-table" data-controller="apex-toggles">
<table class="table table-sm table-striped shadow rounded">
<thead class="table-primary">
<tr>
<td><%= t(".table_header.episodes") %></td>
<td><%= t(".table_header.published_at") %></td>
<td><%= t(".table_header.downloads") %></td>
<td><%= t(".table_header.all_time") %></td>
</tr>
</thead>
<tbody>
<% episode_rollups.each_with_index do |er, i| %>
<% sum = sum_rollups(er[:rollups]) %>
<% total = sum_rollups(er[:totals]) %>
<% title = er[:episode][:title] %>
<tr class="align-items-center">
<td class="d-flex align-items-center">
<input id="episode<%= title %>" type="checkbox" class="form-check-input mt-0 me-2" checked="true" data-action="click->apex-toggles#toggleSeries" data-apex-toggles-series-param="<%= title %>" data-apex-toggles-id-param="<%= chart_id %>">
<span class="material-icons me-2" aria-label="hidden" style="color: <%= er[:color] %>">circle</span>
<span class="text-nowrap overflow-hidden"><%= 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"} %></span>
</td>
<td><%= er[:episode][:published_at].strftime("%Y-%m-%d") %></td>
<td><% if sum %><%= sum %><% else %>&mdash;<% end %></td>
<td><% if total %><%= total %><% else %>&mdash;<% end %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>

<%= 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"} %>
<div class="episode-card-info inside-card flex-fill px-3 py-2 position-relative">
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repurposing this partial for the published episode cards.

<p class="episode-card-date z-1"><%= local_date(episode.published_at, format: :short) %></p>
<div class="episode-card-inner inside-card">
<h2 class="h5 episode-card-title m-0 z-1"><%= link_to episode.title, episode_path(episode) %></h2>
</div>
<%= 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 %>
</div>
11 changes: 7 additions & 4 deletions app/views/metrics/_error_card.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<%= turbo_frame_tag metrics_path do %>
<div class="h-100 d-flex justify-content-center align-items-center">
Clickhouse not connected
</div>
<% if card_type == "error" %>
<%= turbo_frame_tag metrics_path do %>
<div class="h-100 d-flex justify-content-center align-items-center">
Clickhouse not connected
</div>
<% end %>
<% elsif card_type == "blank" %>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't render anything for the sparklines if clickhouse is not connected.

<% end %>
Loading