<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RN Debugger Telemetry Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
padding: 24px;
}
.header {
margin-bottom: 32px;
}
.header h1 {
font-size: 24px;
font-weight: 600;
color: #f8fafc;
margin-bottom: 8px;
}
.header p {
color: #94a3b8;
font-size: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: #1e293b;
border-radius: 12px;
padding: 20px;
border: 1px solid #334155;
}
.stat-card .label {
font-size: 12px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.stat-card .value {
font-size: 32px;
font-weight: 700;
color: #f8fafc;
}
.stat-card .change {
font-size: 12px;
margin-top: 4px;
}
.stat-card .change.positive { color: #4ade80; }
.stat-card .change.negative { color: #f87171; }
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.chart-card {
background: #1e293b;
border-radius: 12px;
padding: 24px;
border: 1px solid #334155;
}
.chart-card h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #f8fafc;
}
.chart-container {
position: relative;
height: 300px;
}
.table-card {
background: #1e293b;
border-radius: 12px;
padding: 24px;
border: 1px solid #334155;
overflow-x: auto;
margin-bottom: 24px;
}
.table-card h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #f8fafc;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 12px;
border-bottom: 1px solid #334155;
}
th {
color: #94a3b8;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
td {
font-size: 14px;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-badge.success {
background: rgba(74, 222, 128, 0.1);
color: #4ade80;
}
.status-badge.failure {
background: rgba(248, 113, 113, 0.1);
color: #f87171;
}
.loading {
text-align: center;
padding: 40px;
color: #94a3b8;
}
.error {
background: rgba(248, 113, 113, 0.1);
border: 1px solid #f87171;
border-radius: 8px;
padding: 16px;
color: #f87171;
margin-bottom: 24px;
}
.tool-tag {
display: inline-block;
background: #334155;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
margin: 2px;
color: #e2e8f0;
}
code {
background: #334155;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
color: #94a3b8;
}
.time-filter {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.time-filter button {
background: #334155;
border: none;
color: #e2e8f0;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.time-filter button:hover {
background: #475569;
}
.time-filter button.active {
background: #3b82f6;
}
.refresh-btn {
background: #3b82f6;
border: none;
color: white;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
margin-left: auto;
}
.refresh-btn:hover {
background: #2563eb;
}
.toggle-label {
display: flex;
align-items: center;
gap: 8px;
color: #e2e8f0;
font-size: 14px;
cursor: pointer;
padding: 8px 12px;
background: #334155;
border-radius: 6px;
}
.toggle-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.filter-section {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-input {
background: #1e293b;
border: 1px solid #334155;
color: #e2e8f0;
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
min-width: 300px;
}
.filter-input::placeholder {
color: #64748b;
}
.filter-input:focus {
outline: none;
border-color: #3b82f6;
}
.filter-label {
color: #94a3b8;
font-size: 14px;
}
.excluded-users {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.excluded-user-tag {
display: inline-flex;
align-items: center;
gap: 4px;
background: #7c3aed20;
border: 1px solid #7c3aed;
color: #a78bfa;
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
font-family: monospace;
}
.excluded-user-tag button {
background: none;
border: none;
color: #a78bfa;
cursor: pointer;
padding: 0 2px;
font-size: 14px;
line-height: 1;
}
.excluded-user-tag button:hover {
color: #f87171;
}
.error-category {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.error-category.network { background: #dc262620; color: #f87171; }
.error-category.timeout { background: #ea580c20; color: #fb923c; }
.error-category.validation { background: #ca8a0420; color: #fbbf24; }
.error-category.execution { background: #7c3aed20; color: #a78bfa; }
.error-category.connection { background: #05966920; color: #34d399; }
.error-category.unknown { background: #64748b20; color: #94a3b8; }
.error-message {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-context-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-context {
font-family: 'Fira Code', 'Monaco', monospace;
font-size: 11px;
background: #1e293b;
padding: 2px 6px;
border-radius: 4px;
color: #fbbf24;
}
.stat-card-chart {
min-width: 180px;
}
.mini-chart-container {
position: relative;
height: 80px;
margin-top: 8px;
}
</style>
</head>
<body>
<div class="header">
<h1>RN Debugger Telemetry</h1>
<p>Usage analytics for React Native AI Debugger MCP Server</p>
</div>
<div class="time-filter">
<button class="active" data-days="0">Today</button>
<button data-days="7">7 Days</button>
<button data-days="30">30 Days</button>
<button data-days="-1">All</button>
<button class="refresh-btn" onclick="forceRefresh()">Refresh</button>
<button class="refresh-btn" style="background: #64748b;" onclick="resetKey()">Reset Key</button>
</div>
<div class="filter-section">
<span class="filter-label">Exclude users:</span>
<input type="text" id="exclude-users-input" class="filter-input"
placeholder="Enter user ID and press Enter (e.g., 674b31e5)"
onkeydown="handleExcludeInputKeydown(event)">
<div id="excluded-users-list" class="excluded-users"></div>
</div>
<div id="error-container"></div>
<div class="stats-grid">
<div class="stat-card">
<div class="label">Sessions Started</div>
<div class="value" id="total-sessions">-</div>
<div class="change" id="sessions-detail"></div>
</div>
<div class="stat-card">
<div class="label">Unique Installations</div>
<div class="value" id="unique-installs">-</div>
</div>
<div class="stat-card">
<div class="label">Total Tool Calls</div>
<div class="value" id="total-calls">-</div>
</div>
<div class="stat-card">
<div class="label">Active Users</div>
<div class="value" id="active-users">-</div>
<div class="change" id="active-users-detail"></div>
</div>
<div class="stat-card">
<div class="label">Inactive Users</div>
<div class="value" id="inactive-users">-</div>
<div class="change" id="inactive-users-detail"></div>
</div>
<div class="stat-card">
<div class="label">Success Rate</div>
<div class="value" id="success-rate">-</div>
</div>
<div class="stat-card">
<div class="label">Avg Duration</div>
<div class="value" id="avg-duration">-</div>
</div>
<div class="stat-card">
<div class="label">NPM Downloads (Week)</div>
<div class="value" id="npm-downloads">-</div>
<div class="change" id="npm-downloads-trend"></div>
</div>
<div class="stat-card stat-card-chart">
<div class="label">User Activity</div>
<div class="mini-chart-container">
<canvas id="userActivityChart"></canvas>
</div>
</div>
</div>
<div class="charts-grid">
<div class="chart-card">
<h3>Tool Usage</h3>
<div class="chart-container">
<canvas id="toolUsageChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>Calls Over Time</h3>
<div class="chart-container">
<canvas id="timelineChart"></canvas>
</div>
</div>
</div>
<!-- Daily Active vs Inactive Users Chart -->
<div class="chart-card" style="margin-bottom: 32px;">
<h3>
Daily Active vs Inactive Users (Last 30 Days)
<span style="color: #64748b; font-size: 12px; cursor: help; margin-left: 8px;"
title="Active: had at least 1 tool call or session on that day. Inactive: all other users seen in the 30-day window.">ⓘ</span>
</h3>
<div class="chart-container">
<canvas id="dailyUserActivityChart"></canvas>
</div>
<div id="daily-user-activity-footer" style="text-align: center; color: #64748b; font-size: 12px; margin-top: 12px;">
Loading...
</div>
</div>
<!-- User Retention Chart -->
<div class="chart-card" style="margin-bottom: 32px;">
<h3>
User Retention
<span style="color: #64748b; font-size: 12px; cursor: help; margin-left: 8px;"
title="Percentage of users who returned at least once within N days of first use">ⓘ</span>
</h3>
<div class="chart-container">
<canvas id="retentionChart"></canvas>
</div>
<div id="retention-footer" style="text-align: center; color: #64748b; font-size: 12px; margin-top: 12px;">
Loading...
</div>
</div>
<div class="table-card">
<h3>Top Tools by Usage</h3>
<table>
<thead>
<tr>
<th>Tool Name</th>
<th>Calls</th>
<th>Success Rate</th>
<th>Avg Duration</th>
<th>Avg Tokens</th>
</tr>
</thead>
<tbody id="tools-table">
<tr><td colspan="5" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="table-card">
<h3>Recent Errors</h3>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Category</th>
<th>Error Message</th>
<th>Context</th>
<th>Count</th>
</tr>
</thead>
<tbody id="errors-table">
<tr><td colspan="5" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="table-card">
<h3 id="users-activity-header">Users Activity (5+ calls/week = Active)</h3>
<table>
<thead>
<tr>
<th>User ID</th>
<th>Total Calls</th>
<th id="calls-period-header">Calls/Week</th>
<th>Status</th>
</tr>
</thead>
<tbody id="users-activity-table">
<tr><td colspan="4" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="table-card">
<h3>Tools Usage by User</h3>
<table>
<thead>
<tr>
<th>User ID</th>
<th>Total Calls</th>
<th>Top Tools</th>
</tr>
</thead>
<tbody id="user-tools-table">
<tr><td colspan="3" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
<script>
// Configuration
const API_URL = 'https://rn-debugger-telemetry.500griven.workers.dev';
const NPM_PACKAGE = 'react-native-ai-debugger';
async function fetchNpmDownloads() {
try {
const response = await fetch(
`https://api.npmjs.org/downloads/range/last-month/${NPM_PACKAGE}`
);
if (!response.ok) return null;
const data = await response.json();
const downloads = data.downloads || [];
if (downloads.length < 14) return null;
// Use last 7 days of available data (API lags ~1 day)
const recentDays = downloads.slice(-14);
const thisWeek = recentDays.slice(-7).reduce((sum, d) => sum + d.downloads, 0);
const lastWeek = recentDays.slice(0, 7).reduce((sum, d) => sum + d.downloads, 0);
const trend = lastWeek > 0
? ((thisWeek - lastWeek) / lastWeek * 100)
: (thisWeek > 0 ? 100 : 0);
return { thisWeek, lastWeek, trend };
} catch (error) {
console.error('Failed to fetch NPM downloads:', error);
return null;
}
}
async function updateNpmStats() {
const stats = await fetchNpmDownloads();
const valueEl = document.getElementById('npm-downloads');
const trendEl = document.getElementById('npm-downloads-trend');
if (!stats) {
valueEl.textContent = 'N/A';
trendEl.textContent = '';
return;
}
valueEl.textContent = stats.thisWeek.toLocaleString();
const trendSign = stats.trend >= 0 ? '+' : '';
trendEl.textContent = `${trendSign}${stats.trend.toFixed(1)}% vs last week`;
trendEl.className = `change ${stats.trend >= 0 ? 'positive' : 'negative'}`;
}
let toolUsageChart = null;
let timelineChart = null;
let userActivityChart = null;
let retentionChart = null;
let dailyUserActivityChart = null;
let selectedDays = 0;
let dashboardKey = localStorage.getItem('dashboardKey') || '';
// Excluded users - stored in localStorage
let excludedUsers = JSON.parse(localStorage.getItem('excludedUsers') || '[]');
// In-memory cache for fetched data
// Key format: "{days}_{excludedUsers}" (e.g., "7_674b31e5,abc123")
const dataCache = new Map();
function getCacheKey() {
const excludedStr = excludedUsers.sort().join(',');
return `${selectedDays}_${excludedStr}`;
}
function getCachedData() {
const key = getCacheKey();
return dataCache.get(key);
}
function setCachedData(data) {
const key = getCacheKey();
dataCache.set(key, data);
}
function clearCache() {
dataCache.clear();
}
// Prompt for key if not set
if (!dashboardKey) {
dashboardKey = prompt('Enter your Dashboard Key:');
if (dashboardKey) {
localStorage.setItem('dashboardKey', dashboardKey);
}
}
// Time filter buttons
document.querySelectorAll('.time-filter button[data-days]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.time-filter button[data-days]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedDays = parseInt(btn.dataset.days);
loadData(false); // Use cache if available
});
});
function forceRefresh() {
clearCache();
loadData(true);
updateNpmStats();
}
function handleExcludeInputKeydown(event) {
if (event.key === 'Enter') {
const input = document.getElementById('exclude-users-input');
const userId = input.value.trim().toLowerCase();
if (userId && !excludedUsers.includes(userId)) {
excludedUsers.push(userId);
localStorage.setItem('excludedUsers', JSON.stringify(excludedUsers));
renderExcludedUsers();
loadData(false);
}
input.value = '';
}
}
function removeExcludedUser(userId) {
excludedUsers = excludedUsers.filter(u => u !== userId);
localStorage.setItem('excludedUsers', JSON.stringify(excludedUsers));
renderExcludedUsers();
loadData(false);
}
function renderExcludedUsers() {
const container = document.getElementById('excluded-users-list');
if (excludedUsers.length === 0) {
container.innerHTML = '<span style="color: #64748b; font-size: 12px;">No users excluded</span>';
return;
}
container.innerHTML = excludedUsers.map(userId => `
<span class="excluded-user-tag">
${userId}
<button onclick="removeExcludedUser('${userId}')" title="Remove">×</button>
</span>
`).join('');
}
async function loadData(forceRefresh = false) {
if (!dashboardKey) {
document.getElementById('error-container').innerHTML = `
<div class="error">No dashboard key provided. Refresh the page to enter your key.</div>
`;
return;
}
// Check cache first unless forceRefresh is true
if (!forceRefresh) {
const cachedData = getCachedData();
if (cachedData) {
console.log('Using cached data for:', getCacheKey());
updateDashboard(cachedData);
document.getElementById('error-container').innerHTML = '';
return;
}
}
try {
const excludeParam = excludedUsers.length > 0 ? `&excludeUsers=${encodeURIComponent(excludedUsers.join(','))}` : '';
const response = await fetch(`${API_URL}/api/stats?days=${selectedDays}&key=${encodeURIComponent(dashboardKey)}${excludeParam}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const data = await response.json();
setCachedData(data); // Store in cache
updateDashboard(data);
document.getElementById('error-container').innerHTML = '';
} catch (error) {
console.error('Failed to load data:', error);
document.getElementById('error-container').innerHTML = `
<div class="error">
Failed to load data: ${error.message}<br>
<small>Make sure the worker has the /api/stats endpoint deployed.</small>
</div>
`;
}
}
function updateDashboard(data) {
// Update stat cards
const totalSessions = data.totalSessions || 0;
const uniqueInstalls = data.uniqueInstalls || 0;
document.getElementById('total-sessions').textContent = totalSessions.toLocaleString();
document.getElementById('unique-installs').textContent = uniqueInstalls.toLocaleString();
document.getElementById('total-calls').textContent = (data.totalCalls || 0).toLocaleString();
document.getElementById('success-rate').textContent = `${(data.successRate || 0).toFixed(1)}%`;
document.getElementById('avg-duration').textContent = `${(data.avgDuration || 0).toFixed(0)}ms`;
// Calculate sessions per user
if (uniqueInstalls > 0) {
const sessionsPerUser = (totalSessions / uniqueInstalls).toFixed(1);
document.getElementById('sessions-detail').textContent = `${sessionsPerUser} per user`;
document.getElementById('sessions-detail').className = 'change';
}
// Update active/inactive user stats
const userActivity = data.userActivity || { activeUsers: 0, inactiveUsers: 0 };
document.getElementById('active-users').textContent = userActivity.activeUsers.toLocaleString();
document.getElementById('inactive-users').textContent = userActivity.inactiveUsers.toLocaleString();
// Dynamic threshold labels based on selected period
const threshold = userActivity.activeThreshold || 5;
const periodLabel = userActivity.periodType === 'all'
? '/90d avg'
: (userActivity.periodType === 'today' ? '/today' : (userActivity.periodDays < 7 ? `/${userActivity.periodDays}d` : '/week'));
document.getElementById('active-users-detail').textContent = `${threshold}+ calls${periodLabel}`;
document.getElementById('active-users-detail').className = 'change positive';
document.getElementById('inactive-users-detail').textContent = `<${threshold} calls${periodLabel}`;
document.getElementById('inactive-users-detail').className = 'change negative';
// Update table headers for the selected period
const periodName = userActivity.periodType === 'all'
? '90d avg'
: (userActivity.periodType === 'today' ? 'Today' : (userActivity.periodDays < 7 ? `${userActivity.periodDays}d` : 'Week'));
document.getElementById('users-activity-header').textContent = `Users Activity (${threshold}+ calls${periodLabel} = Active)`;
document.getElementById('calls-period-header').textContent = `Calls/${periodName}`;
// Update tool usage chart
updateToolUsageChart(data.toolBreakdown || []);
// Update timeline chart
updateTimelineChart(data.timeline || []);
// Update user activity pie chart
updateUserActivityChart(userActivity);
// Update retention chart
updateRetentionChart(data.retention);
// Update daily active vs inactive users chart
updateDailyUserActivityChart(data.dailyUserActivity);
// Update tools table
updateToolsTable(data.toolBreakdown || []);
// Update errors table
updateErrorsTable(data.errorBreakdown);
// Update users activity table
updateUsersActivityTable(userActivity);
// Update user tools table
updateUserToolsTable(data.userToolsBreakdown || []);
}
function updateToolUsageChart(toolData) {
const ctx = document.getElementById('toolUsageChart').getContext('2d');
const labels = toolData.slice(0, 10).map(t => t.tool);
const values = toolData.slice(0, 10).map(t => t.count);
if (toolUsageChart) {
toolUsageChart.destroy();
}
toolUsageChart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
data: values,
backgroundColor: '#3b82f6',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { color: '#94a3b8' },
grid: { color: '#334155' }
},
y: {
ticks: { color: '#94a3b8' },
grid: { color: '#334155' }
}
}
}
});
}
function updateTimelineChart(timelineData) {
const ctx = document.getElementById('timelineChart').getContext('2d');
const labels = timelineData.map(t => t.date);
const values = timelineData.map(t => t.count);
if (timelineChart) {
timelineChart.destroy();
}
timelineChart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
data: values,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { color: '#94a3b8' },
grid: { color: '#334155' }
},
y: {
ticks: { color: '#94a3b8' },
grid: { color: '#334155' }
}
}
}
});
}
function updateToolsTable(toolData) {
const tbody = document.getElementById('tools-table');
if (toolData.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="loading">No data available</td></tr>';
return;
}
tbody.innerHTML = toolData.map(tool => `
<tr>
<td>${tool.tool}</td>
<td>${tool.count.toLocaleString()}</td>
<td>
<span class="status-badge ${tool.successRate >= 90 ? 'success' : 'failure'}">
${tool.successRate.toFixed(1)}%
</span>
</td>
<td>${tool.avgDuration.toFixed(0)}ms</td>
<td>${tool.avgTotalTokens ? tool.avgTotalTokens.toLocaleString() : '-'}</td>
</tr>
`).join('');
}
function updateErrorsTable(errorBreakdown) {
const tbody = document.getElementById('errors-table');
if (!errorBreakdown?.length) {
tbody.innerHTML = '<tr><td colspan="5" class="loading">No errors recorded</td></tr>';
return;
}
tbody.innerHTML = errorBreakdown.slice(0, 20).map(err => {
const contextDisplay = err.context
? `<code class="error-context" title="${err.context.replace(/"/g, '"')}">${escapeHtml(err.context)}</code>`
: '-';
return `
<tr>
<td>${err.tool}</td>
<td><span class="error-category ${err.category}">${err.category}</span></td>
<td class="error-message" title="${(err.message || '-').replace(/"/g, '"')}">${err.message || '-'}</td>
<td class="error-context-cell">${contextDisplay}</td>
<td>${err.count}</td>
</tr>
`}).join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function updateUserActivityChart(activityData) {
const ctx = document.getElementById('userActivityChart').getContext('2d');
if (userActivityChart) {
userActivityChart.destroy();
}
const active = activityData.activeUsers || 0;
const inactive = activityData.inactiveUsers || 0;
if (active === 0 && inactive === 0) {
ctx.font = '14px sans-serif';
ctx.fillStyle = '#94a3b8';
ctx.textAlign = 'center';
ctx.fillText('No data available', ctx.canvas.width / 2, ctx.canvas.height / 2);
return;
}
userActivityChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Active', 'Inactive'],
datasets: [{
data: [active, inactive],
backgroundColor: ['#4ade80', '#f87171'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `${ctx.label}: ${ctx.raw}`
}
}
}
}
});
}
function updateRetentionChart(retentionData) {
const ctx = document.getElementById('retentionChart').getContext('2d');
if (retentionChart) retentionChart.destroy();
if (!retentionData?.data?.length) {
ctx.font = '14px sans-serif';
ctx.fillStyle = '#94a3b8';
ctx.textAlign = 'center';
ctx.fillText('No retention data', ctx.canvas.width / 2, ctx.canvas.height / 2);
document.getElementById('retention-footer').textContent = 'Insufficient data';
return;
}
retentionChart = new Chart(ctx, {
type: 'line',
data: {
labels: retentionData.data.map(d => `Day ${d.day}`),
datasets: [{
data: retentionData.data.map(d => d.rate),
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const d = retentionData.data[ctx.dataIndex];
return [`${d.rate.toFixed(1)}%`, `${d.returnedUsers}/${d.cohortSize} users`];
}
}
}
},
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' } },
y: {
min: 0, max: 100,
ticks: { color: '#94a3b8', callback: v => v + '%' },
grid: { color: '#334155' }
}
}
}
});
document.getElementById('retention-footer').textContent =
`Based on ${retentionData.totalUsers} users (last 90 days)`;
}
function updateDailyUserActivityChart(dailyUserActivity) {
const ctx = document.getElementById('dailyUserActivityChart').getContext('2d');
if (dailyUserActivityChart) dailyUserActivityChart.destroy();
if (!dailyUserActivity?.days?.length) {
ctx.font = '14px sans-serif';
ctx.fillStyle = '#94a3b8';
ctx.textAlign = 'center';
ctx.fillText('No daily activity data', ctx.canvas.width / 2, ctx.canvas.height / 2);
document.getElementById('daily-user-activity-footer').textContent = 'Insufficient data';
return;
}
const labels = dailyUserActivity.days.map(d => d.date);
dailyUserActivityChart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Active',
data: dailyUserActivity.days.map(d => d.activeCount),
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.15)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 5
},
{
label: 'Inactive',
data: dailyUserActivity.days.map(d => d.inactiveCount),
borderColor: '#64748b',
backgroundColor: 'rgba(100, 116, 139, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 5
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: true, labels: { color: '#94a3b8', usePointStyle: true } },
tooltip: {
callbacks: { label: (ctx) => `${ctx.dataset.label}: ${ctx.raw} users` }
}
},
scales: {
x: { ticks: { color: '#94a3b8', maxTicksLimit: 10, maxRotation: 45 }, grid: { color: '#334155' } },
y: { min: 0, ticks: { color: '#94a3b8', precision: 0 }, grid: { color: '#334155' } }
}
}
});
document.getElementById('daily-user-activity-footer').textContent =
`Based on ${dailyUserActivity.totalUsers} distinct users in the last 30 days`;
}
function updateUsersActivityTable(userActivity) {
const tbody = document.getElementById('users-activity-table');
const users = userActivity.users || [];
const isToday = userActivity.periodType === 'today';
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="loading">No data available</td></tr>';
return;
}
tbody.innerHTML = users.map(user => `
<tr>
<td><code>${user.userId}</code></td>
<td>${user.totalCalls.toLocaleString()}</td>
<td>${isToday ? user.totalCalls.toLocaleString() : user.callsPerWeek}</td>
<td>
<span class="status-badge ${user.isActive ? 'success' : 'failure'}">
${user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
`).join('');
}
function updateUserToolsTable(userToolsData) {
const tbody = document.getElementById('user-tools-table');
if (userToolsData.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="loading">No data available</td></tr>';
return;
}
tbody.innerHTML = userToolsData.map(user => {
const topTools = user.tools.slice(0, 5).map(t =>
`<span class="tool-tag">${t.tool} (${t.count})</span>`
).join(' ');
return `
<tr>
<td><code>${user.userId}</code></td>
<td>${user.totalCalls.toLocaleString()}</td>
<td>${topTools}</td>
</tr>
`;
}).join('');
}
function formatDate(dateStr) {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
return dateStr;
}
}
function resetKey() {
localStorage.removeItem('dashboardKey');
dashboardKey = prompt('Enter your Dashboard Key:');
if (dashboardKey) {
localStorage.setItem('dashboardKey', dashboardKey);
loadData();
}
}
// Initial load
renderExcludedUsers();
loadData();
updateNpmStats();
</script>
</body>
</html>