sponsored_logs 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -0
- data/README.md +176 -1
- data/lib/sponsored_logs/advertisers.rb +203 -67
- data/lib/sponsored_logs/banner.rb +84 -0
- data/lib/sponsored_logs/color.rb +58 -0
- data/lib/sponsored_logs/configuration.rb +21 -2
- data/lib/sponsored_logs/env.rb +27 -8
- data/lib/sponsored_logs/flight.rb +62 -0
- data/lib/sponsored_logs/report/app/helpers/sponsored_logs/reports_helper.rb +65 -64
- data/lib/sponsored_logs/report/app/views/sponsored_logs/reports/show.html.erb +18 -25
- data/lib/sponsored_logs/version.rb +1 -1
- data/lib/sponsored_logs.rb +27 -2
- metadata +11 -5
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SponsoredLogs
|
|
4
|
+
# Premium box-drawn ad inventory: the multi-line :banner placement. Turns a
|
|
5
|
+
# single ad line into above-the-fold, framed real estate in your stdout.
|
|
6
|
+
#
|
|
7
|
+
module Banner
|
|
8
|
+
# Body width, in columns, of a banner placement.
|
|
9
|
+
#
|
|
10
|
+
WIDTH = 60
|
|
11
|
+
|
|
12
|
+
# Glyph sets per impact tier + ascii_only fallback, ordered
|
|
13
|
+
# [top-left, top-right, bottom-left, bottom-right, horizontal, vertical].
|
|
14
|
+
#
|
|
15
|
+
GLYPHS = {
|
|
16
|
+
light: %w[┌ ┐ └ ┘ ─ │],
|
|
17
|
+
heavy: %w[┏ ┓ ┗ ┛ ━ ┃],
|
|
18
|
+
double: %w[╔ ╗ ╚ ╝ ═ ║],
|
|
19
|
+
ascii: %w[+ + + + - |]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Draw the frame for one ad. ascii_only overrides whatever impact tier was
|
|
23
|
+
# purchased with the plain +/-/| fallback set. Inner span matches
|
|
24
|
+
# "<vert> <60 cols> <vert>" so every corner and edge lines up.
|
|
25
|
+
#
|
|
26
|
+
def self.render(entry, prefix, ascii_only, color: false)
|
|
27
|
+
top, top_r, bot, bot_r, horiz, vert = GLYPHS[ascii_only ? :ascii : (entry[:box] || :light)]
|
|
28
|
+
span = WIDTH + 2
|
|
29
|
+
|
|
30
|
+
body = wrap_text(entry[:text].to_s, WIDTH).map do |line|
|
|
31
|
+
"#{vert} #{line.ljust(WIDTH)} #{vert}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
corners = [top, top_r, horiz]
|
|
35
|
+
[top_border(prefix, corners, span, color: color), *body, "#{bot}#{horiz * span}#{bot_r}"].join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Top border with the prefix embedded as "<h> [AD] <h-fill>". A blank
|
|
39
|
+
# prefix collapses to a solid rule (no gap, no tag). The fill math is
|
|
40
|
+
# computed against the PLAIN prefix, then the gilded tag is swapped in --
|
|
41
|
+
# ANSI escapes are zero-width, so gilding must not shift the border count.
|
|
42
|
+
# corners is [top-left, top-right, horizontal].
|
|
43
|
+
#
|
|
44
|
+
def self.top_border(prefix, corners, span, color: false)
|
|
45
|
+
corner, corner_r, horiz = corners
|
|
46
|
+
return "#{corner}#{horiz * span}#{corner_r}" if prefix.empty?
|
|
47
|
+
|
|
48
|
+
tag = " #{prefix} "
|
|
49
|
+
fill = horiz * (span - 1 - tag.length)
|
|
50
|
+
gilded = " #{Color.colorize(prefix, enabled: color)} "
|
|
51
|
+
"#{corner}#{horiz}#{gilded}#{fill}#{corner_r}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Word-wrap text to width columns, breaking a single word longer than the
|
|
55
|
+
# width mid-word. Always returns at least one (possibly blank) line.
|
|
56
|
+
#
|
|
57
|
+
def self.wrap_text(text, width)
|
|
58
|
+
lines = []
|
|
59
|
+
current = +""
|
|
60
|
+
|
|
61
|
+
text.to_s.split(/\s+/).each do |word|
|
|
62
|
+
word = word.dup
|
|
63
|
+
while word.length > width
|
|
64
|
+
lines << current unless current.empty?
|
|
65
|
+
current = +""
|
|
66
|
+
lines << word[0, width]
|
|
67
|
+
word = word[width..]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
candidate = current.empty? ? word : "#{current} #{word}"
|
|
71
|
+
if candidate.length > width
|
|
72
|
+
lines << current
|
|
73
|
+
current = word
|
|
74
|
+
else
|
|
75
|
+
current = candidate
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
lines << current
|
|
80
|
+
lines.reject!(&:empty?)
|
|
81
|
+
lines.empty? ? [""] : lines
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SponsoredLogs
|
|
4
|
+
# The gilding layer: wraps the [AD] prefix in 256-color gold so premium
|
|
5
|
+
# inventory reads as premium in a live terminal. Zero-width escapes only --
|
|
6
|
+
# the visible column count is unchanged, so nothing that measures the plain
|
|
7
|
+
# text (border fill, alignment) has to know color happened.
|
|
8
|
+
#
|
|
9
|
+
module Color
|
|
10
|
+
# SGR 256-color gold (xterm 214) open, plus the universal reset. Matches the
|
|
11
|
+
# gold accent in docs/banner.svg -- the AD tag is always the money color.
|
|
12
|
+
#
|
|
13
|
+
GOLD = "\e[38;5;214m"
|
|
14
|
+
RESET = "\e[0m"
|
|
15
|
+
|
|
16
|
+
require "logger"
|
|
17
|
+
|
|
18
|
+
# Gild text in gold when enabled, otherwise hand it back untouched so the
|
|
19
|
+
# non-TTY path stays byte-identical to the classic plain line.
|
|
20
|
+
#
|
|
21
|
+
def self.colorize(text, enabled:)
|
|
22
|
+
return text unless enabled
|
|
23
|
+
|
|
24
|
+
"#{GOLD}#{text}#{RESET}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Decide whether an emission to target should be gilded. :never never
|
|
28
|
+
# gilds; :always always gilds (overriding NO_COLOR); :auto gilds only when
|
|
29
|
+
# NO_COLOR is unset AND the target is a real TTY. A Logger's sink is treated
|
|
30
|
+
# as non-TTY (log files/streams must never get ANSI), so it gilds only under
|
|
31
|
+
# :always.
|
|
32
|
+
#
|
|
33
|
+
def self.gild?(target, mode:, env: ENV)
|
|
34
|
+
case mode
|
|
35
|
+
when :never then false
|
|
36
|
+
when :always then true
|
|
37
|
+
else no_color_unset?(env) && tty?(target)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# NO_COLOR convention (https://no-color.org): any non-empty value disables
|
|
42
|
+
# color. Unset or empty leaves auto-gilding available.
|
|
43
|
+
#
|
|
44
|
+
def self.no_color_unset?(env)
|
|
45
|
+
value = env["NO_COLOR"]
|
|
46
|
+
value.nil? || value.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A target is a TTY only when it is an IO that reports tty?. Loggers report
|
|
50
|
+
# false here on purpose -- we never unwrap the buried logdev.
|
|
51
|
+
#
|
|
52
|
+
def self.tty?(target)
|
|
53
|
+
return false if target.is_a?(Logger)
|
|
54
|
+
|
|
55
|
+
target.respond_to?(:tty?) && target.tty?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module SponsoredLogs
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :probability, :periodic, :interval, :output, :ad_prefix, :ads, :selection, :store, :report_page
|
|
5
|
+
attr_accessor :probability, :periodic, :interval, :output, :ad_prefix, :ads, :selection, :store, :report_page,
|
|
6
|
+
:ascii_only, :house_ads
|
|
7
|
+
attr_reader :color
|
|
8
|
+
|
|
9
|
+
# Gilding modes for the [AD] prefix. :auto gilds only on a NO_COLOR-clear
|
|
10
|
+
# TTY; :always forces gold (overriding NO_COLOR); :never stays plain.
|
|
11
|
+
#
|
|
12
|
+
COLOR_MODES = %i[auto always never].freeze
|
|
6
13
|
|
|
7
14
|
# Settings that map 1:1 onto an accessor. ads/ads_file are handled
|
|
8
15
|
# separately because they interact (ads wins; ads_file loads into ads).
|
|
9
16
|
#
|
|
10
|
-
DIRECT_KEYS = %i[probability periodic interval output ad_prefix selection store report_page
|
|
17
|
+
DIRECT_KEYS = %i[probability periodic interval output ad_prefix selection store report_page ascii_only
|
|
18
|
+
house_ads color].freeze
|
|
11
19
|
KNOWN_KEYS = (DIRECT_KEYS + %i[ads ads_file]).freeze
|
|
12
20
|
|
|
13
21
|
def initialize
|
|
@@ -20,6 +28,17 @@ module SponsoredLogs
|
|
|
20
28
|
@selection = :weight
|
|
21
29
|
@store = Ledger::Store::Memory.new
|
|
22
30
|
@report_page = false
|
|
31
|
+
@ascii_only = false
|
|
32
|
+
@house_ads = true
|
|
33
|
+
@color = :auto
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Coerce any unrecognized gilding mode back to :auto so a stray value never
|
|
37
|
+
# forces or suppresses color unexpectedly.
|
|
38
|
+
#
|
|
39
|
+
def color=(value)
|
|
40
|
+
symbol = value.to_s.strip.downcase.to_sym
|
|
41
|
+
@color = COLOR_MODES.include?(symbol) ? symbol : :auto
|
|
23
42
|
end
|
|
24
43
|
|
|
25
44
|
# Apply a hash of settings. Symbol or string keys are accepted; unknown
|
data/lib/sponsored_logs/env.rb
CHANGED
|
@@ -8,15 +8,34 @@ module SponsoredLogs
|
|
|
8
8
|
truthy?(env["SPONSORED_LOGS"])
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
# Maps each SPONSORED_LOGS_* variable to its option key and a coercer. Only
|
|
12
|
+
# variables actually present are applied, so the manual sponsor! path is
|
|
13
|
+
# untouched. Add a new override by extending this table.
|
|
14
|
+
#
|
|
15
|
+
OPTION_MAP = {
|
|
16
|
+
"SPONSORED_LOGS_PROBABILITY" => [:probability, ->(v) { Float(v) }],
|
|
17
|
+
"SPONSORED_LOGS_INTERVAL" => [:interval, ->(v) { Float(v) }],
|
|
18
|
+
"SPONSORED_LOGS_PERIODIC" => [:periodic, ->(v) { truthy?(v) }],
|
|
19
|
+
"SPONSORED_LOGS_PREFIX" => [:ad_prefix, ->(v) { v }],
|
|
20
|
+
"SPONSORED_LOGS_ADS_FILE" => [:ads_file, ->(v) { v }],
|
|
21
|
+
"SPONSORED_LOGS_SELECTION" => [:selection, :to_sym.to_proc],
|
|
22
|
+
"SPONSORED_LOGS_ASCII_ONLY" => [:ascii_only, ->(v) { truthy?(v) }],
|
|
23
|
+
"SPONSORED_LOGS_HOUSE_ADS" => [:house_ads, ->(v) { truthy?(v) }],
|
|
24
|
+
"SPONSORED_LOGS_COLOR" => [:color, ->(v) { color_mode(v) }]
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Map a raw SPONSORED_LOGS_COLOR value to a gilding mode symbol, falling
|
|
28
|
+
# back to :auto for anything unrecognized (invalid never forces color).
|
|
29
|
+
#
|
|
30
|
+
def self.color_mode(value)
|
|
31
|
+
symbol = value.to_s.strip.downcase.to_sym
|
|
32
|
+
Configuration::COLOR_MODES.include?(symbol) ? symbol : :auto
|
|
33
|
+
end
|
|
34
|
+
|
|
11
35
|
def self.options(env = ENV)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
opts[:periodic] = truthy?(env["SPONSORED_LOGS_PERIODIC"]) if env["SPONSORED_LOGS_PERIODIC"]
|
|
16
|
-
opts[:ad_prefix] = env["SPONSORED_LOGS_PREFIX"] if env["SPONSORED_LOGS_PREFIX"]
|
|
17
|
-
opts[:ads_file] = env["SPONSORED_LOGS_ADS_FILE"] if env["SPONSORED_LOGS_ADS_FILE"]
|
|
18
|
-
opts[:selection] = env["SPONSORED_LOGS_SELECTION"].to_sym if env["SPONSORED_LOGS_SELECTION"]
|
|
19
|
-
opts
|
|
36
|
+
OPTION_MAP.each_with_object({}) do |(var, (key, coerce)), opts|
|
|
37
|
+
opts[key] = coerce.call(env[var]) if env[var]
|
|
38
|
+
end
|
|
20
39
|
end
|
|
21
40
|
|
|
22
41
|
def self.truthy?(value)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module SponsoredLogs
|
|
6
|
+
# Flight-window and impression-cap predicates for a normalized ad. Extracted
|
|
7
|
+
# from Advertisers so the eligibility rules live in one cohesive place and the
|
|
8
|
+
# host module stays under Metrics/ModuleLength. Advertisers keeps thin
|
|
9
|
+
# delegators for its public surface (live?, status, capped?, eligible?).
|
|
10
|
+
#
|
|
11
|
+
module Flight
|
|
12
|
+
# Whether `now` falls before an ad's flight window opens. A nil starts_at is
|
|
13
|
+
# open-ended, so the ad has always started.
|
|
14
|
+
#
|
|
15
|
+
def self.before_start?(ad, now)
|
|
16
|
+
!ad[:starts_at].nil? && now < ad[:starts_at]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Whether `now` falls after an ad's flight window closes. A nil ends_at is
|
|
20
|
+
# open-ended, so the ad never ends.
|
|
21
|
+
#
|
|
22
|
+
def self.after_end?(ad, now)
|
|
23
|
+
!ad[:ends_at].nil? && now > ad[:ends_at]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whether an ad is within its flight window at `now`. Missing bounds are
|
|
27
|
+
# open-ended (nil starts_at = always started; nil ends_at = never ends).
|
|
28
|
+
#
|
|
29
|
+
def self.live?(ad, now)
|
|
30
|
+
!before_start?(ad, now) && !after_end?(ad, now)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Whether an ad has reached its impression cap given a current count.
|
|
34
|
+
# Uncapped ads (nil cap) are never capped.
|
|
35
|
+
#
|
|
36
|
+
def self.capped?(ad, count)
|
|
37
|
+
cap = ad[:cap]
|
|
38
|
+
return false if cap.nil?
|
|
39
|
+
|
|
40
|
+
count.to_i >= cap
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether an ad is eligible for selection: live at `now` and not capped.
|
|
44
|
+
#
|
|
45
|
+
def self.eligible?(ad, now, count)
|
|
46
|
+
live?(ad, now) && !capped?(ad, count)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Status of an ad at `now` given its impression count: :exhausted (cap
|
|
50
|
+
# reached), :scheduled (window not started), :ended (window passed),
|
|
51
|
+
# :evergreen (no bounds), or :active.
|
|
52
|
+
#
|
|
53
|
+
def self.status(ad, now = Time.now, count = 0)
|
|
54
|
+
return :exhausted if capped?(ad, count)
|
|
55
|
+
return :scheduled if before_start?(ad, now)
|
|
56
|
+
return :ended if after_end?(ad, now)
|
|
57
|
+
return :evergreen if ad[:starts_at].nil? && ad[:ends_at].nil?
|
|
58
|
+
|
|
59
|
+
:active
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -13,13 +13,7 @@ module SponsoredLogs
|
|
|
13
13
|
exhausted: "#f59e0b"
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
BAR_GAP = 10
|
|
18
|
-
LABEL_WIDTH = 320
|
|
19
|
-
TRACK_WIDTH = 360
|
|
20
|
-
VALUE_PAD = 8
|
|
21
|
-
|
|
22
|
-
# Segment palette for the share-of-spend donut, drawn from the banner
|
|
16
|
+
# Segment palette for the donut charts, drawn from the banner
|
|
23
17
|
# (gold, cyan, greens, violets) and cycled for larger pools.
|
|
24
18
|
#
|
|
25
19
|
DONUT_COLORS = %w[
|
|
@@ -27,50 +21,30 @@ module SponsoredLogs
|
|
|
27
21
|
#34d399 #60a5fa #f472b6 #fb923c #22d3ee
|
|
28
22
|
].freeze
|
|
29
23
|
|
|
30
|
-
|
|
31
|
-
# `value` picks the numeric field per row; `format` renders the label.
|
|
32
|
-
#
|
|
33
|
-
def bar_chart(ads, value:, format:)
|
|
34
|
-
rows = ads.map { |ad| [ad[:text], value.call(ad).to_f] }
|
|
35
|
-
.sort_by { |(_text, v)| -v }
|
|
36
|
-
return content_tag(:p, "No data yet.", class: "empty") if rows.empty?
|
|
37
|
-
|
|
38
|
-
max = rows.map { |(_t, v)| v }.max
|
|
39
|
-
max = 1.0 if max <= 0
|
|
40
|
-
|
|
41
|
-
height = rows.size * (BAR_HEIGHT + BAR_GAP)
|
|
42
|
-
width = LABEL_WIDTH + TRACK_WIDTH + 90
|
|
43
|
-
|
|
44
|
-
bars = rows.each_with_index.map do |(text, v), i|
|
|
45
|
-
y = i * (BAR_HEIGHT + BAR_GAP)
|
|
46
|
-
bar_w = ((v / max) * TRACK_WIDTH).round(2)
|
|
47
|
-
svg_bar(text, format.call(v), y, bar_w)
|
|
48
|
-
end.join
|
|
24
|
+
DONUT_TOP_N = 7
|
|
49
25
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Share-of-spend donut as inline SVG. Each ad becomes an arc sized by its
|
|
62
|
-
# fraction of total spend, rendered as an offset stroke on a circle, with a
|
|
63
|
-
# legend beside it. Ads with zero spend are omitted.
|
|
26
|
+
# Donut chart as inline SVG. Each row becomes an arc sized by its fraction
|
|
27
|
+
# of the total, with a legend beside it. Zero/negative values are omitted;
|
|
28
|
+
# only the top DONUT_TOP_N slices are shown individually and the remainder
|
|
29
|
+
# is rolled into a single "Other" slice so the ring still totals 100%.
|
|
30
|
+
#
|
|
31
|
+
# `label` picks the slice name, `value` the number to slice on (default
|
|
32
|
+
# spend), `format` renders the legend value (default dollars), and `empty`
|
|
33
|
+
# is the message when there's nothing to show.
|
|
64
34
|
#
|
|
65
|
-
def donut_chart(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
35
|
+
def donut_chart(rows_in, label: ->(row) { row[:text] },
|
|
36
|
+
value: ->(row) { row[:spend] },
|
|
37
|
+
format: ->(v) { "$#{Kernel.format("%.2f", v)}" },
|
|
38
|
+
empty: "No data yet.")
|
|
39
|
+
rows = rows_in.map { |row| [label.call(row), value.call(row).to_f] }
|
|
40
|
+
.select { |(_t, v)| v.positive? }
|
|
41
|
+
.sort_by { |(_t, v)| -v }
|
|
42
|
+
return content_tag(:p, empty, class: "empty") if rows.empty?
|
|
43
|
+
|
|
44
|
+
rows = collapse_to_top(rows, DONUT_TOP_N)
|
|
69
45
|
total = rows.sum { |(_t, v)| v }
|
|
70
|
-
return content_tag(:p, "No spend yet.", class: "empty") if total <= 0
|
|
71
|
-
|
|
72
46
|
radius = 60
|
|
73
|
-
donut_svg(donut_segments(rows, total, radius), donut_legend(rows, total), radius)
|
|
47
|
+
donut_svg(donut_segments(rows, total, radius), donut_legend(rows, total, format), radius)
|
|
74
48
|
end
|
|
75
49
|
|
|
76
50
|
# Delivery-to-goal bars for capped ads across all groups: a filled track
|
|
@@ -89,6 +63,24 @@ module SponsoredLogs
|
|
|
89
63
|
content_tag(:div, raw(rows), class: "cap-list")
|
|
90
64
|
end
|
|
91
65
|
|
|
66
|
+
# Per-advertiser rollup table (advertiser accounts), sorted by spend.
|
|
67
|
+
# Returns nil for an empty set so the caller can skip the section.
|
|
68
|
+
#
|
|
69
|
+
def advertiser_table(rows)
|
|
70
|
+
return if rows.nil? || rows.empty?
|
|
71
|
+
|
|
72
|
+
header = content_tag(:thead, content_tag(:tr,
|
|
73
|
+
safe_join([
|
|
74
|
+
content_tag(:th, "Advertiser"),
|
|
75
|
+
content_tag(:th, "Ads", class: "num"),
|
|
76
|
+
content_tag(:th, "Impressions", class: "num"),
|
|
77
|
+
content_tag(:th, "Spend", class: "num")
|
|
78
|
+
])))
|
|
79
|
+
|
|
80
|
+
body = content_tag(:tbody, safe_join(rows.map { |a| advertiser_row(a) }))
|
|
81
|
+
content_tag(:table, safe_join([header, body]))
|
|
82
|
+
end
|
|
83
|
+
|
|
92
84
|
# Colored pill for an ad's flight status (:active/:scheduled/:ended/:evergreen).
|
|
93
85
|
#
|
|
94
86
|
def status_badge(status)
|
|
@@ -116,6 +108,7 @@ module SponsoredLogs
|
|
|
116
108
|
|
|
117
109
|
header = content_tag(:thead, content_tag(:tr,
|
|
118
110
|
safe_join([
|
|
111
|
+
content_tag(:th, "Advertiser"),
|
|
119
112
|
content_tag(:th, "Creative"),
|
|
120
113
|
content_tag(:th, "Status"),
|
|
121
114
|
content_tag(:th, "Flight"),
|
|
@@ -135,6 +128,7 @@ module SponsoredLogs
|
|
|
135
128
|
|
|
136
129
|
def campaign_row(ad)
|
|
137
130
|
content_tag(:tr, safe_join([
|
|
131
|
+
content_tag(:td, ad[:advertiser], class: "advertiser"),
|
|
138
132
|
content_tag(:td, ad[:text]),
|
|
139
133
|
content_tag(:td, status_badge(ad[:status])),
|
|
140
134
|
content_tag(:td, flight_window(ad[:starts_at], ad[:ends_at]), class: "flight"),
|
|
@@ -144,6 +138,15 @@ module SponsoredLogs
|
|
|
144
138
|
]))
|
|
145
139
|
end
|
|
146
140
|
|
|
141
|
+
def advertiser_row(account)
|
|
142
|
+
content_tag(:tr, safe_join([
|
|
143
|
+
content_tag(:td, account[:advertiser], class: "advertiser"),
|
|
144
|
+
content_tag(:td, account[:ads], class: "num"),
|
|
145
|
+
content_tag(:td, account[:impressions], class: "num"),
|
|
146
|
+
content_tag(:td, "$#{format("%.2f", account[:spend])}", class: "num")
|
|
147
|
+
]))
|
|
148
|
+
end
|
|
149
|
+
|
|
147
150
|
def donut_segments(rows, total, radius)
|
|
148
151
|
circumference = 2 * Math::PI * radius
|
|
149
152
|
offset = 0.0
|
|
@@ -156,9 +159,20 @@ module SponsoredLogs
|
|
|
156
159
|
end.join
|
|
157
160
|
end
|
|
158
161
|
|
|
159
|
-
|
|
162
|
+
# Keep the top n rows; fold the rest into a single "Other" slice so the
|
|
163
|
+
# donut still represents the whole.
|
|
164
|
+
#
|
|
165
|
+
def collapse_to_top(rows, count)
|
|
166
|
+
return rows if rows.size <= count
|
|
167
|
+
|
|
168
|
+
top = rows.first(count)
|
|
169
|
+
other = rows.drop(count).sum { |(_t, v)| v }
|
|
170
|
+
top + [["Other", other]]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def donut_legend(rows, total, format)
|
|
160
174
|
rows.each_with_index.map do |(text, v), i|
|
|
161
|
-
donut_legend_row(text, v, v / total, DONUT_COLORS[i % DONUT_COLORS.size])
|
|
175
|
+
donut_legend_row(text, v, v / total, DONUT_COLORS[i % DONUT_COLORS.size], format)
|
|
162
176
|
end.join
|
|
163
177
|
end
|
|
164
178
|
|
|
@@ -175,12 +189,12 @@ module SponsoredLogs
|
|
|
175
189
|
stroke-dashoffset="#{dash_offset}"/>)
|
|
176
190
|
end
|
|
177
191
|
|
|
178
|
-
def donut_legend_row(text,
|
|
192
|
+
def donut_legend_row(text, value, frac, color, format)
|
|
179
193
|
pct = (frac * 100).round(1)
|
|
180
194
|
%(<div class="legend-row">
|
|
181
195
|
<span class="legend-swatch" style="background:#{color};"></span>
|
|
182
196
|
<span class="legend-label">#{esc(truncate_label(text))}</span>
|
|
183
|
-
<span class="legend-value"
|
|
197
|
+
<span class="legend-value">#{esc(format.call(value))} · #{pct}%</span>
|
|
184
198
|
</div>)
|
|
185
199
|
end
|
|
186
200
|
|
|
@@ -218,19 +232,6 @@ module SponsoredLogs
|
|
|
218
232
|
</div>)
|
|
219
233
|
end
|
|
220
234
|
|
|
221
|
-
def svg_bar(label, value_label, y, bar_w)
|
|
222
|
-
text_y = y + (BAR_HEIGHT / 2) + 4
|
|
223
|
-
label_text = esc(truncate_label(label))
|
|
224
|
-
value_text = esc(value_label)
|
|
225
|
-
|
|
226
|
-
%(
|
|
227
|
-
<text x="0" y="#{text_y}" class="bar-label">#{label_text}</text>
|
|
228
|
-
<rect x="#{LABEL_WIDTH}" y="#{y}" width="#{TRACK_WIDTH}" height="#{BAR_HEIGHT}" class="bar-track"/>
|
|
229
|
-
<rect x="#{LABEL_WIDTH}" y="#{y}" width="#{bar_w}" height="#{BAR_HEIGHT}" class="bar-fill"/>
|
|
230
|
-
<text x="#{LABEL_WIDTH + bar_w + VALUE_PAD}" y="#{text_y}" class="bar-value">#{value_text}</text>
|
|
231
|
-
)
|
|
232
|
-
end
|
|
233
|
-
|
|
234
235
|
# Truncate the raw text first, then escape, so we never slice through an
|
|
235
236
|
# HTML entity.
|
|
236
237
|
#
|
|
@@ -61,7 +61,10 @@
|
|
|
61
61
|
.totals .label { display: block; font-size: 0.66rem; text-transform: uppercase;
|
|
62
62
|
letter-spacing: 0.09em; color: var(--muted); font-family: var(--mono); }
|
|
63
63
|
.totals .value { font-size: 1.9rem; font-weight: 800; font-family: var(--mono);
|
|
64
|
-
|
|
64
|
+
margin-top: 0.35rem;
|
|
65
|
+
background: linear-gradient(90deg, var(--gold-0), var(--gold-1));
|
|
66
|
+
-webkit-background-clip: text; background-clip: text;
|
|
67
|
+
-webkit-text-fill-color: transparent; color: var(--gold-1); }
|
|
65
68
|
|
|
66
69
|
h2 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.09em;
|
|
67
70
|
color: var(--cyan); font-family: var(--mono); margin: 2.25rem 0 0.75rem; }
|
|
@@ -80,18 +83,12 @@
|
|
|
80
83
|
.empty { color: var(--faint); font-style: italic; margin-top: 1.5rem;
|
|
81
84
|
font-family: var(--mono); }
|
|
82
85
|
|
|
83
|
-
.chart { display: block; }
|
|
84
|
-
.chart .bar-label { font-size: 12px; fill: var(--muted); font-family: var(--mono); }
|
|
85
|
-
.chart .bar-value { font-size: 12px; fill: var(--gold-1); font-variant-numeric: tabular-nums;
|
|
86
|
-
font-family: var(--mono); }
|
|
87
|
-
.chart .bar-track { fill: #0f1727; rx: 4; }
|
|
88
|
-
.chart .bar-fill { fill: url(#slGold); rx: 4; }
|
|
89
|
-
|
|
90
86
|
.badge { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px;
|
|
91
87
|
color: #0b0f19; font-size: 0.62rem; text-transform: uppercase;
|
|
92
88
|
letter-spacing: 0.04em; font-weight: 800; font-family: var(--mono); }
|
|
93
89
|
td.flight { font-variant-numeric: tabular-nums; color: var(--muted);
|
|
94
90
|
white-space: nowrap; font-family: var(--mono); font-size: 0.8rem; }
|
|
91
|
+
td.advertiser { font-weight: 700; color: var(--gold-1); white-space: nowrap; }
|
|
95
92
|
|
|
96
93
|
/* Share-of-spend donut */
|
|
97
94
|
.donut-wrap { display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;
|
|
@@ -122,16 +119,6 @@
|
|
|
122
119
|
</style>
|
|
123
120
|
</head>
|
|
124
121
|
<body>
|
|
125
|
-
<!-- Shared gradient for SVG bar fills, matching the banner's gold. -->
|
|
126
|
-
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
|
127
|
-
<defs>
|
|
128
|
-
<linearGradient id="slGold" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
129
|
-
<stop offset="0%" stop-color="#f59e0b" />
|
|
130
|
-
<stop offset="100%" stop-color="#fbbf24" />
|
|
131
|
-
</linearGradient>
|
|
132
|
-
</defs>
|
|
133
|
-
</svg>
|
|
134
|
-
|
|
135
122
|
<div class="wrap">
|
|
136
123
|
<header class="masthead">
|
|
137
124
|
<span class="dots"><i class="r"></i><i class="y"></i><i class="g"></i></span>
|
|
@@ -153,17 +140,23 @@
|
|
|
153
140
|
</div>
|
|
154
141
|
</div>
|
|
155
142
|
|
|
143
|
+
<% if @report[:advertisers].any? %>
|
|
144
|
+
<h2>Advertiser accounts</h2>
|
|
145
|
+
<%= advertiser_table(@report[:advertisers]) %>
|
|
146
|
+
<% end %>
|
|
147
|
+
|
|
156
148
|
<% if @report[:ads].empty? %>
|
|
157
149
|
<p class="empty">No impressions delivered yet.</p>
|
|
158
150
|
<% else %>
|
|
159
151
|
<h2>Share of spend</h2>
|
|
160
|
-
<%= donut_chart(@report[:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
152
|
+
<%= donut_chart(@report[:advertisers], label: ->(a) { a[:advertiser] },
|
|
153
|
+
empty: "No spend yet.") %>
|
|
154
|
+
|
|
155
|
+
<h2>Share of impressions</h2>
|
|
156
|
+
<%= donut_chart(@report[:advertisers], label: ->(a) { a[:advertiser] },
|
|
157
|
+
value: ->(a) { a[:impressions] },
|
|
158
|
+
format: ->(v) { v.to_i.to_s },
|
|
159
|
+
empty: "No impressions yet.") %>
|
|
167
160
|
|
|
168
161
|
<h2>Running campaigns</h2>
|
|
169
162
|
<%= campaign_table(@report[:ads]) %>
|
data/lib/sponsored_logs.rb
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
require "logger"
|
|
4
4
|
|
|
5
5
|
require_relative "sponsored_logs/version"
|
|
6
|
+
require_relative "sponsored_logs/color"
|
|
6
7
|
require_relative "sponsored_logs/advertisers"
|
|
8
|
+
require_relative "sponsored_logs/flight"
|
|
9
|
+
require_relative "sponsored_logs/banner"
|
|
7
10
|
require_relative "sponsored_logs/ads_file"
|
|
8
11
|
require_relative "sponsored_logs/ledger/store/base"
|
|
9
12
|
require_relative "sponsored_logs/ledger/store/memory"
|
|
@@ -76,7 +79,11 @@ module SponsoredLogs
|
|
|
76
79
|
return if ad.nil?
|
|
77
80
|
|
|
78
81
|
ledger.record(ad)
|
|
79
|
-
line = Advertisers.render(
|
|
82
|
+
line = Advertisers.render(
|
|
83
|
+
ad, configuration.ad_prefix,
|
|
84
|
+
ascii_only: configuration.ascii_only,
|
|
85
|
+
color: Color.gild?(target, mode: configuration.color)
|
|
86
|
+
)
|
|
80
87
|
|
|
81
88
|
if target.is_a?(Logger)
|
|
82
89
|
# Raw << avoids re-triggering our own Logger#add patch (infinite loop).
|
|
@@ -120,7 +127,8 @@ module SponsoredLogs
|
|
|
120
127
|
spend: ledger.total_spend.round(2),
|
|
121
128
|
ads: grouped[:running],
|
|
122
129
|
upcoming: grouped[:upcoming],
|
|
123
|
-
finished: grouped[:finished]
|
|
130
|
+
finished: grouped[:finished],
|
|
131
|
+
advertisers: advertiser_rollup(grouped)
|
|
124
132
|
}
|
|
125
133
|
end
|
|
126
134
|
|
|
@@ -189,6 +197,7 @@ module SponsoredLogs
|
|
|
189
197
|
#
|
|
190
198
|
def report_row(text, meta, entry, status)
|
|
191
199
|
{
|
|
200
|
+
advertiser: meta[:advertiser] || Advertisers::DEFAULT_ADVERTISER,
|
|
192
201
|
text: text,
|
|
193
202
|
impressions: entry ? entry.impressions : 0,
|
|
194
203
|
cpm: entry ? entry.cpm : meta[:cpm].to_f,
|
|
@@ -200,6 +209,22 @@ module SponsoredLogs
|
|
|
200
209
|
}
|
|
201
210
|
end
|
|
202
211
|
|
|
212
|
+
# Roll every report row up to its advertiser: total impressions, spend, and
|
|
213
|
+
# ad count per advertiser account, sorted by spend descending.
|
|
214
|
+
#
|
|
215
|
+
def advertiser_rollup(grouped)
|
|
216
|
+
by_advertiser = grouped.values.flatten.group_by { |row| row[:advertiser] }
|
|
217
|
+
accounts = by_advertiser.map do |advertiser, ads|
|
|
218
|
+
{
|
|
219
|
+
advertiser: advertiser,
|
|
220
|
+
ads: ads.size,
|
|
221
|
+
impressions: ads.sum { |a| a[:impressions] },
|
|
222
|
+
spend: ads.sum { |a| a[:spend] }.round(2)
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
accounts.sort_by { |a| -a[:spend] }
|
|
226
|
+
end
|
|
227
|
+
|
|
203
228
|
def start_periodic_thread
|
|
204
229
|
stop_periodic_thread
|
|
205
230
|
@periodic_thread = Thread.new do
|