sponsored_logs 0.1.0 → 0.3.0
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 +77 -0
- data/README.md +155 -1
- data/lib/sponsored_logs/advertisers.rb +165 -39
- 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/report/app/helpers/sponsored_logs/reports_helper.rb +159 -42
- data/lib/sponsored_logs/report/app/views/sponsored_logs/reports/show.html.erb +143 -33
- data/lib/sponsored_logs/version.rb +1 -1
- data/lib/sponsored_logs.rb +26 -2
- metadata +10 -5
|
@@ -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)
|
|
@@ -2,49 +2,83 @@
|
|
|
2
2
|
|
|
3
3
|
module SponsoredLogs
|
|
4
4
|
module ReportsHelper
|
|
5
|
+
# Bright, saturated fills chosen for contrast against the badge's dark text,
|
|
6
|
+
# drawn from the banner palette (gold/cyan/green).
|
|
7
|
+
#
|
|
5
8
|
STATUS_COLORS = {
|
|
6
|
-
active: "#
|
|
7
|
-
scheduled: "#
|
|
9
|
+
active: "#10b981",
|
|
10
|
+
scheduled: "#38bdf8",
|
|
8
11
|
ended: "#6b7280",
|
|
9
|
-
evergreen: "#
|
|
12
|
+
evergreen: "#fbbf24",
|
|
13
|
+
exhausted: "#f59e0b"
|
|
10
14
|
}.freeze
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
# Segment palette for the donut charts, drawn from the banner
|
|
17
|
+
# (gold, cyan, greens, violets) and cycled for larger pools.
|
|
18
|
+
#
|
|
19
|
+
DONUT_COLORS = %w[
|
|
20
|
+
#fbbf24 #38bdf8 #10b981 #f59e0b #a78bfa
|
|
21
|
+
#34d399 #60a5fa #f472b6 #fb923c #22d3ee
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
DONUT_TOP_N = 7
|
|
18
25
|
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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%.
|
|
21
30
|
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
34
|
+
#
|
|
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)
|
|
45
|
+
total = rows.sum { |(_t, v)| v }
|
|
46
|
+
radius = 60
|
|
47
|
+
donut_svg(donut_segments(rows, total, radius), donut_legend(rows, total, format), radius)
|
|
48
|
+
end
|
|
26
49
|
|
|
27
|
-
|
|
28
|
-
|
|
50
|
+
# Delivery-to-goal bars for capped ads across all groups: a filled track
|
|
51
|
+
# showing impressions against the cap, so pacing is visible at a glance.
|
|
52
|
+
# Returns nil when no ad has a cap.
|
|
53
|
+
#
|
|
54
|
+
def cap_progress(report)
|
|
55
|
+
capped = %i[ads upcoming finished]
|
|
56
|
+
.flat_map { |group| report[group] || [] }
|
|
57
|
+
.select { |ad| ad[:cap] }
|
|
58
|
+
.uniq { |ad| ad[:text] }
|
|
59
|
+
.sort_by { |ad| -(ad[:impressions].to_f / ad[:cap]) }
|
|
60
|
+
return if capped.empty?
|
|
61
|
+
|
|
62
|
+
rows = capped.map { |ad| cap_progress_row(ad) }.join
|
|
63
|
+
content_tag(:div, raw(rows), class: "cap-list")
|
|
64
|
+
end
|
|
29
65
|
|
|
30
|
-
|
|
31
|
-
|
|
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?
|
|
32
71
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
])))
|
|
38
79
|
|
|
39
|
-
content_tag(
|
|
40
|
-
|
|
41
|
-
raw(bars),
|
|
42
|
-
xmlns: "http://www.w3.org/2000/svg",
|
|
43
|
-
viewBox: "0 0 #{width} #{height}",
|
|
44
|
-
role: "img",
|
|
45
|
-
class: "chart",
|
|
46
|
-
style: "width:100%;max-width:#{width}px;height:auto;"
|
|
47
|
-
)
|
|
80
|
+
body = content_tag(:tbody, safe_join(rows.map { |a| advertiser_row(a) }))
|
|
81
|
+
content_tag(:table, safe_join([header, body]))
|
|
48
82
|
end
|
|
49
83
|
|
|
50
84
|
# Colored pill for an ad's flight status (:active/:scheduled/:ended/:evergreen).
|
|
@@ -74,6 +108,7 @@ module SponsoredLogs
|
|
|
74
108
|
|
|
75
109
|
header = content_tag(:thead, content_tag(:tr,
|
|
76
110
|
safe_join([
|
|
111
|
+
content_tag(:th, "Advertiser"),
|
|
77
112
|
content_tag(:th, "Creative"),
|
|
78
113
|
content_tag(:th, "Status"),
|
|
79
114
|
content_tag(:th, "Flight"),
|
|
@@ -93,6 +128,7 @@ module SponsoredLogs
|
|
|
93
128
|
|
|
94
129
|
def campaign_row(ad)
|
|
95
130
|
content_tag(:tr, safe_join([
|
|
131
|
+
content_tag(:td, ad[:advertiser], class: "advertiser"),
|
|
96
132
|
content_tag(:td, ad[:text]),
|
|
97
133
|
content_tag(:td, status_badge(ad[:status])),
|
|
98
134
|
content_tag(:td, flight_window(ad[:starts_at], ad[:ends_at]), class: "flight"),
|
|
@@ -102,17 +138,98 @@ module SponsoredLogs
|
|
|
102
138
|
]))
|
|
103
139
|
end
|
|
104
140
|
|
|
105
|
-
def
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
|
|
150
|
+
def donut_segments(rows, total, radius)
|
|
151
|
+
circumference = 2 * Math::PI * radius
|
|
152
|
+
offset = 0.0
|
|
153
|
+
|
|
154
|
+
rows.each_with_index.map do |(_text, v), i|
|
|
155
|
+
frac = v / total
|
|
156
|
+
seg = donut_segment(frac, offset, radius, circumference, DONUT_COLORS[i % DONUT_COLORS.size])
|
|
157
|
+
offset += frac
|
|
158
|
+
seg
|
|
159
|
+
end.join
|
|
160
|
+
end
|
|
161
|
+
|
|
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)
|
|
174
|
+
rows.each_with_index.map do |(text, v), i|
|
|
175
|
+
donut_legend_row(text, v, v / total, DONUT_COLORS[i % DONUT_COLORS.size], format)
|
|
176
|
+
end.join
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def donut_segment(frac, offset, radius, circumference, color)
|
|
180
|
+
dash = (frac * circumference).round(3)
|
|
181
|
+
gap = (circumference - dash).round(3)
|
|
182
|
+
# -offset rotates each segment to start where the previous ended;
|
|
183
|
+
# the whole ring is rotated -90deg (via the group) to begin at 12 o'clock.
|
|
184
|
+
#
|
|
185
|
+
dash_offset = (-offset * circumference).round(3)
|
|
186
|
+
|
|
187
|
+
%(<circle cx="80" cy="80" r="#{radius}" fill="none" stroke="#{color}"
|
|
188
|
+
stroke-width="26" stroke-dasharray="#{dash} #{gap}"
|
|
189
|
+
stroke-dashoffset="#{dash_offset}"/>)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def donut_legend_row(text, value, frac, color, format)
|
|
193
|
+
pct = (frac * 100).round(1)
|
|
194
|
+
%(<div class="legend-row">
|
|
195
|
+
<span class="legend-swatch" style="background:#{color};"></span>
|
|
196
|
+
<span class="legend-label">#{esc(truncate_label(text))}</span>
|
|
197
|
+
<span class="legend-value">#{esc(format.call(value))} · #{pct}%</span>
|
|
198
|
+
</div>)
|
|
199
|
+
end
|
|
109
200
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
201
|
+
def donut_svg(segments, legend, radius)
|
|
202
|
+
hole = %(<circle cx="80" cy="80" r="#{radius - 20}" fill="#0f1727"/>)
|
|
203
|
+
ring = %(<g transform="rotate(-90 80 80)">#{segments}</g>)
|
|
204
|
+
svg = content_tag(
|
|
205
|
+
:svg,
|
|
206
|
+
raw(ring + hole),
|
|
207
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
208
|
+
viewBox: "0 0 160 160",
|
|
209
|
+
role: "img",
|
|
210
|
+
class: "donut",
|
|
211
|
+
style: "flex:0 0 160px;width:160px;height:160px;"
|
|
115
212
|
)
|
|
213
|
+
|
|
214
|
+
content_tag(:div, safe_join([svg, content_tag(:div, raw(legend), class: "legend")]),
|
|
215
|
+
class: "donut-wrap")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def cap_progress_row(ad)
|
|
219
|
+
cap = ad[:cap].to_i
|
|
220
|
+
imp = ad[:impressions].to_i
|
|
221
|
+
pct = cap.positive? ? [(imp.to_f / cap * 100), 100].min.round(1) : 0.0
|
|
222
|
+
full = pct >= 100
|
|
223
|
+
|
|
224
|
+
%(<div class="cap-row">
|
|
225
|
+
<div class="cap-head">
|
|
226
|
+
<span class="cap-label">#{esc(truncate_label(ad[:text]))}</span>
|
|
227
|
+
<span class="cap-count">#{imp} / #{cap}#{" ✓" if full}</span>
|
|
228
|
+
</div>
|
|
229
|
+
<div class="cap-track">
|
|
230
|
+
<div class="cap-fill#{" full" if full}" style="width:#{pct}%;"></div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>)
|
|
116
233
|
end
|
|
117
234
|
|
|
118
235
|
# Truncate the raw text first, then escape, so we never slice through an
|
|
@@ -4,61 +4,170 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<title>Campaign Performance</title>
|
|
6
6
|
<style>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
7
|
+
:root {
|
|
8
|
+
--bg-0: #0b0f19;
|
|
9
|
+
--bg-1: #161e2e;
|
|
10
|
+
--panel: #111827;
|
|
11
|
+
--panel-2: #1f2937;
|
|
12
|
+
--border: #374151;
|
|
13
|
+
--border-soft: #243044;
|
|
14
|
+
--text: #e5e7eb;
|
|
15
|
+
--muted: #9ca3af;
|
|
16
|
+
--faint: #6b7280;
|
|
17
|
+
--gold-0: #f59e0b;
|
|
18
|
+
--gold-1: #fbbf24;
|
|
19
|
+
--cyan: #38bdf8;
|
|
20
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
21
|
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
22
|
+
}
|
|
23
|
+
* { box-sizing: border-box; }
|
|
24
|
+
body {
|
|
25
|
+
font-family: var(--sans);
|
|
26
|
+
margin: 0;
|
|
27
|
+
padding: 2.5rem 1.5rem;
|
|
28
|
+
color: var(--text);
|
|
29
|
+
background: linear-gradient(135deg, var(--bg-0), var(--bg-1));
|
|
30
|
+
background-attachment: fixed;
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
}
|
|
33
|
+
.wrap { max-width: 64rem; margin: 0 auto; }
|
|
34
|
+
|
|
35
|
+
.masthead {
|
|
36
|
+
display: flex; align-items: center; gap: 0.75rem;
|
|
37
|
+
border: 1px solid var(--border); border-radius: 14px;
|
|
38
|
+
background: linear-gradient(180deg, var(--panel-2), var(--panel));
|
|
39
|
+
padding: 1.1rem 1.4rem; margin-bottom: 1.75rem;
|
|
40
|
+
}
|
|
41
|
+
.masthead .dots { display: flex; gap: 6px; margin-right: 0.4rem; }
|
|
42
|
+
.masthead .dots i { width: 10px; height: 10px; border-radius: 50%; display: block; }
|
|
43
|
+
.masthead .dots .r { background: #ef4444; }
|
|
44
|
+
.masthead .dots .y { background: var(--gold-0); }
|
|
45
|
+
.masthead .dots .g { background: #10b981; }
|
|
46
|
+
.masthead h1 { font-size: 1.25rem; margin: 0; font-weight: 800; letter-spacing: -0.02em; }
|
|
47
|
+
.masthead h1 .gold { color: var(--gold-1); }
|
|
48
|
+
.masthead .subtitle { color: var(--cyan); margin: 0.15rem 0 0; font-size: 0.72rem;
|
|
49
|
+
font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.08em; }
|
|
50
|
+
.masthead .promoted { margin-left: auto; font-family: var(--mono); font-size: 0.62rem;
|
|
51
|
+
font-weight: 700; letter-spacing: 0.1em; color: var(--gold-0);
|
|
52
|
+
border: 1px solid var(--border); border-radius: 5px; padding: 0.3rem 0.55rem;
|
|
53
|
+
background: #0f1625; }
|
|
54
|
+
|
|
55
|
+
.totals { display: flex; flex-wrap: wrap; gap: 1rem; margin: 0 0 2rem; }
|
|
56
|
+
.totals .card {
|
|
57
|
+
flex: 1 1 12rem;
|
|
58
|
+
background: linear-gradient(180deg, var(--panel-2), var(--panel));
|
|
59
|
+
border: 1px solid var(--border); border-radius: 12px; padding: 1.1rem 1.3rem;
|
|
60
|
+
}
|
|
61
|
+
.totals .label { display: block; font-size: 0.66rem; text-transform: uppercase;
|
|
62
|
+
letter-spacing: 0.09em; color: var(--muted); font-family: var(--mono); }
|
|
63
|
+
.totals .value { font-size: 1.9rem; font-weight: 800; font-family: var(--mono);
|
|
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); }
|
|
68
|
+
|
|
69
|
+
h2 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.09em;
|
|
70
|
+
color: var(--cyan); font-family: var(--mono); margin: 2.25rem 0 0.75rem; }
|
|
71
|
+
|
|
72
|
+
table { width: 100%; border-collapse: collapse; margin-top: 0.5rem;
|
|
73
|
+
border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
|
|
74
|
+
background: var(--panel); }
|
|
75
|
+
th, td { text-align: left; padding: 0.65rem 0.85rem; border-bottom: 1px solid var(--border-soft); }
|
|
76
|
+
tr:last-child td { border-bottom: none; }
|
|
77
|
+
th { font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.08em;
|
|
78
|
+
color: var(--muted); font-family: var(--mono); background: var(--panel-2); }
|
|
79
|
+
td { font-size: 0.85rem; }
|
|
80
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono); }
|
|
81
|
+
tbody tr:hover { background: #0f1727; }
|
|
82
|
+
|
|
83
|
+
.empty { color: var(--faint); font-style: italic; margin-top: 1.5rem;
|
|
84
|
+
font-family: var(--mono); }
|
|
85
|
+
|
|
86
|
+
.badge { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px;
|
|
87
|
+
color: #0b0f19; font-size: 0.62rem; text-transform: uppercase;
|
|
88
|
+
letter-spacing: 0.04em; font-weight: 800; font-family: var(--mono); }
|
|
89
|
+
td.flight { font-variant-numeric: tabular-nums; color: var(--muted);
|
|
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; }
|
|
92
|
+
|
|
93
|
+
/* Share-of-spend donut */
|
|
94
|
+
.donut-wrap { display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;
|
|
95
|
+
border: 1px solid var(--border); border-radius: 12px;
|
|
96
|
+
background: var(--panel); padding: 1.2rem 1.4rem; }
|
|
97
|
+
.legend { flex: 1 1 16rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
98
|
+
.legend-row { display: flex; align-items: center; gap: 0.6rem; font-size: 0.82rem; }
|
|
99
|
+
.legend-swatch { width: 12px; height: 12px; border-radius: 3px; flex: 0 0 12px; }
|
|
100
|
+
.legend-label { color: var(--text); overflow: hidden; text-overflow: ellipsis;
|
|
101
|
+
white-space: nowrap; }
|
|
102
|
+
.legend-value { margin-left: auto; color: var(--muted); font-family: var(--mono);
|
|
103
|
+
font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
104
|
+
|
|
105
|
+
/* Delivery-to-goal (cap) progress */
|
|
106
|
+
.cap-list { display: flex; flex-direction: column; gap: 0.9rem;
|
|
107
|
+
border: 1px solid var(--border); border-radius: 12px;
|
|
108
|
+
background: var(--panel); padding: 1.2rem 1.4rem; }
|
|
109
|
+
.cap-head { display: flex; justify-content: space-between; align-items: baseline;
|
|
110
|
+
margin-bottom: 0.35rem; font-size: 0.82rem; }
|
|
111
|
+
.cap-label { color: var(--text); overflow: hidden; text-overflow: ellipsis;
|
|
112
|
+
white-space: nowrap; margin-right: 1rem; }
|
|
113
|
+
.cap-count { color: var(--muted); font-family: var(--mono);
|
|
114
|
+
font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
115
|
+
.cap-track { height: 10px; border-radius: 999px; background: #0f1727; overflow: hidden; }
|
|
116
|
+
.cap-fill { height: 100%; border-radius: 999px;
|
|
117
|
+
background: linear-gradient(90deg, var(--gold-0), var(--gold-1)); }
|
|
118
|
+
.cap-fill.full { background: linear-gradient(90deg, #10b981, #34d399); }
|
|
32
119
|
</style>
|
|
33
120
|
</head>
|
|
34
121
|
<body>
|
|
35
|
-
<
|
|
36
|
-
|
|
122
|
+
<div class="wrap">
|
|
123
|
+
<header class="masthead">
|
|
124
|
+
<span class="dots"><i class="r"></i><i class="y"></i><i class="g"></i></span>
|
|
125
|
+
<div>
|
|
126
|
+
<h1>Sponsored<span class="gold">Logs</span> <span style="color:var(--muted);font-weight:600;">Command Center</span></h1>
|
|
127
|
+
<p class="subtitle">Campaign performance · live delivery report</p>
|
|
128
|
+
</div>
|
|
129
|
+
<span class="promoted">SPONSORED</span>
|
|
130
|
+
</header>
|
|
37
131
|
|
|
38
132
|
<div class="totals">
|
|
39
|
-
<div>
|
|
133
|
+
<div class="card">
|
|
40
134
|
<span class="label">Impressions</span>
|
|
41
135
|
<span class="value"><%= @report[:impressions] %></span>
|
|
42
136
|
</div>
|
|
43
|
-
<div>
|
|
137
|
+
<div class="card">
|
|
44
138
|
<span class="label">Total spend</span>
|
|
45
139
|
<span class="value">$<%= format("%.2f", @report[:spend]) %></span>
|
|
46
140
|
</div>
|
|
47
141
|
</div>
|
|
48
142
|
|
|
143
|
+
<% if @report[:advertisers].any? %>
|
|
144
|
+
<h2>Advertiser accounts</h2>
|
|
145
|
+
<%= advertiser_table(@report[:advertisers]) %>
|
|
146
|
+
<% end %>
|
|
147
|
+
|
|
49
148
|
<% if @report[:ads].empty? %>
|
|
50
149
|
<p class="empty">No impressions delivered yet.</p>
|
|
51
150
|
<% else %>
|
|
52
|
-
<h2>
|
|
53
|
-
<%=
|
|
151
|
+
<h2>Share of spend</h2>
|
|
152
|
+
<%= donut_chart(@report[:advertisers], label: ->(a) { a[:advertiser] },
|
|
153
|
+
empty: "No spend yet.") %>
|
|
54
154
|
|
|
55
|
-
<h2>
|
|
56
|
-
<%=
|
|
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.") %>
|
|
57
160
|
|
|
58
161
|
<h2>Running campaigns</h2>
|
|
59
162
|
<%= campaign_table(@report[:ads]) %>
|
|
60
163
|
<% end %>
|
|
61
164
|
|
|
165
|
+
<% cap = cap_progress(@report) %>
|
|
166
|
+
<% if cap %>
|
|
167
|
+
<h2>Delivery to goal</h2>
|
|
168
|
+
<%= cap %>
|
|
169
|
+
<% end %>
|
|
170
|
+
|
|
62
171
|
<% if @report[:upcoming].any? %>
|
|
63
172
|
<h2>Upcoming campaigns</h2>
|
|
64
173
|
<%= campaign_table(@report[:upcoming]) %>
|
|
@@ -68,5 +177,6 @@
|
|
|
68
177
|
<h2>Finished campaigns</h2>
|
|
69
178
|
<%= campaign_table(@report[:finished]) %>
|
|
70
179
|
<% end %>
|
|
180
|
+
</div>
|
|
71
181
|
</body>
|
|
72
182
|
</html>
|