feat: Add Analytics, Odoo Integration, A/B Testing, and Content features
Phase 1 - Analytics y Reportes: - PostMetrics and AnalyticsReport models for tracking engagement - Analytics service with dashboard stats, top posts, optimal times - 8 API endpoints at /api/analytics/* - Interactive dashboard with Chart.js charts - Celery tasks for metrics fetch (15min) and weekly reports Phase 2 - Integración Odoo: - Lead and OdooSyncLog models for CRM integration - Odoo fields added to Product and Service models - XML-RPC service for bidirectional sync - Lead management API at /api/leads/* - Leads dashboard template - Celery tasks for product/service sync and lead export Phase 3 - A/B Testing y Recycling: - ABTest, ABTestVariant, RecycledPost models - Statistical winner analysis using chi-square test - Content recycling with engagement-based scoring - APIs at /api/ab-tests/* and /api/recycling/* - Automated test evaluation and content recycling tasks Phase 4 - Thread Series y Templates: - ThreadSeries and ThreadPost models for multi-post threads - AI-powered thread generation - Enhanced ImageTemplate with HTML template support - APIs at /api/threads/* and /api/templates/* - Thread scheduling with reply chain support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
446
dashboard/templates/analytics.html
Normal file
446
dashboard/templates/analytics.html
Normal file
@@ -0,0 +1,446 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Analytics - Social Media Automation</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
body { background-color: #1a1a2e; color: #eee; }
|
||||
.card { background-color: #16213e; border-radius: 12px; }
|
||||
.accent { color: #d4a574; }
|
||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
||||
.btn-primary:hover { background-color: #c49564; }
|
||||
.btn-secondary { background-color: #374151; color: #fff; }
|
||||
.btn-secondary:hover { background-color: #4b5563; }
|
||||
.stat-up { color: #10b981; }
|
||||
.stat-down { color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span class="accent">Consultoría AS</span> - Analytics
|
||||
</h1>
|
||||
<nav class="flex gap-4">
|
||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
||||
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800 accent">+ Crear Post</a>
|
||||
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
||||
<a href="/dashboard/analytics" class="px-4 py-2 rounded bg-gray-800">Analytics</a>
|
||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<!-- Period Selector -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold">Dashboard de Analytics</h2>
|
||||
<div class="flex gap-2">
|
||||
<select id="periodSelect" onchange="loadDashboard()"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
|
||||
<option value="7">Últimos 7 días</option>
|
||||
<option value="30" selected>Últimos 30 días</option>
|
||||
<option value="90">Últimos 90 días</option>
|
||||
</select>
|
||||
<select id="platformSelect" onchange="loadDashboard()"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
|
||||
<option value="">Todas las plataformas</option>
|
||||
<option value="x">X (Twitter)</option>
|
||||
<option value="threads">Threads</option>
|
||||
<option value="instagram">Instagram</option>
|
||||
<option value="facebook">Facebook</option>
|
||||
</select>
|
||||
<button onclick="generateReport()" class="btn-primary px-4 py-2 rounded">
|
||||
Generar Reporte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-5 gap-4 mb-8">
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold accent" id="totalPosts">-</div>
|
||||
<div class="text-gray-400 text-sm">Posts Publicados</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-400" id="totalImpressions">-</div>
|
||||
<div class="text-gray-400 text-sm">Impresiones</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-400" id="totalEngagements">-</div>
|
||||
<div class="text-gray-400 text-sm">Interacciones</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-purple-400" id="engagementRate">-</div>
|
||||
<div class="text-gray-400 text-sm">Engagement Rate</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-yellow-400" id="pendingInteractions">-</div>
|
||||
<div class="text-gray-400 text-sm">Por Responder</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engagement Breakdown -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-8">
|
||||
<div class="card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-400 text-sm">Likes</div>
|
||||
<div class="text-2xl font-bold" id="totalLikes">-</div>
|
||||
</div>
|
||||
<div class="text-4xl">❤️</div>
|
||||
</div>
|
||||
<div class="card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-400 text-sm">Comentarios</div>
|
||||
<div class="text-2xl font-bold" id="totalComments">-</div>
|
||||
</div>
|
||||
<div class="text-4xl">💬</div>
|
||||
</div>
|
||||
<div class="card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-400 text-sm">Compartidos</div>
|
||||
<div class="text-2xl font-bold" id="totalShares">-</div>
|
||||
</div>
|
||||
<div class="text-4xl">🔄</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 mb-8">
|
||||
<!-- Engagement Trend Chart -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Tendencia de Engagement</h3>
|
||||
<canvas id="engagementChart" height="200"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Platform Breakdown -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Por Plataforma</h3>
|
||||
<div id="platformBreakdown" class="space-y-3">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 mb-8">
|
||||
<!-- Top Posts -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Top Posts por Engagement</h3>
|
||||
<div id="topPosts" class="space-y-3">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optimal Times Heatmap -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Mejores Horarios</h3>
|
||||
<div id="optimalTimes" class="space-y-2">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Type Performance -->
|
||||
<div class="card p-6 mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">Rendimiento por Tipo de Contenido</h3>
|
||||
<div id="contentBreakdown" class="grid grid-cols-4 gap-4">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reports History -->
|
||||
<div class="card p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">Reportes Anteriores</h3>
|
||||
<button onclick="sendReportTelegram()" class="btn-secondary px-4 py-2 rounded text-sm">
|
||||
Enviar a Telegram
|
||||
</button>
|
||||
</div>
|
||||
<div id="reportsList" class="space-y-2">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let engagementChart = null;
|
||||
|
||||
async function loadDashboard() {
|
||||
const days = document.getElementById('periodSelect').value;
|
||||
const platform = document.getElementById('platformSelect').value;
|
||||
|
||||
try {
|
||||
// Load dashboard stats
|
||||
const params = new URLSearchParams({ days });
|
||||
if (platform) params.append('platform', platform);
|
||||
|
||||
const statsRes = await fetch(`/api/analytics/dashboard?${params}`);
|
||||
const stats = await statsRes.json();
|
||||
|
||||
// Update stat cards
|
||||
document.getElementById('totalPosts').textContent = stats.total_posts;
|
||||
document.getElementById('totalImpressions').textContent = formatNumber(stats.total_impressions);
|
||||
document.getElementById('totalEngagements').textContent = formatNumber(stats.total_engagements);
|
||||
document.getElementById('engagementRate').textContent = stats.avg_engagement_rate + '%';
|
||||
document.getElementById('pendingInteractions').textContent = stats.pending_interactions;
|
||||
document.getElementById('totalLikes').textContent = formatNumber(stats.total_likes);
|
||||
document.getElementById('totalComments').textContent = formatNumber(stats.total_comments);
|
||||
document.getElementById('totalShares').textContent = formatNumber(stats.total_shares);
|
||||
|
||||
// Platform breakdown
|
||||
renderPlatformBreakdown(stats.platform_breakdown);
|
||||
|
||||
// Content breakdown
|
||||
renderContentBreakdown(stats.content_breakdown);
|
||||
|
||||
// Load engagement trend
|
||||
const trendRes = await fetch(`/api/analytics/engagement-trend?${params}`);
|
||||
const trendData = await trendRes.json();
|
||||
renderEngagementChart(trendData.trend);
|
||||
|
||||
// Load top posts
|
||||
const topRes = await fetch(`/api/analytics/top-posts?${params}&limit=5`);
|
||||
const topData = await topRes.json();
|
||||
renderTopPosts(topData.posts);
|
||||
|
||||
// Load optimal times
|
||||
const timesRes = await fetch(`/api/analytics/optimal-times?days=${days}${platform ? '&platform=' + platform : ''}`);
|
||||
const timesData = await timesRes.json();
|
||||
renderOptimalTimes(timesData.optimal_times);
|
||||
|
||||
// Load reports
|
||||
const reportsRes = await fetch('/api/analytics/reports?limit=5');
|
||||
const reportsData = await reportsRes.json();
|
||||
renderReports(reportsData.reports);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
function renderPlatformBreakdown(breakdown) {
|
||||
const container = document.getElementById('platformBreakdown');
|
||||
container.innerHTML = '';
|
||||
|
||||
const platformIcons = {
|
||||
'x': '𝕏',
|
||||
'threads': '🧵',
|
||||
'instagram': '📷',
|
||||
'facebook': '📘'
|
||||
};
|
||||
|
||||
for (const [platform, data] of Object.entries(breakdown)) {
|
||||
container.innerHTML += `
|
||||
<div class="flex items-center justify-between bg-gray-800 rounded p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">${platformIcons[platform] || '📱'}</span>
|
||||
<div>
|
||||
<div class="font-medium capitalize">${platform}</div>
|
||||
<div class="text-sm text-gray-400">${data.posts} posts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-green-400 font-bold">${formatNumber(data.engagements)}</div>
|
||||
<div class="text-sm text-gray-400">interacciones</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (Object.keys(breakdown).length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No hay datos disponibles</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderContentBreakdown(breakdown) {
|
||||
const container = document.getElementById('contentBreakdown');
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const [type, data] of Object.entries(breakdown)) {
|
||||
container.innerHTML += `
|
||||
<div class="bg-gray-800 rounded p-4 text-center">
|
||||
<div class="text-lg font-bold accent">${data.posts}</div>
|
||||
<div class="text-sm text-gray-400 capitalize">${type.replace('_', ' ')}</div>
|
||||
<div class="text-xs text-green-400 mt-1">${formatNumber(data.engagements)} eng.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (Object.keys(breakdown).length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500 col-span-4">No hay datos disponibles</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderEngagementChart(trend) {
|
||||
const ctx = document.getElementById('engagementChart').getContext('2d');
|
||||
|
||||
if (engagementChart) {
|
||||
engagementChart.destroy();
|
||||
}
|
||||
|
||||
engagementChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: trend.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Impresiones',
|
||||
data: trend.map(d => d.impressions),
|
||||
borderColor: '#60a5fa',
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Interacciones',
|
||||
data: trend.map(d => d.engagements),
|
||||
borderColor: '#34d399',
|
||||
backgroundColor: 'rgba(52, 211, 153, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: '#9ca3af' }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: '#9ca3af' },
|
||||
grid: { color: '#374151' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#9ca3af' },
|
||||
grid: { color: '#374151' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTopPosts(posts) {
|
||||
const container = document.getElementById('topPosts');
|
||||
container.innerHTML = '';
|
||||
|
||||
posts.forEach((post, i) => {
|
||||
container.innerHTML += `
|
||||
<div class="bg-gray-800 rounded p-3">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="text-accent font-bold">#${i + 1}</span>
|
||||
<span class="text-green-400 text-sm">${post.engagement_rate}% eng.</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-300 mb-2">${post.content}</p>
|
||||
<div class="flex gap-4 text-xs text-gray-500">
|
||||
<span>❤️ ${post.likes}</span>
|
||||
<span>💬 ${post.comments}</span>
|
||||
<span>🔄 ${post.shares}</span>
|
||||
<span class="ml-auto">${post.platforms.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
if (posts.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No hay posts con métricas</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOptimalTimes(times) {
|
||||
const container = document.getElementById('optimalTimes');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Show top 10 times
|
||||
times.slice(0, 10).forEach(time => {
|
||||
const barWidth = Math.min(time.avg_engagement_rate * 10, 100);
|
||||
container.innerHTML += `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-20 text-sm text-gray-400">${time.day_name} ${time.hour_formatted}</span>
|
||||
<div class="flex-1 bg-gray-800 rounded h-4 overflow-hidden">
|
||||
<div class="bg-accent h-full" style="width: ${barWidth}%"></div>
|
||||
</div>
|
||||
<span class="text-sm text-green-400 w-16 text-right">${time.avg_engagement_rate}%</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
if (times.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No hay suficientes datos</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderReports(reports) {
|
||||
const container = document.getElementById('reportsList');
|
||||
container.innerHTML = '';
|
||||
|
||||
reports.forEach(report => {
|
||||
container.innerHTML += `
|
||||
<div class="bg-gray-800 rounded p-3 flex justify-between items-center">
|
||||
<div>
|
||||
<span class="font-medium capitalize">${report.report_type}</span>
|
||||
<span class="text-gray-500 text-sm ml-2">
|
||||
${report.period_start} - ${report.period_end}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<span>${report.total_posts} posts</span>
|
||||
<span class="text-green-400">${report.avg_engagement_rate}% eng.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
if (reports.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No hay reportes generados</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReport() {
|
||||
try {
|
||||
const res = await fetch('/api/analytics/reports/generate?report_type=weekly', {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
alert('Reporte generado exitosamente');
|
||||
loadDashboard();
|
||||
} else {
|
||||
alert('Error: ' + data.detail);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error generando reporte');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReportTelegram() {
|
||||
try {
|
||||
const res = await fetch('/api/analytics/reports/send-telegram', {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
alert(data.message);
|
||||
} catch (error) {
|
||||
alert('Error enviando reporte');
|
||||
}
|
||||
}
|
||||
|
||||
// Load on page load
|
||||
document.addEventListener('DOMContentLoaded', loadDashboard);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user