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.
@@ -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].freeze
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
@@ -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
- opts = {}
13
- opts[:probability] = Float(env["SPONSORED_LOGS_PROBABILITY"]) if env["SPONSORED_LOGS_PROBABILITY"]
14
- opts[:interval] = Float(env["SPONSORED_LOGS_INTERVAL"]) if env["SPONSORED_LOGS_INTERVAL"]
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: "#16a34a",
7
- scheduled: "#2563eb",
9
+ active: "#10b981",
10
+ scheduled: "#38bdf8",
8
11
  ended: "#6b7280",
9
- evergreen: "#7c3aed"
12
+ evergreen: "#fbbf24",
13
+ exhausted: "#f59e0b"
10
14
  }.freeze
11
15
 
12
- BAR_HEIGHT = 22
13
- BAR_GAP = 10
14
- LABEL_WIDTH = 320
15
- TRACK_WIDTH = 360
16
- VALUE_PAD = 8
17
- BAR_COLOR = "#4f46e5"
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
- # Render a horizontal bar chart as inline SVG from report ad rows.
20
- # `value` picks the numeric field per row; `format` renders the label.
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
- def bar_chart(ads, value:, format:)
23
- rows = ads.map { |ad| [ad[:text], value.call(ad).to_f] }
24
- .sort_by { |(_text, v)| -v }
25
- return content_tag(:p, "No data yet.", class: "empty") if rows.empty?
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
- max = rows.map { |(_t, v)| v }.max
28
- max = 1.0 if max <= 0
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
- height = rows.size * (BAR_HEIGHT + BAR_GAP)
31
- width = LABEL_WIDTH + TRACK_WIDTH + 90
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
- bars = rows.each_with_index.map do |(text, v), i|
34
- y = i * (BAR_HEIGHT + BAR_GAP)
35
- bar_w = ((v / max) * TRACK_WIDTH).round(2)
36
- svg_bar(text, format.call(v), y, bar_w)
37
- end.join
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
- :svg,
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 svg_bar(label, value_label, y, bar_w)
106
- text_y = y + (BAR_HEIGHT / 2) + 4
107
- label_text = esc(truncate_label(label))
108
- value_text = esc(value_label)
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))} &middot; #{pct}%</span>
198
+ </div>)
199
+ end
109
200
 
110
- %(
111
- <text x="0" y="#{text_y}" class="bar-label">#{label_text}</text>
112
- <rect x="#{LABEL_WIDTH}" y="#{y}" width="#{TRACK_WIDTH}" height="#{BAR_HEIGHT}" class="bar-track"/>
113
- <rect x="#{LABEL_WIDTH}" y="#{y}" width="#{bar_w}" height="#{BAR_HEIGHT}" class="bar-fill"/>
114
- <text x="#{LABEL_WIDTH + bar_w + VALUE_PAD}" y="#{text_y}" class="bar-value">#{value_text}</text>
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}#{" &check;" 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
- body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
8
- margin: 2rem auto; max-width: 60rem; color: #1a1a1a; }
9
- h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
10
- .subtitle { color: #666; margin-top: 0; font-size: 0.9rem; }
11
- .totals { display: flex; gap: 2rem; margin: 1.5rem 0; }
12
- .totals div { background: #f4f4f6; border-radius: 8px; padding: 1rem 1.25rem; }
13
- .totals .label { display: block; font-size: 0.75rem; text-transform: uppercase;
14
- letter-spacing: 0.05em; color: #888; }
15
- .totals .value { font-size: 1.6rem; font-weight: 600; }
16
- table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
17
- th, td { text-align: left; padding: 0.6rem 0.75rem; border-bottom: 1px solid #eee; }
18
- th { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #888; }
19
- td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
20
- .empty { color: #888; font-style: italic; margin-top: 1.5rem; }
21
- h2 { font-size: 0.95rem; text-transform: uppercase; letter-spacing: 0.05em;
22
- color: #888; margin: 2rem 0 0.5rem; }
23
- .chart { display: block; }
24
- .chart .bar-label { font-size: 12px; fill: #333; }
25
- .chart .bar-value { font-size: 12px; fill: #555; font-variant-numeric: tabular-nums; }
26
- .chart .bar-track { fill: #f0f0f3; rx: 4; }
27
- .chart .bar-fill { fill: #4f46e5; rx: 4; }
28
- .badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 999px;
29
- color: #fff; font-size: 0.7rem; text-transform: uppercase;
30
- letter-spacing: 0.03em; }
31
- td.flight { font-variant-numeric: tabular-nums; color: #555; white-space: nowrap; }
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
- <h1>Campaign Performance</h1>
36
- <p class="subtitle">Sponsored message delivery report</p>
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 &middot; 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>Spend by advertiser</h2>
53
- <%= bar_chart(@report[:ads], value: ->(ad) { ad[:spend] }, format: ->(v) { "$#{format('%.2f', v)}" }) %>
151
+ <h2>Share of spend</h2>
152
+ <%= donut_chart(@report[:advertisers], label: ->(a) { a[:advertiser] },
153
+ empty: "No spend yet.") %>
54
154
 
55
- <h2>Impressions by advertiser</h2>
56
- <%= bar_chart(@report[:ads], value: ->(ad) { ad[:impressions] }, format: ->(v) { v.to_i.to_s }) %>
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>
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SponsoredLogs
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end