sponsored_logs 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c36f3d05ba2e86f78562d0dc5dcfb9b9a2f90e1ea674fc5616615d67016426d5
4
- data.tar.gz: 499cf9eec6047a818239ebed6b04518d9523cf10dc72ab02ddd72833092e4bb6
3
+ metadata.gz: be8c55d29f6cb9e44a75def20e54a4a7ca8b5c3fcb2eab2d973a67f1237847cc
4
+ data.tar.gz: '0033992014c5b8b54c847d7f7b139738323901721a965d718edb62b9820c3844'
5
5
  SHA512:
6
- metadata.gz: d6a269ba2faee897196f0cdcca7259d3267a9f7f1816d9831edeff71a40091a6804bb9dac6945b1f4a8cc5bad92fdeaa852e53634dc30552b587d39d86c4ce4c
7
- data.tar.gz: 1bb00754fb7ff7b83f775783ed18436f236afb98fc8056b69ddf142e88eaca0aeed712c7666c6cd4c8ea80d52e15c22fbcd84269ee5241cbf9145454b1c4be53
6
+ metadata.gz: fdce138553b30eeb611c03013da3c80ce7e642927873b1ff7b07c91978cf72603f9f928b2f7c74c53cb5e553e64ced063dcfcbb4130c272bbdfcbb69a6ca034b
7
+ data.tar.gz: 0e71d8587e4d60f9ed8235fdf6f414a54a77a0b0ea337ed38b12bfc715bcf16698441c5ebc79be2e84f1066ded1330809d09953fdd86402d05e0fc091b422c2f
@@ -2,11 +2,15 @@
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
16
  BAR_HEIGHT = 22
@@ -14,7 +18,14 @@ module SponsoredLogs
14
18
  LABEL_WIDTH = 320
15
19
  TRACK_WIDTH = 360
16
20
  VALUE_PAD = 8
17
- BAR_COLOR = "#4f46e5"
21
+
22
+ # Segment palette for the share-of-spend donut, drawn from the banner
23
+ # (gold, cyan, greens, violets) and cycled for larger pools.
24
+ #
25
+ DONUT_COLORS = %w[
26
+ #fbbf24 #38bdf8 #10b981 #f59e0b #a78bfa
27
+ #34d399 #60a5fa #f472b6 #fb923c #22d3ee
28
+ ].freeze
18
29
 
19
30
  # Render a horizontal bar chart as inline SVG from report ad rows.
20
31
  # `value` picks the numeric field per row; `format` renders the label.
@@ -47,6 +58,37 @@ module SponsoredLogs
47
58
  )
48
59
  end
49
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.
64
+ #
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 }
69
+ total = rows.sum { |(_t, v)| v }
70
+ return content_tag(:p, "No spend yet.", class: "empty") if total <= 0
71
+
72
+ radius = 60
73
+ donut_svg(donut_segments(rows, total, radius), donut_legend(rows, total), radius)
74
+ end
75
+
76
+ # Delivery-to-goal bars for capped ads across all groups: a filled track
77
+ # showing impressions against the cap, so pacing is visible at a glance.
78
+ # Returns nil when no ad has a cap.
79
+ #
80
+ def cap_progress(report)
81
+ capped = %i[ads upcoming finished]
82
+ .flat_map { |group| report[group] || [] }
83
+ .select { |ad| ad[:cap] }
84
+ .uniq { |ad| ad[:text] }
85
+ .sort_by { |ad| -(ad[:impressions].to_f / ad[:cap]) }
86
+ return if capped.empty?
87
+
88
+ rows = capped.map { |ad| cap_progress_row(ad) }.join
89
+ content_tag(:div, raw(rows), class: "cap-list")
90
+ end
91
+
50
92
  # Colored pill for an ad's flight status (:active/:scheduled/:ended/:evergreen).
51
93
  #
52
94
  def status_badge(status)
@@ -102,6 +144,80 @@ module SponsoredLogs
102
144
  ]))
103
145
  end
104
146
 
