- Fix YAML files with unquoted strings containing quotes - Export singleton instances in ai/__init__.py - Fix validator scoring prompt to use replace() instead of format() to avoid conflicts with JSON curly braces Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
257 lines
11 KiB
HTML
257 lines
11 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Analytics{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="animate-fade-in">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 class="text-3xl font-bold">Analytics</h1>
|
|
<p class="text-gray-400 mt-1">Métricas y rendimiento de tus publicaciones</p>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<select id="periodSelect" onchange="loadDashboard()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
|
<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-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
|
|
<option value="">Todas las plataformas</option>
|
|
<option value="x">X (Twitter)</option>
|
|
<option value="facebook">Facebook</option>
|
|
<option value="instagram">Instagram</option>
|
|
<option value="threads">Threads</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Grid -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
<div class="stat-card card rounded-2xl p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
|
<span class="text-2xl">📝</span>
|
|
</div>
|
|
</div>
|
|
<p class="text-3xl font-bold" id="stat-posts">-</p>
|
|
<p class="text-gray-400 text-sm mt-1">Posts Totales</p>
|
|
</div>
|
|
|
|
<div class="stat-card card rounded-2xl p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
|
|
<span class="text-2xl">👁️</span>
|
|
</div>
|
|
</div>
|
|
<p class="text-3xl font-bold" id="stat-impressions">-</p>
|
|
<p class="text-gray-400 text-sm mt-1">Impresiones</p>
|
|
</div>
|
|
|
|
<div class="stat-card card rounded-2xl p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
|
<span class="text-2xl">❤️</span>
|
|
</div>
|
|
</div>
|
|
<p class="text-3xl font-bold" id="stat-engagements">-</p>
|
|
<p class="text-gray-400 text-sm mt-1">Interacciones</p>
|
|
</div>
|
|
|
|
<div class="stat-card card rounded-2xl p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="w-12 h-12 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
|
<span class="text-2xl">📊</span>
|
|
</div>
|
|
</div>
|
|
<p class="text-3xl font-bold" id="stat-rate">-</p>
|
|
<p class="text-gray-400 text-sm mt-1">Engagement Rate</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
<div class="card rounded-2xl p-6">
|
|
<h3 class="font-semibold mb-4">Engagement por Plataforma</h3>
|
|
<canvas id="platformChart" height="200"></canvas>
|
|
</div>
|
|
<div class="card rounded-2xl p-6">
|
|
<h3 class="font-semibold mb-4">Rendimiento por Tipo de Contenido</h3>
|
|
<canvas id="contentChart" height="200"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top Posts -->
|
|
<div class="card rounded-2xl p-6 mb-8">
|
|
<h3 class="font-semibold mb-4">Mejores Posts</h3>
|
|
<div id="top-posts" class="space-y-4">
|
|
<div class="text-center py-8 text-gray-500">Cargando...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Optimal Times -->
|
|
<div class="card rounded-2xl p-6">
|
|
<h3 class="font-semibold mb-4">Mejores Horarios para Publicar</h3>
|
|
<div id="optimal-times" class="grid grid-cols-7 gap-2">
|
|
<div class="text-center py-8 text-gray-500 col-span-7">Cargando...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
let platformChart, contentChart;
|
|
|
|
async function loadDashboard() {
|
|
const period = document.getElementById('periodSelect').value;
|
|
const platform = document.getElementById('platformSelect').value;
|
|
|
|
const params = new URLSearchParams({ days: period });
|
|
if (platform) params.append('platform', platform);
|
|
|
|
try {
|
|
const statsRes = await fetch(`/api/analytics/dashboard?${params}`);
|
|
const stats = await statsRes.json();
|
|
|
|
document.getElementById('stat-posts').textContent = stats.total_posts || 0;
|
|
document.getElementById('stat-impressions').textContent = formatNumber(stats.total_impressions || 0);
|
|
document.getElementById('stat-engagements').textContent = formatNumber(stats.total_engagements || 0);
|
|
document.getElementById('stat-rate').textContent = (stats.avg_engagement_rate || 0).toFixed(2) + '%';
|
|
|
|
updatePlatformChart(stats.platform_breakdown || {});
|
|
updateContentChart(stats.content_breakdown || {});
|
|
|
|
loadTopPosts(period, platform);
|
|
loadOptimalTimes(platform);
|
|
} 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 updatePlatformChart(data) {
|
|
const ctx = document.getElementById('platformChart').getContext('2d');
|
|
const labels = Object.keys(data).length ? Object.keys(data) : ['Sin datos'];
|
|
const values = Object.keys(data).length ? Object.values(data) : [0];
|
|
|
|
if (platformChart) platformChart.destroy();
|
|
|
|
platformChart = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
data: values,
|
|
backgroundColor: ['#6366f1', '#8b5cf6', '#ec4899', '#f59e0b'],
|
|
borderWidth: 0
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: { position: 'bottom', labels: { color: '#9ca3af' } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateContentChart(data) {
|
|
const ctx = document.getElementById('contentChart').getContext('2d');
|
|
const labels = Object.keys(data).length ? Object.keys(data) : ['Sin datos'];
|
|
const values = Object.keys(data).length ? Object.values(data) : [0];
|
|
|
|
if (contentChart) contentChart.destroy();
|
|
|
|
contentChart = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Posts',
|
|
data: values,
|
|
backgroundColor: '#6366f1',
|
|
borderRadius: 8
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
y: { grid: { color: '#334155' }, ticks: { color: '#9ca3af' } },
|
|
x: { grid: { display: false }, ticks: { color: '#9ca3af' } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadTopPosts(period, platform) {
|
|
const container = document.getElementById('top-posts');
|
|
try {
|
|
const params = new URLSearchParams({ days: period, limit: 5 });
|
|
if (platform) params.append('platform', platform);
|
|
|
|
const response = await fetch(`/api/analytics/top-posts?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (data.posts && data.posts.length) {
|
|
container.innerHTML = data.posts.map(post => `
|
|
<div class="bg-dark-800/50 rounded-xl p-4">
|
|
<p class="text-sm text-gray-300 mb-2">${post.content?.substring(0, 100) || 'Sin contenido'}...</p>
|
|
<div class="flex items-center justify-between text-xs text-gray-500">
|
|
<span>${post.platforms?.join(', ') || '-'}</span>
|
|
<span>Engagement: ${(post.engagement_rate || 0).toFixed(2)}%</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
container.innerHTML = '<div class="text-center py-8 text-gray-500">No hay datos suficientes</div>';
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="text-center py-8 text-red-400">Error cargando datos</div>';
|
|
}
|
|
}
|
|
|
|
async function loadOptimalTimes(platform) {
|
|
const container = document.getElementById('optimal-times');
|
|
try {
|
|
const params = platform ? `?platform=${platform}` : '';
|
|
const response = await fetch(`/api/analytics/optimal-times${params}`);
|
|
const data = await response.json();
|
|
|
|
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
|
|
|
if (data.optimal_times && Object.keys(data.optimal_times).length) {
|
|
container.innerHTML = days.map((day, i) => {
|
|
const times = data.optimal_times[i] || [];
|
|
return `
|
|
<div class="bg-dark-800/50 rounded-xl p-3 text-center">
|
|
<p class="font-medium text-sm mb-2">${day}</p>
|
|
<div class="space-y-1">
|
|
${times.length ? times.slice(0, 3).map(t => `<span class="text-xs bg-primary/20 text-primary px-2 py-1 rounded-full block">${t}:00</span>`).join('') : '<span class="text-xs text-gray-500">-</span>'}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} else {
|
|
container.innerHTML = '<div class="text-center py-8 text-gray-500 col-span-7">No hay datos suficientes para calcular horarios óptimos</div>';
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="text-center py-8 text-red-400 col-span-7">Error cargando datos</div>';
|
|
}
|
|
}
|
|
|
|
// Load on page load
|
|
loadDashboard();
|
|
</script>
|
|
{% endblock %}
|