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.
@@ -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].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)
@@ -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
- BAR_HEIGHT = 22
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
- # Render a horizontal bar chart as inline SVG from report ad rows.
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
- content_tag(
51
- :svg,
52
- raw(bars),
53
- xmlns: "http://www.w3.org/2000/svg",
54
- viewBox: "0 0 #{width} #{height}",
55
- role: "img",
56
- class: "chart",
57
- style: "width:100%;max-width:#{width}px;height:auto;"
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(ads)
66
- rows = ads.map { |ad| [ad[:text], ad[:spend].to_f] }
67
- .select { |(_t, v)| v.positive? }
68
- .sort_by { |(_t, v)| -v }
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
- def donut_legend(rows, total)
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, spend, frac, color)
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">$#{format("%.2f", spend)} &middot; #{pct}%</span>
197
+ <span class="legend-value">#{esc(format.call(value))} &middot; #{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
- color: var(--gold-1); margin-top: 0.35rem; }
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[:ads]) %>
161
-
162
- <h2>Spend by advertiser</h2>
163
- <%= bar_chart(@report[:ads], value: ->(ad) { ad[:spend] }, format: ->(v) { "$#{format('%.2f', v)}" }) %>
164
-
165
- <h2>Impressions by advertiser</h2>
166
- <%= bar_chart(@report[:ads], value: ->(ad) { ad[:impressions] }, format: ->(v) { v.to_i.to_s }) %>
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]) %>
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SponsoredLogs
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
@@ -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(ad, configuration.ad_prefix)
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