147
+ def donut_segments(rows, total, radius)
148
+ circumference = 2 * Math::PI * radius
149
+ offset = 0.0
150
+
151
+ rows.each_with_index.map do |(_text, v), i|
152
+ frac = v / total
153
+ seg = donut_segment(frac, offset, radius, circumference, DONUT_COLORS[i % DONUT_COLORS.size])
154
+ offset += frac
155
+ seg
156
+ end.join
157
+ end
158
+
159
+ def donut_legend(rows, total)
160
+ rows.each_with_index.map do |(text, v), i|
161
+ donut_legend_row(text, v, v / total, DONUT_COLORS[i % DONUT_COLORS.size])
162
+ end.join
163
+ end
164
+
165
+ def donut_segment(frac, offset, radius, circumference, color)
166
+ dash = (frac * circumference).round(3)
167
+ gap = (circumference - dash).round(3)
168
+ # -offset rotates each segment to start where the previous ended;
169
+ # the whole ring is rotated -90deg (via the group) to begin at 12 o'clock.
170
+ #
171
+ dash_offset = (-offset * circumference).round(3)
172
+
173
+ %(<circle cx="80" cy="80" r="#{radius}" fill="none" stroke="#{color}"
174
+ stroke-width="26" stroke-dasharray="#{dash} #{gap}"
175
+ stroke-dashoffset="#{dash_offset}"/>)
176
+ end
177
+
178
+ def donut_legend_row(text, spend, frac, color)
179
+ pct = (frac * 100).round(1)
180
+ %(<div class="legend-row">
181
+ <span class="legend-swatch" style="background:#{color};"></span>
182
+ <span class="legend-label">#{esc(truncate_label(text))}</span>
183
+ <span class="legend-value">$#{format("%.2f", spend)} &middot; #{pct}%</span>
184
+ </div>)
185
+ end
186
+
187
+ def donut_svg(segments, legend, radius)
188
+ hole = %(<circle cx="80" cy="80" r="#{radius - 20}" fill="#0f1727"/>)
189
+ ring = %(<g transform="rotate(-90 80 80)">#{segments}</g>)
190
+ svg = content_tag(
191
+ :svg,
192
+ raw(ring + hole),
193
+ xmlns: "http://www.w3.org/2000/svg",
194
+ viewBox: "0 0 160 160",
195
+ role: "img",
196
+ class: "donut",
197
+ style: "flex:0 0 160px;width:160px;height:160px;"
198
+ )
199
+
200
+ content_tag(:div, safe_join([svg, content_tag(:div, raw(legend), class: "legend")]),
201
+ class: "donut-wrap")
202
+ end
203
+
204
+ def cap_progress_row(ad)
205
+ cap = ad[:cap].to_i
206
+ imp = ad[:impressions].to_i
207
+ pct = cap.positive? ? [(imp.to_f / cap * 100), 100].min.round(1) : 0.0
208
+ full = pct >= 100
209
+
210
+ %(<div class="cap-row">
211
+ <div class="cap-head">
212
+ <span class="cap-label">#{esc(truncate_label(ad[:text]))}</span>
213
+ <span class="cap-count">#{imp} / #{cap}#{" &check;" if full}</span>
214
+ </div>
215
+ <div class="cap-track">
216
+ <div class="cap-fill#{" full" if full}" style="width:#{pct}%;"></div>
217
+ </div>
218
+ </div>)
219
+ end
220
+
105
221
  def svg_bar(label, value_label, y, bar_w)
106
222
  text_y = y + (BAR_HEIGHT / 2) + 4
107
223
  label_text = esc(truncate_label(label))
@@ -4,43 +4,150 @@
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; }
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
+ color: var(--gold-1); margin-top: 0.35rem; }
65
+
66
+ h2 { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.09em;
67
+ color: var(--cyan); font-family: var(--mono); margin: 2.25rem 0 0.75rem; }
68
+
69
+ table { width: 100%; border-collapse: collapse; margin-top: 0.5rem;
70
+ border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
71
+ background: var(--panel); }
72
+ th, td { text-align: left; padding: 0.65rem 0.85rem; border-bottom: 1px solid var(--border-soft); }
73
+ tr:last-child td { border-bottom: none; }
74
+ th { font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.08em;
75
+ color: var(--muted); font-family: var(--mono); background: var(--panel-2); }
76
+ td { font-size: 0.85rem; }
77
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono); }
78
+ tbody tr:hover { background: #0f1727; }
79
+
80
+ .empty { color: var(--faint); font-style: italic; margin-top: 1.5rem;
81
+ font-family: var(--mono); }
82
+
23
83
  .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; }
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
+ .badge { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px;
91
+ color: #0b0f19; font-size: 0.62rem; text-transform: uppercase;
92
+ letter-spacing: 0.04em; font-weight: 800; font-family: var(--mono); }
93
+ td.flight { font-variant-numeric: tabular-nums; color: var(--muted);
94
+ white-space: nowrap; font-family: var(--mono); font-size: 0.8rem; }
95
+
96
+ /* Share-of-spend donut */
97
+ .donut-wrap { display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;
98
+ border: 1px solid var(--border); border-radius: 12px;
99
+ background: var(--panel); padding: 1.2rem 1.4rem; }
100
+ .legend { flex: 1 1 16rem; display: flex; flex-direction: column; gap: 0.5rem; }
101
+ .legend-row { display: flex; align-items: center; gap: 0.6rem; font-size: 0.82rem; }
102
+ .legend-swatch { width: 12px; height: 12px; border-radius: 3px; flex: 0 0 12px; }
103
+ .legend-label { color: var(--text); overflow: hidden; text-overflow: ellipsis;
104
+ white-space: nowrap; }
105
+ .legend-value { margin-left: auto; color: var(--muted); font-family: var(--mono);
106
+ font-variant-numeric: tabular-nums; white-space: nowrap; }
107
+
108
+ /* Delivery-to-goal (cap) progress */
109
+ .cap-list { display: flex; flex-direction: column; gap: 0.9rem;
110
+ border: 1px solid var(--border); border-radius: 12px;
111
+ background: var(--panel); padding: 1.2rem 1.4rem; }
112
+ .cap-head { display: flex; justify-content: space-between; align-items: baseline;
113
+ margin-bottom: 0.35rem; font-size: 0.82rem; }
114
+ .cap-label { color: var(--text); overflow: hidden; text-overflow: ellipsis;
115
+ white-space: nowrap; margin-right: 1rem; }
116
+ .cap-count { color: var(--muted); font-family: var(--mono);
117
+ font-variant-numeric: tabular-nums; white-space: nowrap; }
118
+ .cap-track { height: 10px; border-radius: 999px; background: #0f1727; overflow: hidden; }
119
+ .cap-fill { height: 100%; border-radius: 999px;
120
+ background: linear-gradient(90deg, var(--gold-0), var(--gold-1)); }
121
+ .cap-fill.full { background: linear-gradient(90deg, #10b981, #34d399); }
32
122
  </style>
33
123
  </head>
34
124
  <body>
35
- <h1>Campaign Performance</h1>
36
- <p class="subtitle">Sponsored message delivery report</p>
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
+ <div class="wrap">
136
+ <header class="masthead">
137
+ <span class="dots"><i class="r"></i><i class="y"></i><i class="g"></i></span>
138
+ <div>
139
+ <h1>Sponsored<span class="gold">Logs</span> <span style="color:var(--muted);font-weight:600;">Command Center</span></h1>
140
+ <p class="subtitle">Campaign performance &middot; live delivery report</p>
141
+ </div>
142
+ <span class="promoted">SPONSORED</span>
143
+ </header>
37
144
 
38
145
  <div class="totals">
39
- <div>
146
+ <div class="card">
40
147
  <span class="label">Impressions</span>
41
148
  <span class="value"><%= @report[:impressions] %></span>
42
149
  </div>
43
- <div>
150
+ <div class="card">
44
151
  <span class="label">Total spend</span>
45
152
  <span class="value">$<%= format("%.2f", @report[:spend]) %></span>
46
153
  </div>
@@ -49,6 +156,9 @@
49
156
  <% if @report[:ads].empty? %>
50
157
  <p class="empty">No impressions delivered yet.</p>
51
158
  <% else %>
159
+ <h2>Share of spend</h2>
160
+ <%= donut_chart(@report[:ads]) %>
161
+
52
162
  <h2>Spend by advertiser</h2>
53
163
  <%= bar_chart(@report[:ads], value: ->(ad) { ad[:spend] }, format: ->(v) { "$#{format('%.2f', v)}" }) %>
54
164
 
@@ -59,6 +169,12 @@
59
169
  <%= campaign_table(@report[:ads]) %>
60
170
  <% end %>
61
171
 
172
+ <% cap = cap_progress(@report) %>
173
+ <% if cap %>
174
+ <h2>Delivery to goal</h2>
175
+ <%= cap %>
176
+ <% end %>
177
+
62
178
  <% if @report[:upcoming].any? %>
63
179
  <h2>Upcoming campaigns</h2>
64
180
  <%= campaign_table(@report[:upcoming]) %>
@@ -68,5 +184,6 @@
68
184
  <h2>Finished campaigns</h2>
69
185
  <%= campaign_table(@report[:finished]) %>
70
186
  <% end %>
187
+ </div>
71
188
  </body>
72
189
  </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.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sponsored_logs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerri Miller