Files
wehub-resource-sync bb5c75ce05
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:58 +08:00

755 lines
26 KiB
JavaScript

/**
* Cloudflare Worker: Pulse — Weekly KPI Report
*
* Collects metrics from GitHub, Discord, Supabase, npm, and Google Analytics,
* then sends a consolidated report via Telegram every Sunday at 14:00 UTC.
*
* All source collectors are in this single file (no npm dependencies).
*
* Error tracking: set SENTRY_DSN (wrangler secret put SENTRY_DSN) to report
* failed collectors to the "aitmpl-workers" Sentry project. Optional — the
* worker degrades gracefully (console.error only) if it's not configured.
*/
import { reportError, checkIn } from './sentry.js';
const REPO = 'davila7/claude-code-templates';
const NPM_PACKAGE = 'claude-code-templates';
const GITHUB_API = 'https://api.github.com';
const DISCORD_API = 'https://discord.com/api/v10';
const NPM_API = 'https://api.npmjs.org';
const GA4_API = 'https://analyticsdata.googleapis.com/v1beta';
const TELEGRAM_API = 'https://api.telegram.org';
const MAX_MESSAGE_LENGTH = 4096;
// ─── Entry Points ────────────────────────────────────────────────────────────
export default {
async scheduled(event, env, ctx) {
console.log('📊 Pulse: Starting weekly report (cron)...');
await runReport(env);
},
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Manual trigger
if (url.pathname === '/trigger' && request.method === 'POST') {
const authHeader = request.headers.get('Authorization');
if (!env.TRIGGER_SECRET || authHeader !== `Bearer ${env.TRIGGER_SECRET}`) {
return jsonResponse({ error: 'Unauthorized' }, 401);
}
const onlySource = url.searchParams.get('source');
const sendTelegram = url.searchParams.get('send') !== 'false';
const result = await runReport(env, { onlySource, sendTelegram });
return jsonResponse(result);
}
// Status endpoint
if (url.pathname === '/status') {
return jsonResponse({
status: 'running',
worker: 'pulse-weekly-report',
schedule: 'Sundays 14:00 UTC',
sources: ['github', 'discord', 'downloads', 'npm', 'analytics']
});
}
return new Response(
'Pulse — Weekly KPI Report Worker\n\nEndpoints:\n- POST /trigger (requires auth)\n- GET /status',
{ headers: { 'Content-Type': 'text/plain' } }
);
}
};
// ─── Report Runner ───────────────────────────────────────────────────────────
async function runReport(env, opts = {}) {
const { onlySource, sendTelegram = true } = opts;
const checkInId = await checkIn(env, 'pulse-weekly-report', 'in_progress');
let results = {};
if (onlySource) {
const collectors = {
github: collectGitHub,
discord: collectDiscord,
downloads: collectDownloads,
npm: collectNpm,
analytics: collectAnalytics
};
const collector = collectors[onlySource];
if (!collector) {
return { error: `Unknown source: ${onlySource}`, available: Object.keys(collectors) };
}
results[onlySource] = await collector(env);
} else {
const [github, discord, downloads, npm] = await Promise.all([
collectGitHub(env),
collectDiscord(env),
collectDownloads(env),
collectNpm(env)
]);
results = { github, discord, downloads, npm };
}
// Report any failed collectors to Sentry (they still degrade gracefully
// into "Unavailable" in the rendered report — this just makes failures visible).
const failedSources = Object.entries(results).filter(([, v]) => v && v.error);
for (const [source, value] of failedSources) {
await reportError(env, `pulse collector "${source}" failed: ${value.error}`, {
worker: 'pulse-weekly-report',
source
});
}
const reportText = formatReport(results);
let telegramResult = null;
if (sendTelegram) {
telegramResult = await sendToTelegram(env, reportText);
}
await checkIn(env, 'pulse-weekly-report', failedSources.length > 0 ? 'error' : 'ok', checkInId);
return {
success: true,
sources: Object.fromEntries(
Object.entries(results).map(([k, v]) => [k, v.error ? 'error' : 'ok'])
),
report: reportText,
telegram: sendTelegram ? telegramResult : 'skipped'
};
}
// ─── Source: GitHub ──────────────────────────────────────────────────────────
async function collectGitHub(env) {
try {
if (!env.GITHUB_TOKEN) return { error: 'GITHUB_TOKEN not configured' };
const headers = {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${env.GITHUB_TOKEN}`,
'User-Agent': 'Pulse-Weekly-Report/1.0'
};
const since = weekAgo();
const sinceISO = since.toISOString();
const [repoRes, issuesRes, prsRes] = await Promise.all([
fetchJSON(`${GITHUB_API}/repos/${REPO}`, { headers }),
fetchJSON(`${GITHUB_API}/repos/${REPO}/issues?state=all&since=${sinceISO}&per_page=100`, { headers }),
fetchJSON(`${GITHUB_API}/repos/${REPO}/pulls?state=all&sort=updated&direction=desc&per_page=100`, { headers })
]);
const repo = repoRes;
// Filter issues (exclude PRs) created this week
const weekIssues = issuesRes.filter(
i => !i.pull_request && new Date(i.created_at) >= since
);
const closedThisWeek = issuesRes.filter(
i => !i.pull_request && i.state === 'closed' && i.closed_at && new Date(i.closed_at) >= since
);
// Filter PRs from this week
const weekPRs = prsRes.filter(pr => new Date(pr.created_at) >= since);
const mergedPRs = prsRes.filter(pr => pr.merged_at && new Date(pr.merged_at) >= since);
// Count new stars this week — paginate to the last page to get most recent
let starsWeek = null;
try {
const starHeaders = { ...headers, Accept: 'application/vnd.github.v3.star+json' };
// HEAD request to get total pages from Link header
const headRes = await fetch(`${GITHUB_API}/repos/${REPO}/stargazers?per_page=100`, {
method: 'HEAD',
headers: starHeaders
});
const linkHeader = headRes.headers.get('Link') || '';
const lastMatch = linkHeader.match(/page=(\d+)>; rel="last"/);
const lastPage = lastMatch ? parseInt(lastMatch[1]) : 1;
// Fetch last few pages to find stars from this week
let recentStars = [];
for (let page = lastPage; page >= Math.max(1, lastPage - 2); page--) {
const pageRes = await fetchJSON(
`${GITHUB_API}/repos/${REPO}/stargazers?per_page=100&page=${page}`,
{ headers: starHeaders }
);
recentStars = recentStars.concat(pageRes);
}
starsWeek = recentStars.filter(s => new Date(s.starred_at) >= since).length;
} catch { /* stargazers with timestamps may not always work */ }
// Estimate new forks this week
let forksWeek = null;
try {
const forkRes = await fetchJSON(
`${GITHUB_API}/repos/${REPO}/forks?sort=newest&per_page=100`,
{ headers }
);
forksWeek = forkRes.filter(f => new Date(f.created_at) >= since).length;
} catch { /* ignore */ }
return {
stars: repo.stargazers_count,
starsWeek,
forks: repo.forks_count,
forksWeek,
openIssues: repo.open_issues_count - weekPRs.filter(pr => pr.state === 'open').length,
issuesOpenedWeek: weekIssues.length,
issuesClosedWeek: closedThisWeek.length,
prsOpenedWeek: weekPRs.length,
prsMergedWeek: mergedPRs.length
};
} catch (error) {
console.error('GitHub source error:', error.message);
return { error: error.message };
}
}
// ─── Source: Discord ─────────────────────────────────────────────────────────
async function collectDiscord(env) {
try {
if (!env.DISCORD_BOT_TOKEN || !env.DISCORD_GUILD_ID) {
return { error: 'DISCORD_BOT_TOKEN or DISCORD_GUILD_ID not configured' };
}
const headers = {
Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`,
'Content-Type': 'application/json'
};
const guildId = env.DISCORD_GUILD_ID;
// Fetch guild info with approximate counts
const guild = await fetchJSON(
`${DISCORD_API}/guilds/${guildId}?with_counts=true`,
{ headers }
);
const totalMembers = guild.approximate_member_count || 0;
const activeMembers = guild.approximate_presence_count || 0;
// Estimate new members this week from audit log
let newMembersWeek = null;
try {
const since = weekAgo();
const auditRes = await fetchJSON(
`${DISCORD_API}/guilds/${guildId}/audit-logs?action_type=1&limit=100`,
{ headers }
);
const entries = auditRes.audit_log_entries || [];
newMembersWeek = entries.filter(e => {
const timestamp = Number(BigInt(e.id) >> 22n) + 1420070400000;
return timestamp >= since.getTime();
}).length;
} catch { /* Audit log access may not be available */ }
// Count messages in last 7 days across text channels
let messagesWeek = null;
try {
const channels = await fetchJSON(
`${DISCORD_API}/guilds/${guildId}/channels`,
{ headers }
);
const textChannels = channels.filter(c => c.type === 0);
const since = weekAgo();
const sinceSnowflake = String((BigInt(since.getTime()) - 1420070400000n) << 22n);
let total = 0;
// Sample up to 10 channels to avoid rate limits
for (const ch of textChannels.slice(0, 10)) {
try {
const msgs = await fetchJSON(
`${DISCORD_API}/channels/${ch.id}/messages?after=${sinceSnowflake}&limit=100`,
{ headers }
);
total += msgs.length;
} catch { /* Channel may not be accessible */ }
}
messagesWeek = total;
} catch { /* ignore */ }
return { totalMembers, activeMembers, newMembersWeek, messagesWeek };
} catch (error) {
console.error('Discord source error:', error.message);
return { error: error.message };
}
}
// ─── Source: Supabase Downloads (PostgREST) ──────────────────────────────────
async function collectDownloads(env) {
try {
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
return { error: 'SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY not configured' };
}
const baseUrl = `${env.SUPABASE_URL}/rest/v1`;
const headers = {
apikey: env.SUPABASE_SERVICE_ROLE_KEY,
Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
'Content-Type': 'application/json',
Prefer: 'count=exact'
};
const since = weekAgo();
const sinceISO = since.toISOString();
// Total downloads all-time (HEAD request with count)
const totalRes = await fetch(`${baseUrl}/component_downloads?select=*`, {
method: 'HEAD',
headers
});
const totalAllTime = parseInt(totalRes.headers.get('content-range')?.split('/')[1]) || 0;
// Downloads this week (HEAD request with count + filter)
const weekRes = await fetch(
`${baseUrl}/component_downloads?select=*&download_timestamp=gte.${sinceISO}`,
{ method: 'HEAD', headers }
);
const totalWeek = parseInt(weekRes.headers.get('content-range')?.split('/')[1]) || 0;
// Get weekly downloads with component info for top components + type breakdown
const weekDownloads = await fetchJSON(
`${baseUrl}/component_downloads?select=component_name,component_type&download_timestamp=gte.${sinceISO}&limit=10000`,
{ headers: { ...headers, Prefer: '' } }
);
let topComponents = [];
let byType = {};
if (weekDownloads && weekDownloads.length > 0) {
const componentCounts = {};
const typeCounts = {};
for (const row of weekDownloads) {
componentCounts[row.component_name] = (componentCounts[row.component_name] || 0) + 1;
const t = row.component_type || 'unknown';
typeCounts[t] = (typeCounts[t] || 0) + 1;
}
topComponents = Object.entries(componentCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, count]) => ({ name, count }));
const total = weekDownloads.length || 1;
for (const [type, count] of Object.entries(typeCounts)) {
byType[type] = (count / total) * 100;
}
}
// Top 5 countries this week
let topCountries = [];
const countryData = await fetchJSON(
`${baseUrl}/component_downloads?select=country&download_timestamp=gte.${sinceISO}&country=not.is.null&limit=10000`,
{ headers: { ...headers, Prefer: '' } }
);
if (countryData && countryData.length > 0) {
const countryCounts = {};
for (const row of countryData) {
countryCounts[row.country] = (countryCounts[row.country] || 0) + 1;
}
const total = countryData.length || 1;
topCountries = Object.entries(countryCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([country, count]) => ({ country, pct: (count / total) * 100 }));
}
return { totalAllTime, totalWeek, topComponents, byType, topCountries };
} catch (error) {
console.error('Supabase downloads source error:', error.message);
return { error: error.message };
}
}
// ─── Source: npm Downloads ───────────────────────────────────────────────────
async function collectNpm(env) {
try {
const headers = { 'User-Agent': 'Pulse-Weekly-Report/1.0' };
// Weekly downloads (last 7 days)
const weekData = await fetchJSON(
`${NPM_API}/downloads/point/last-week/${NPM_PACKAGE}`,
{ headers }
);
// Monthly downloads (last 30 days) for context
const monthData = await fetchJSON(
`${NPM_API}/downloads/point/last-month/${NPM_PACKAGE}`,
{ headers }
);
// Daily breakdown for the last week (to show trend)
const end = new Date();
const start = weekAgo();
const startStr = start.toISOString().split('T')[0];
const endStr = end.toISOString().split('T')[0];
const rangeData = await fetchJSON(
`${NPM_API}/downloads/range/${startStr}:${endStr}/${NPM_PACKAGE}`,
{ headers }
);
// Find peak day
let peakDay = null;
let peakDownloads = 0;
if (rangeData.downloads) {
for (const day of rangeData.downloads) {
if (day.downloads > peakDownloads) {
peakDownloads = day.downloads;
peakDay = day.day;
}
}
}
// Calculate daily average
const dailyAvg = weekData.downloads > 0
? Math.round(weekData.downloads / 7)
: 0;
return {
weeklyDownloads: weekData.downloads || 0,
monthlyDownloads: monthData.downloads || 0,
dailyAvg,
peakDay,
peakDownloads
};
} catch (error) {
console.error('npm source error:', error.message);
return { error: error.message };
}
}
// ─── Source: Google Analytics (GA4) ──────────────────────────────────────────
async function collectAnalytics(env) {
try {
if (!env.GA_PROPERTY_ID || !env.GA_SERVICE_ACCOUNT_JSON) {
return { error: 'GA_PROPERTY_ID or GA_SERVICE_ACCOUNT_JSON not configured' };
}
const accessToken = await getGAAccessToken(env);
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
};
const since = weekAgo();
const startDate = since.toISOString().split('T')[0];
const endDate = new Date().toISOString().split('T')[0];
const propertyId = env.GA_PROPERTY_ID;
const [summaryRes, pagesRes, referrersRes] = await Promise.all([
fetchJSON(`${GA4_API}/properties/${propertyId}:runReport`, {
method: 'POST',
headers,
body: JSON.stringify({
dateRanges: [{ startDate, endDate }],
metrics: [{ name: 'activeUsers' }, { name: 'screenPageViews' }]
})
}),
fetchJSON(`${GA4_API}/properties/${propertyId}:runReport`, {
method: 'POST',
headers,
body: JSON.stringify({
dateRanges: [{ startDate, endDate }],
dimensions: [{ name: 'pagePath' }],
metrics: [{ name: 'screenPageViews' }],
orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
limit: 10
})
}),
fetchJSON(`${GA4_API}/properties/${propertyId}:runReport`, {
method: 'POST',
headers,
body: JSON.stringify({
dateRanges: [{ startDate, endDate }],
dimensions: [{ name: 'sessionSource' }],
metrics: [{ name: 'sessions' }],
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
limit: 10
})
})
]);
const summaryRow = summaryRes.rows?.[0];
const visitors = summaryRow ? parseInt(summaryRow.metricValues[0].value) : 0;
const pageviews = summaryRow ? parseInt(summaryRow.metricValues[1].value) : 0;
const topPages = (pagesRes.rows || []).map(row => ({
path: row.dimensionValues[0].value,
views: parseInt(row.metricValues[0].value)
}));
const referrerRows = referrersRes.rows || [];
const totalSessions = referrerRows.reduce((sum, r) => sum + parseInt(r.metricValues[0].value), 0) || 1;
const topReferrers = referrerRows.map(row => ({
source: row.dimensionValues[0].value,
sessions: parseInt(row.metricValues[0].value),
pct: (parseInt(row.metricValues[0].value) / totalSessions) * 100
}));
return { visitors, pageviews, topPages, topReferrers };
} catch (error) {
console.error('Google Analytics source error:', error.message);
return { error: error.message };
}
}
// GA4 JWT auth — build and sign JWT for service account
async function getGAAccessToken(env) {
const saJson = atob(env.GA_SERVICE_ACCOUNT_JSON);
const sa = JSON.parse(saJson);
const now = Math.floor(Date.now() / 1000);
const headerB64 = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const payloadB64 = base64url(JSON.stringify({
iss: sa.client_email,
scope: 'https://www.googleapis.com/auth/analytics.readonly',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600
}));
const signingInput = `${headerB64}.${payloadB64}`;
// Import RSA private key and sign
const pemContents = sa.private_key
.replace(/-----BEGIN PRIVATE KEY-----/, '')
.replace(/-----END PRIVATE KEY-----/, '')
.replace(/\n/g, '');
const keyData = Uint8Array.from(atob(pemContents), c => c.charCodeAt(0));
const cryptoKey = await crypto.subtle.importKey(
'pkcs8',
keyData.buffer,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign']
);
const signatureBuffer = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', cryptoKey, new TextEncoder().encode(signingInput));
const signatureB64 = base64url(signatureBuffer);
const jwt = `${signingInput}.${signatureB64}`;
// Exchange JWT for access token
const tokenRes = await fetchJSON('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`
});
return tokenRes.access_token;
}
// ─── Report Formatter ────────────────────────────────────────────────────────
function formatReport(results) {
const sections = [];
sections.push(`📊 Claude Code Templates - Weekly Report\n📅 ${getWeekRange()}`);
// Downloads
if (results.downloads?.error) {
sections.push(`DOWNLOADS\n└ Unavailable: ${results.downloads.error}`);
} else if (results.downloads) {
const dl = results.downloads;
const lines = [
`DOWNLOADS (aitmpl.com)`,
`├ Total: ${fmt(dl.totalAllTime)}${fmtDelta(dl.totalWeek)}`
];
if (dl.topComponents?.length > 0) {
lines.push(`├ Top: ${dl.topComponents[0].name} (${fmt(dl.topComponents[0].count)})`);
}
if (dl.byType && Object.keys(dl.byType).length > 0) {
const types = Object.entries(dl.byType).sort((a, b) => b[1] - a[1]).filter(([, pct]) => pct >= 1);
for (let i = 0; i < types.length; i++) {
const prefix = (i === types.length - 1 && dl.topComponents?.length === 0) ? '└' : '├';
lines.push(`${prefix} ${types[i][0]}: ${fmtPct(types[i][1])}`);
}
}
// fix last item to └
if (lines.length > 1) {
lines[lines.length - 1] = lines[lines.length - 1].replace('├', '└');
}
sections.push(lines.join('\n'));
}
// npm
if (results.npm?.error) {
sections.push(`NPM (npmjs.com)\n└ ⚠️ Unavailable: ${results.npm.error}`);
} else if (results.npm) {
const n = results.npm;
const lines = [
`NPM (npmjs.com)`,
`├ Weekly: ${fmt(n.weeklyDownloads)}`,
`├ Monthly: ${fmt(n.monthlyDownloads)}`,
`├ Daily avg: ${fmt(n.dailyAvg)}`
];
if (n.peakDay) {
const peakDate = new Date(n.peakDay);
const dayName = peakDate.toLocaleDateString('en-US', { weekday: 'short' });
lines.push(`└ Peak: ${dayName} (${fmt(n.peakDownloads)})`);
} else {
lines[lines.length - 1] = lines[lines.length - 1].replace('├', '└');
}
sections.push(lines.join('\n'));
}
// GitHub
if (results.github?.error) {
sections.push(`GITHUB\n└ Unavailable: ${results.github.error}`);
} else if (results.github) {
const g = results.github;
sections.push([
`GITHUB`,
`├ Stars: ${fmt(g.stars)}${fmtDelta(g.starsWeek)}`,
`├ Forks: ${fmt(g.forks)}${fmtDelta(g.forksWeek)}`,
`├ Issues: ${fmt(g.openIssues)} open (${fmt(g.issuesOpenedWeek)} new, ${fmt(g.issuesClosedWeek)} closed)`,
`└ PRs: ${fmt(g.prsOpenedWeek)} opened, ${fmt(g.prsMergedWeek)} merged`
].join('\n'));
}
// Discord
if (results.discord?.error) {
sections.push(`DISCORD\n└ Unavailable: ${results.discord.error}`);
} else if (results.discord) {
const d = results.discord;
sections.push([
`DISCORD`,
`├ Members: ${fmt(d.totalMembers)}${fmtDelta(d.newMembersWeek)}`,
`├ Active: ~${fmt(d.activeMembers)}`,
`└ Messages: ${fmt(d.messagesWeek)}`
].join('\n'));
}
return sections.join('\n\n');
}
// ─── Telegram Sender ─────────────────────────────────────────────────────────
async function sendToTelegram(env, text) {
const botToken = env.TELEGRAM_BOT_TOKEN;
const chatId = env.TELEGRAM_CHAT_ID;
if (!botToken || !chatId) {
throw new Error('Missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID');
}
const messages = splitMessage(text);
let sent = 0;
for (const msg of messages) {
const response = await fetch(`${TELEGRAM_API}/bot${botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: msg,
parse_mode: 'HTML',
disable_web_page_preview: true
})
});
const result = await response.json();
if (!result.ok) {
console.error('Telegram API error:', JSON.stringify(result));
return { success: false, error: result.description };
}
sent++;
}
console.log(`✅ Telegram: sent ${sent} message(s)`);
return { success: true, messagesSent: sent };
}
function splitMessage(text) {
if (text.length <= MAX_MESSAGE_LENGTH) return [text];
const messages = [];
const sections = text.split('\n\n');
let current = '';
for (const section of sections) {
if ((current + '\n\n' + section).length > MAX_MESSAGE_LENGTH) {
if (current) messages.push(current.trim());
current = section;
} else {
current = current ? current + '\n\n' + section : section;
}
}
if (current) messages.push(current.trim());
return messages;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function fetchJSON(url, opts = {}) {
const response = await fetch(url, opts);
if (!response.ok) {
const body = await response.text();
throw new Error(`HTTP ${response.status}: ${body.substring(0, 200)}`);
}
return response.json();
}
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data, null, 2), {
status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
function weekAgo() {
const d = new Date();
d.setDate(d.getDate() - 7);
return d;
}
function getWeekRange() {
const end = new Date();
const start = weekAgo();
const opts = { month: 'short', day: 'numeric', year: 'numeric' };
return `${start.toLocaleDateString('en-US', opts)} - ${end.toLocaleDateString('en-US', opts)}`;
}
function fmt(n) {
if (n == null) return '—';
return Number(n).toLocaleString('en-US');
}
function fmtDelta(n) {
if (n == null) return '';
const sign = n >= 0 ? '+' : '';
return ` (${sign}${fmt(n)})`;
}
function fmtPct(n) {
if (n == null) return '—';
return `${Math.round(n)}%`;
}
function base64url(input) {
let str;
if (input instanceof ArrayBuffer) {
str = btoa(String.fromCharCode(...new Uint8Array(input)));
} else {
str = btoa(input);
}
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}