1
2
3
4
5
6
7
8
9function esc(s) {
10 return String(s ?? '')
11 .replace(/&/g, '&')
12 .replace(/</g, '<')
13 .replace(/>/g, '>')
14 .replace(/"/g, '"');
15}
16
17function money(n, dec = 2) {
18 const v = Number(n ?? 0);
19 return '$' + v.toLocaleString('en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec });
20}
21
22function pct(n, dec = 1) {
23 return `${(Number(n ?? 0) * 100).toFixed(dec)}%`;
24}
25
26function pctRaw(n, dec = 1) {
27 return `${Number(n ?? 0).toFixed(dec)}%`;
28}
29
30function num(n, dec = 0) {
31 const v = Number(n ?? 0);
32 return dec === 0 ? v.toLocaleString('en-US') : v.toFixed(dec);
33}
34
35function delta(n, dec = 2) {
36 const v = Number(n ?? 0);
37 const abs = Math.abs(v).toLocaleString('en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec });
38 return v >= 0 ? `+$${abs}` : `−$${abs}`;
39}
40
41function signDelta(n) {
42 const v = Number(n ?? 0);
43 return v >= 0 ? `+${num(v)}` : `${num(v)}`;
44}
45
46
47function card(label, value, sub, accentVar, tooltip = '') {
48 return `
49<div class="card"${tooltip ? ` data-tip="${esc(tooltip)}"` : ''} style="--ac:${accentVar}">
50 <div class="card-label">${esc(label)}</div>
51 <div class="card-value">${value}</div>
52 ${sub ? `<div class="card-sub">${esc(sub)}</div>` : ''}
53</div>`;
54}
55
56
57function revenueBar(payingRev, freeRev, payingRevToday, freeRevToday) {
58 const totalMTD = payingRev + freeRev;
59 const payingPct = totalMTD > 0 ? (payingRev / totalMTD) * 100 : 0;
60 const freePct = 100 - payingPct;
61 const totalToday = payingRevToday + freeRevToday;
62 const payingPctToday = totalToday > 0 ? (payingRevToday / totalToday) * 100 : 0;
63 const freePctToday = 100 - payingPctToday;
64
65 return `
66<div class="rev-split">
67 <div class="rev-split-row">
68 <div class="rev-split-col">
69 <div class="rev-split-head">Revenue Source — Month to Date</div>
70 <div class="rev-split-bar">
71 <div class="rev-bar-fill rev-bar-paying" style="width:${payingPct.toFixed(1)}%" data-tip="Paying users: ${money(payingRev)} (${payingPct.toFixed(1)}%)"></div>
72 <div class="rev-bar-fill rev-bar-free" style="width:${freePct.toFixed(1)}%" data-tip="Free users: ${money(freeRev)} (${freePct.toFixed(1)}%)"></div>
73 </div>
74 <div class="rev-split-legend">
75 <span class="legend-dot leg-paying"></span><span class="legend-label">Paying <strong>${money(payingRev)}</strong> <em>${payingPct.toFixed(1)}%</em></span>
76 <span class="legend-dot leg-free"></span><span class="legend-label">Free <strong>${money(freeRev)}</strong> <em>${freePct.toFixed(1)}%</em></span>
77 </div>
78 </div>
79 <div class="rev-split-col">
80 <div class="rev-split-head">Revenue Source — Today</div>
81 <div class="rev-split-bar">
82 <div class="rev-bar-fill rev-bar-paying" style="width:${payingPctToday.toFixed(1)}%" data-tip="Paying users: ${money(payingRevToday)} (${payingPctToday.toFixed(1)}%)"></div>
83 <div class="rev-bar-fill rev-bar-free" style="width:${freePctToday.toFixed(1)}%" data-tip="Free users: ${money(freeRevToday)} (${freePctToday.toFixed(1)}%)"></div>
84 </div>
85 <div class="rev-split-legend">
86 <span class="legend-dot leg-paying"></span><span class="legend-label">Paying <strong>${money(payingRevToday)}</strong> <em>${payingPctToday.toFixed(1)}%</em></span>
87 <span class="legend-dot leg-free"></span><span class="legend-label">Free <strong>${money(freeRevToday)}</strong> <em>${freePctToday.toFixed(1)}%</em></span>
88 </div>
89 </div>
90 </div>
91</div>`;
92}
93
94
95function renderActorRow(d) {
96 const id = esc(d.actorId);
97 const title = esc(d.actorTitle || d.actorName);
98
99 let signal = '';
100 let signalClass = d.status;
101 if (d.status === 'red') {
102 signal = d.isRemoved ? 'Removed' : d.todayRuns === 0 ? 'No runs today' : `${num(d.todaySuccessRate, 1)}% success`;
103 } else if (d.status === 'yellow') {
104 signal = `Rev ${delta(d.revenueDelta)}`;
105 } else {
106 signal = `${money(d.todayRevenue)} today`;
107 }
108
109 const badges = [
110 d.isNew ? `<span class="badge badge-new">NEW</span>` : '',
111 d.isRemoved ? `<span class="badge badge-rm">REMOVED</span>` : '',
112 ].join('');
113
114 return `
115<div class="arow" data-status="${d.status}">
116 <div class="arow-hdr" onclick="toggleActor('${id}')">
117 <span class="status-pip pip-${d.status}"></span>
118 <span class="aname">${title}${badges}</span>
119 <span class="asignal sig-${signalClass}">${signal}</span>
120 <svg class="chevron" id="chev-${id}" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
121 <polyline points="5 8 10 13 15 8"/>
122 </svg>
123 </div>
124 <div class="adetail" id="d-${id}">
125 <div class="detail-grid">
126
127 <div class="dsec">
128 <div class="dsec-head">Financials — Month</div>
129 <div class="drow"><span>Revenue</span><span class="dval pos">${money(d.totalRevenue)}</span></div>
130 <div class="drow"><span>Cost</span><span class="dval">${money(d.totalCost)}</span></div>
131 <div class="drow"><span>Net Profit</span><span class="dval ${d.netProfit >= 0 ? 'pos' : 'neg'}">${money(d.netProfit)}</span></div>
132 <div class="drow"><span>Margin</span><span class="dval pos">${pct(d.profitMargin)}</span></div>
133 </div>
134
135 <div class="dsec">
136 <div class="dsec-head">Users — Month</div>
137 <div class="drow"><span>Paying MTD</span><span class="dval">${num(d.payingUsers)}<em class="${d.newPayingUsersGained >= 0 ? 'pos' : 'neg'}">${d.newPayingUsersGained >= 0 ? '+' : ''}${d.newPayingUsersGained} since last run</em></span></div>
138 <div class="drow"><span>Free MTD</span><span class="dval">${num(d.freeUsers)}<em class="${d.newFreeUsersGained >= 0 ? 'pos' : 'neg'}">${d.newFreeUsersGained >= 0 ? '+' : ''}${d.newFreeUsersGained} since last run</em></span></div>
139 <div class="drow"><span>Active Today (paying)</span><span class="dval">${num(d.todayPayingUsers)}</span></div>
140 <div class="drow"><span>Active Today (free)</span><span class="dval">${num(d.todayFreeUsers)}</span></div>
141 </div>
142
143 <div class="dsec">
144 <div class="dsec-head">Today's Runs</div>
145 <div class="drow"><span>Total</span><span class="dval">${num(d.todayRuns)}</span></div>
146 <div class="drow"><span>Succeeded</span><span class="dval pos">${num(d.todaySucceeded)}</span></div>
147 <div class="drow"><span>Failed / T-O</span><span class="dval ${d.todayFailed > 0 ? 'neg' : ''}">${num(d.todayFailed)}</span></div>
148 <div class="drow"><span>Success Rate</span><span class="dval ${d.todaySuccessRate >= 90 ? 'pos' : d.todaySuccessRate < 50 ? 'neg' : 'warn'}">${num(d.todaySuccessRate, 1)}%</span></div>
149 </div>
150
151 <div class="dsec">
152 <div class="dsec-head">Efficiency</div>
153 <div class="drow"><span>Cost / 1k Results</span><span class="dval">${money(d.costPer1000Results, 4)}</span></div>
154 <div class="drow"><span>Daily Results avg</span><span class="dval">${num(d.dailyResults?.avg, 0)}</span></div>
155 <div class="drow"><span>Results min / max</span><span class="dval">${num(d.dailyResults?.min)} / ${num(d.dailyResults?.max)}</span></div>
156 <div class="drow"><span>Daily Runs avg</span><span class="dval">${num(d.dailyRuns?.avg, 1)}</span></div>
157 <div class="drow"><span>Runs min / max</span><span class="dval">${num(d.dailyRuns?.min)} / ${num(d.dailyRuns?.max)}</span></div>
158 </div>
159
160 </div>
161 </div>
162</div>`;
163}
164
165
166function getSectionInfo(status) {
167 const info = {
168 red: '<strong>Critical:</strong> Actors with zero runs today, low success rates, or recently removed. Check logs immediately.',
169 yellow: '<strong>Watch:</strong> Revenue shifted >20% or run volume changed significantly. Monitor before escalating.',
170 green: '<strong>Healthy:</strong> Running smoothly with consistent revenue and high success rates.',
171 };
172 return info[status] || '';
173}
174
175function renderSection(actors, status, label, openByDefault) {
176 if (actors.length === 0) return '';
177 const infoText = getSectionInfo(status);
178 return `
179<div class="section">
180 <div class="sec-hdr" onclick="toggleSec('${status}')">
181 <span class="sec-pip pip-${status}"></span>
182 <span class="sec-label">${esc(label)}</span>
183 <span class="sec-count">${actors.length}</span>
184 <svg class="sec-chev" id="sc-${status}" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"${openByDefault ? '' : ' style="transform:rotate(-90deg)"'}>
185 <polyline points="5 8 10 13 15 8"/>
186 </svg>
187 </div>
188 <div class="sec-body" id="sec-${status}"${openByDefault ? '' : ' style="display:none"'}>
189 <div class="sec-info">${infoText}</div>
190 ${actors.map(renderActorRow).join('\n')}
191 </div>
192</div>`;
193}
194
195
196export function generateReport(accountSummary, diffs, reportUrl = null, isFirstRun = false, fetchErrorCount = 0) {
197 const date = accountSummary.today;
198 const timeStr = new Date(accountSummary.capturedAt).toISOString().slice(11, 19);
199
200 const red = diffs.filter((d) => d.status === 'red');
201 const yellow = diffs.filter((d) => d.status === 'yellow');
202 const green = diffs.filter((d) => d.status === 'green');
203
204 const actorRunsTotal = diffs.reduce((s, d) => s + (d.todayRuns ?? 0), 0);
205 const actorSucceededTotal = diffs.reduce((s, d) => s + (d.todaySucceeded ?? 0), 0);
206 const successRate = actorRunsTotal > 0 ? (actorSucceededTotal / actorRunsTotal) * 100 : 0;
207 const runsTooltip = fetchErrorCount > 0
208 ? `Sum of today's runs across all actors shown. Note: ${fetchErrorCount} actor(s) failed to load and are excluded.`
209 : `Sum of today's runs across all actors shown below`;
210
211 const todayProfit = accountSummary.todayRevenue - accountSummary.todayCost;
212
213 const activeDiffs = diffs.filter((d) => !d.isRemoved);
214 const totalPayingMTD = activeDiffs.reduce((s, d) => s + (d.payingUsers ?? 0), 0);
215 const totalFreeMTD = activeDiffs.reduce((s, d) => s + (d.freeUsers ?? 0), 0);
216
217 const overviewCards = [
218 card('Total Revenue', money(accountSummary.totalRevenue), 'month to date', 'var(--c-green)', 'Sum of all actor earnings for the current calendar month'),
219 card('Total Cost', money(accountSummary.totalCost), 'month to date', 'var(--c-amber)', 'Platform compute costs deducted from your earnings'),
220 card('Net Profit', money(accountSummary.netProfit), 'month to date', 'var(--c-green)', 'Revenue minus cost. Your take-home for the month so far'),
221 card('Profit Margin', pct(accountSummary.overallMargin), 'month to date', 'var(--c-blue)', 'Net profit as a percentage of total revenue'),
222 card('Revenue Today', money(accountSummary.todayRevenue), 'today', 'var(--c-teal)', 'Revenue earned from actor runs today'),
223 card('Cost Today', money(accountSummary.todayCost), 'today', 'var(--c-amber)', 'Platform compute costs incurred today'),
224 card('Profit Today', money(todayProfit), 'today', todayProfit >= 0 ? 'var(--c-green)' : 'var(--c-red)', 'Revenue minus cost for today'),
225 card('Runs Today', num(actorRunsTotal), null, 'var(--c-blue)', runsTooltip),
226 card('Success Rate', `${num(successRate, 1)}%`, 'today', successRate >= 90 ? 'var(--c-green)' : 'var(--c-amber)', 'Percentage of today\'s runs that completed successfully'),
227 card('Paying Users MTD', num(totalPayingMTD), 'month to date', 'var(--c-teal)', 'Cumulative unique paying users across all actors this month'),
228 card('Free Users MTD', num(totalFreeMTD), 'month to date', 'var(--c-blue)', 'Cumulative unique free users across all actors this month'),
229 card('Paying Active Today', num(accountSummary.todayPayingUsers), 'ran actors today', 'var(--c-teal)', 'Unique paying users who ran at least one of your actors today'),
230 card('Free Active Today', num(accountSummary.todayFreeUsers), 'ran actors today', 'var(--c-muted)', 'Unique free-tier users who ran at least one of your actors today'),
231 ].join('\n');
232
233 const revSplit = revenueBar(
234 accountSummary.payingRevenueMTD ?? 0,
235 accountSummary.freeRevenueMTD ?? 0,
236 accountSummary.todayPayingRevenue ?? 0,
237 accountSummary.todayFreeRevenue ?? 0,
238 );
239
240 const firstRunBanner = isFirstRun ? `
241<div class="frb">
242 <div class="frb-icon">◈</div>
243 <div class="frb-body">
244 <div class="frb-title">Baseline snapshot captured — this is your first run</div>
245 <div class="frb-text">Today's data has been saved. Status indicators, revenue deltas, and trend comparisons will appear on your <strong>next run</strong> once there is a previous snapshot to compare against.</div>
246 </div>
247</div>` : '';
248
249 const sections = [
250 renderSection(red, 'red', 'Needs Attention', true),
251 renderSection(yellow, 'yellow', 'Watch Closely', true),
252 renderSection(green, 'green', 'On Track', false),
253 ].join('\n');
254
255 return `<!DOCTYPE html>
256<html lang="en">
257<head>
258<meta charset="UTF-8">
259<meta name="viewport" content="width=device-width, initial-scale=1">
260<title>Apify Monitor — ${esc(date)}</title>
261<style>
262@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=DM+Mono:wght@400;500&display=swap');
263
264:root {
265 --bg: #0C0E14;
266 --bg-2: #13151E;
267 --bg-3: #191C28;
268 --bg-4: #1E2232;
269 --border: rgba(255,255,255,.07);
270 --border-md: rgba(255,255,255,.12);
271 --border-hi: rgba(255,255,255,.20);
272
273 --text: #F0F2FF;
274 --text-2: #8B91B0;
275 --text-3: #454C6B;
276 --text-4: #2C3050;
277
278 --c-blue: #5B8DEF;
279 --c-teal: #34D1B5;
280 --c-green: #3DD68C;
281 --c-red: #F0556A;
282 --c-amber: #F5A623;
283 --c-purple: #9B7BFF;
284 --c-muted: #5A6080;
285
286 --green-dim: rgba(61,214,140,.10);
287 --green-border: rgba(61,214,140,.25);
288 --red-dim: rgba(240,85,106,.10);
289 --red-border: rgba(240,85,106,.25);
290 --amber-dim: rgba(245,166,35,.10);
291 --amber-border: rgba(245,166,35,.25);
292
293 --pos: #3DD68C;
294 --neg: #F0556A;
295 --warn: #F5A623;
296
297 --r: 6px;
298 --sans: 'DM Sans', 'Segoe UI', system-ui, sans-serif;
299 --mono: 'DM Mono', 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
300}
301
302*,*::before,*::after { box-sizing:border-box; margin:0; padding:0; }
303html { scroll-behavior:smooth; }
304body {
305 font-family: var(--sans);
306 font-size: 13px;
307 line-height: 1.5;
308 color: var(--text);
309 background: var(--bg);
310 -webkit-font-smoothing: antialiased;
311}
312
313/* ── HEADER ── */
314.hdr {
315 background: var(--bg-2);
316 border-bottom: 1px solid var(--border-md);
317 position: sticky; top: 0; z-index: 50;
318}
319.hdr-inner {
320 max-width: 1280px; margin: 0 auto;
321 padding: 0 32px;
322 height: 64px;
323 display: flex; align-items: center; gap: 20px;
324}
325.hdr-brand {
326 display: flex; align-items: center; gap: 10px;
327}
328.hdr-logo {
329 width: 28px; height: 28px;
330 background: linear-gradient(135deg, var(--c-teal), var(--c-blue));
331 border-radius: 6px;
332 display: flex; align-items: center; justify-content: center;
333 font-size: 14px; font-weight: 800; color: #fff;
334 font-family: var(--mono);
335 letter-spacing: -1px;
336 flex-shrink: 0;
337}
338.hdr-name {
339 font-size: 11px; font-weight: 700; letter-spacing: 2px;
340 text-transform: uppercase; color: var(--text-2);
341}
342.hdr-sep {
343 width: 1px; height: 24px; background: var(--border-md);
344}
345.hdr-title {
346 font-size: 16px; font-weight: 700; color: var(--text);
347 letter-spacing: -.2px;
348}
349.hdr-right {
350 margin-left: auto;
351 display: flex; align-items: center; gap: 16px;
352}
353.hdr-ts {
354 font-family: var(--mono); font-size: 12px; color: var(--text-3);
355}
356.hdr-link {
357 font-size: 12px; font-weight: 600; color: var(--c-blue);
358 text-decoration: none; display: flex; align-items: center; gap: 4px;
359 padding: 6px 12px;
360 border: 1px solid rgba(91,141,239,.3);
361 border-radius: var(--r);
362 transition: background .15s, border-color .15s;
363}
364.hdr-link:hover { background: rgba(91,141,239,.08); border-color: rgba(91,141,239,.5); text-decoration: none; }
365
366/* ── LAYOUT ── */
367.wrap { max-width: 1280px; margin: 0 auto; padding: 28px 32px 80px; }
368
369/* ── SECTION TITLES ── */
370.blk-title {
371 font-size: 10px; font-weight: 700; letter-spacing: 2.5px;
372 text-transform: uppercase; color: var(--text-3);
373 margin: 32px 0 14px;
374 display: flex; align-items: center; gap: 12px;
375}
376.blk-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
377
378/* ── METRIC CARDS ── */
379.cards {
380 display: grid;
381 grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
382 gap: 10px;
383}
384.card {
385 background: var(--bg-2);
386 border: 1px solid var(--border);
387 border-radius: var(--r);
388 padding: 16px 18px 14px;
389 position: relative;
390 overflow: hidden;
391 transition: border-color .15s, transform .15s;
392 cursor: default;
393}
394.card::before {
395 content: '';
396 position: absolute; top: 0; left: 0; right: 0;
397 height: 2px;
398 background: var(--ac, var(--c-blue));
399}
400.card:hover { border-color: var(--border-md); transform: translateY(-1px); }
401.card-label {
402 font-size: 10px; font-weight: 700; letter-spacing: 1.5px;
403 text-transform: uppercase; color: var(--text-3);
404 margin-bottom: 10px;
405}
406.card-value {
407 font-family: var(--mono);
408 font-size: 26px; font-weight: 500; line-height: 1;
409 color: var(--text); letter-spacing: -.5px;
410}
411.card-sub {
412 font-size: 10px; color: var(--text-3); margin-top: 8px;
413 text-transform: uppercase; letter-spacing: .8px;
414}
415
416/* ── REVENUE SPLIT PANEL ── */
417.rev-split {
418 background: var(--bg-2);
419 border: 1px solid var(--border);
420 border-radius: var(--r);
421 padding: 20px 24px;
422 margin-top: 10px;
423}
424.rev-split-row {
425 display: grid;
426 grid-template-columns: 1fr 1fr;
427 gap: 32px;
428}
429@media(max-width:640px) { .rev-split-row { grid-template-columns: 1fr; } }
430.rev-split-head {
431 font-size: 10px; font-weight: 700; letter-spacing: 1.5px;
432 text-transform: uppercase; color: var(--text-3);
433 margin-bottom: 12px;
434}
435.rev-split-bar {
436 height: 8px;
437 border-radius: 4px;
438 background: var(--bg-4);
439 overflow: hidden;
440 display: flex;
441 margin-bottom: 12px;
442}
443.rev-bar-fill {
444 height: 100%;
445 transition: width .4s ease;
446 position: relative; cursor: help;
447}
448.rev-bar-paying { background: var(--c-teal); }
449.rev-bar-free { background: var(--c-blue); opacity: .55; }
450.rev-split-legend {
451 display: flex; gap: 20px; align-items: center;
452}
453.legend-dot {
454 width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
455}
456.leg-paying { background: var(--c-teal); }
457.leg-free { background: var(--c-blue); opacity: .7; }
458.legend-label {
459 font-size: 12px; color: var(--text-2);
460 display: flex; align-items: center; gap: 6px;
461}
462.legend-label strong { font-family: var(--mono); font-weight: 500; color: var(--text); font-size: 13px; }
463.legend-label em { font-style: normal; font-family: var(--mono); font-size: 11px; color: var(--text-3); }
464
465/* ── FIRST RUN BANNER ── */
466.frb {
467 display: flex; align-items: flex-start; gap: 16px;
468 margin: 16px 0 0;
469 padding: 18px 22px;
470 background: var(--bg-3);
471 border: 1px solid var(--border-md);
472 border-left: 3px solid var(--c-blue);
473 border-radius: var(--r);
474}
475.frb-icon {
476 font-size: 20px; color: var(--c-blue); flex-shrink: 0; line-height: 1.4;
477 font-family: var(--mono);
478}
479.frb-title { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
480.frb-text { font-size: 13px; color: var(--text-2); line-height: 1.6; }
481.frb-text strong { color: var(--text); font-weight: 600; }
482
483/* ── STATUS PILLS ── */
484.status-row { display: flex; gap: 8px; margin: 24px 0 28px; }
485.s-pill {
486 display: flex; align-items: center; gap: 10px;
487 padding: 8px 18px;
488 border-radius: var(--r);
489 border: 1px solid var(--border);
490 background: var(--bg-2);
491 font-size: 12px; font-weight: 600; color: var(--text-2);
492 cursor: default;
493}
494.s-pill-num {
495 font-family: var(--mono); font-size: 22px; font-weight: 500;
496 line-height: 1;
497}
498.s-pill.s-red { border-color: var(--red-border); background: var(--red-dim); }
499.s-pill.s-red .s-pill-num { color: var(--c-red); }
500.s-pill.s-amber { border-color: var(--amber-border); background: var(--amber-dim); }
501.s-pill.s-amber .s-pill-num { color: var(--c-amber); }
502.s-pill.s-green { border-color: var(--green-border); background: var(--green-dim); }
503.s-pill.s-green .s-pill-num { color: var(--c-green); }
504
505/* ── SECTION ── */
506.section { margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; }
507
508.sec-hdr {
509 display: flex; align-items: center; gap: 10px;
510 padding: 12px 18px;
511 background: var(--bg-2);
512 cursor: pointer; user-select: none;
513 transition: background .12s;
514}
515.sec-hdr:hover { background: var(--bg-3); }
516
517.sec-pip {
518 width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
519}
520.pip-red { background: var(--c-red); box-shadow: 0 0 5px var(--c-red); }
521.pip-yellow { background: var(--c-amber); box-shadow: 0 0 5px var(--c-amber); }
522.pip-green { background: var(--c-green); box-shadow: 0 0 5px var(--c-green); }
523
524.sec-label { font-size: 13px; font-weight: 700; color: var(--text); flex: 1; }
525.sec-count {
526 font-family: var(--mono); font-size: 12px; font-weight: 500;
527 color: var(--text-3);
528 background: var(--bg-4); padding: 2px 10px; border-radius: 20px;
529}
530.sec-chev { width: 14px; height: 14px; color: var(--text-3); transition: transform .2s; }
531
532.sec-body { border-top: 1px solid var(--border); }
533.sec-info {
534 padding: 10px 18px;
535 background: var(--bg-3);
536 border-bottom: 1px solid var(--border);
537 font-size: 12px; color: var(--text-2); line-height: 1.5;
538}
539.sec-info strong { color: var(--text); font-weight: 600; }
540
541/* ── ACTOR ROWS ── */
542.arow {
543 border-left: 3px solid transparent;
544 border-bottom: 1px solid var(--border);
545 transition: background .1s;
546}
547.arow:last-child { border-bottom: none; }
548.arow[data-status="red"] { border-left-color: var(--c-red); }
549.arow[data-status="yellow"] { border-left-color: var(--c-amber); }
550.arow[data-status="green"] { border-left-color: var(--c-green); }
551
552.arow-hdr {
553 display: flex; align-items: center; gap: 12px;
554 padding: 11px 18px 11px 14px;
555 cursor: pointer; user-select: none;
556 background: var(--bg-2);
557 transition: background .1s;
558}
559.arow-hdr:hover { background: var(--bg-3); }
560
561.status-pip {
562 width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0;
563}
564
565.aname {
566 flex: 1; font-size: 14px; font-weight: 600; color: var(--text);
567 display: flex; align-items: center; gap: 8px;
568 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
569}
570.asignal {
571 font-family: var(--mono); font-size: 12px; font-weight: 500;
572 white-space: nowrap; padding: 2px 0;
573}
574.sig-red { color: var(--c-red); }
575.sig-yellow { color: var(--c-amber); }
576.sig-green { color: var(--pos); }
577
578.chevron { width: 14px; height: 14px; color: var(--text-3); transition: transform .2s; flex-shrink: 0; }
579
580/* ── BADGES ── */
581.badge {
582 font-size: 9px; font-weight: 800; letter-spacing: 1px;
583 padding: 2px 6px; border-radius: 3px;
584 text-transform: uppercase; flex-shrink: 0;
585}
586.badge-new { background: var(--green-dim); color: var(--c-green); border: 1px solid var(--green-border); }
587.badge-rm { background: var(--red-dim); color: var(--c-red); border: 1px solid var(--red-border); }
588
589/* ── ACTOR DETAIL ── */
590.adetail {
591 display: none;
592 padding: 20px;
593 background: var(--bg);
594 border-top: 1px solid var(--border);
595}
596.detail-grid {
597 display: grid;
598 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
599 gap: 20px;
600}
601.dsec-head {
602 font-size: 9px; font-weight: 700; letter-spacing: 2px;
603 text-transform: uppercase; color: var(--text-3);
604 margin-bottom: 10px; padding-bottom: 8px;
605 border-bottom: 1px solid var(--border);
606}
607.drow {
608 display: flex; justify-content: space-between; align-items: baseline;
609 padding: 5px 0;
610 border-bottom: 1px solid var(--border);
611 font-size: 13px;
612}
613.drow:last-child { border-bottom: none; }
614.drow > span:first-child { color: var(--text-2); }
615.dval {
616 font-family: var(--mono); font-size: 14px; font-weight: 500;
617 color: var(--text); text-align: right;
618}
619.dval.pos { color: var(--pos); }
620.dval.neg { color: var(--neg); }
621.dval.warn { color: var(--warn); }
622.dval em {
623 display: block; font-style: normal;
624 font-size: 11px; font-weight: 500;
625 font-family: var(--mono);
626}
627
628/* ── TOOLTIP ── */
629[data-tip] { position: relative; cursor: help; }
630[data-tip]:hover::after {
631 content: attr(data-tip);
632 position: absolute; bottom: calc(100% + 7px); left: 50%; transform: translateX(-50%);
633 background: var(--bg-4); color: var(--text-2);
634 border: 1px solid var(--border-md);
635 border-radius: var(--r); padding: 7px 11px;
636 font-size: 11px; font-weight: 400; line-height: 1.5;
637 white-space: nowrap; z-index: 200;
638 font-family: var(--sans); pointer-events: none;
639 box-shadow: 0 8px 24px rgba(0,0,0,.5);
640 max-width: 280px; white-space: normal; text-align: center;
641}
642[data-tip]:hover::before {
643 content: '';
644 position: absolute; bottom: calc(100% + 2px); left: 50%; transform: translateX(-50%);
645 border: 5px solid transparent; border-top-color: var(--bg-4);
646 z-index: 201; pointer-events: none;
647}
648</style>
649</head>
650<body>
651
652<div class="hdr">
653 <div class="hdr-inner">
654 <div class="hdr-brand">
655 <div class="hdr-logo">A∿</div>
656 <div class="hdr-name">Apify Monitor</div>
657 </div>
658 <div class="hdr-sep"></div>
659 <div class="hdr-title">Performance Report — ${esc(date)}</div>
660 <div class="hdr-right">
661 <div class="hdr-ts">Generated ${esc(timeStr)} UTC</div>
662 ${reportUrl ? `<a class="hdr-link" href="${esc(reportUrl)}">Open ↗</a>` : ''}
663 </div>
664 </div>
665</div>
666
667<div class="wrap">
668
669 <div class="blk-title">Account Overview</div>
670 <div class="cards">${overviewCards}</div>
671
672 <div class="blk-title">Revenue Source</div>
673 ${revSplit}
674
675 ${firstRunBanner}
676
677 <div class="status-row">
678 <div class="s-pill s-red">
679 <div class="s-pill-num">${red.length}</div>
680 <div>Needs Attention</div>
681 </div>
682 <div class="s-pill s-amber">
683 <div class="s-pill-num">${yellow.length}</div>
684 <div>Watch Closely</div>
685 </div>
686 <div class="s-pill s-green">
687 <div class="s-pill-num">${green.length}</div>
688 <div>On Track</div>
689 </div>
690 </div>
691
692 <div class="blk-title">Actor Breakdown</div>
693 ${sections}
694
695</div>
696
697<script>
698function toggleActor(id) {
699 var el = document.getElementById('d-' + id);
700 var chev = document.getElementById('chev-' + id);
701 var open = el.style.display === 'block';
702 el.style.display = open ? 'none' : 'block';
703 chev.style.transform = open ? '' : 'rotate(180deg)';
704}
705function toggleSec(status) {
706 var body = document.getElementById('sec-' + status);
707 var chev = document.getElementById('sc-' + status);
708 var open = body.style.display !== 'none';
709 body.style.display = open ? 'none' : '';
710 chev.style.transform = open ? 'rotate(-90deg)' : '';
711}
712// Staggered card entrance
713document.querySelectorAll('.card').forEach(function(c, i) {
714 c.style.opacity = '0';
715 c.style.transform = 'translateY(8px)';
716 setTimeout(function() {
717 c.style.transition = 'opacity .3s ease, transform .3s ease';
718 c.style.opacity = '1';
719 c.style.transform = '';
720 }, 40 + i * 30);
721});
722</script>
723</body>
724</html>`;
725}