feat: add "Load all + charts" button with Chart.js analytics to Social Publisher
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Adds auto-pagination that fetches all Facebook posts and renders 3 Chart.js visualizations: engagement per post (line), publication activity per month (bar), and average engagement trend per month (line with fill). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f8a8e345ea
commit
9444c3484e
@ -283,6 +283,26 @@
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
/* Facebook Charts */
|
||||
.fb-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.fb-chart-card {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.fb-chart-card h4 {
|
||||
font-size: var(--font-size-md);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fb-post-card {
|
||||
flex-direction: column;
|
||||
@ -291,6 +311,9 @@
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
}
|
||||
.fb-charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.posts-table {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
@ -425,9 +448,19 @@
|
||||
<div class="fb-posts-section" id="fbPostsSection-{{ company_id_key }}">
|
||||
<div class="fb-posts-header">
|
||||
<h3>Ostatnie posty na Facebook</h3>
|
||||
<button class="btn btn-secondary btn-small" onclick="loadFbPosts({{ company_id_key }}, this)">Zaladuj posty</button>
|
||||
<div style="display: flex; gap: var(--spacing-xs);">
|
||||
<button class="btn btn-secondary btn-small" onclick="loadFbPosts({{ company_id_key }}, this)">Zaladuj posty</button>
|
||||
<button class="btn btn-primary btn-small" onclick="loadAllFbPosts({{ company_id_key }}, this)">Zaladuj wszystkie + wykresy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fbPostsContainer-{{ company_id_key }}"></div>
|
||||
<div id="fbChartsSection-{{ company_id_key }}" style="display:none;">
|
||||
<div class="fb-charts-grid">
|
||||
<div class="fb-chart-card"><h4>Engagement w czasie</h4><canvas id="engagementChart-{{ company_id_key }}"></canvas></div>
|
||||
<div class="fb-chart-card"><h4>Aktywnosc publikacji</h4><canvas id="activityChart-{{ company_id_key }}"></canvas></div>
|
||||
<div class="fb-chart-card"><h4>Sredni engagement / miesiac</h4><canvas id="avgEngagementChart-{{ company_id_key }}"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@ -573,6 +606,8 @@
|
||||
|
||||
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<style>
|
||||
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
|
||||
.modal-overlay#confirmModal.active { display: flex; }
|
||||
@ -688,65 +723,104 @@
|
||||
return d.toLocaleDateString('pl-PL', {day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'});
|
||||
}
|
||||
|
||||
function loadFbPosts(companyId, btn) {
|
||||
function renderFbPosts(companyId, posts, nextCursor, append) {
|
||||
var container = document.getElementById('fbPostsContainer-' + companyId);
|
||||
var html = '';
|
||||
posts.forEach(function(post) {
|
||||
html += '<div class="fb-post-card">';
|
||||
if (post.full_picture) {
|
||||
html += '<img class="fb-post-thumb" src="' + post.full_picture + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
|
||||
}
|
||||
html += '<div class="fb-post-body">';
|
||||
html += '<div class="fb-post-date">' + formatFbDate(post.created_time);
|
||||
if (post.status_type) html += ' · ' + post.status_type;
|
||||
html += '</div>';
|
||||
if (post.message) {
|
||||
html += '<div class="fb-post-text">' + post.message.replace(/</g, '<').replace(/>/g, '>') + '</div>';
|
||||
} else {
|
||||
html += '<div class="fb-post-text" style="font-style:italic;color:var(--text-secondary);">(post bez tekstu)</div>';
|
||||
}
|
||||
html += '<div class="fb-post-metrics">';
|
||||
html += '<span title="Polubienia">👍 ' + (post.likes || 0) + '</span>';
|
||||
html += '<span title="Komentarze">💬 ' + (post.comments || 0) + '</span>';
|
||||
html += '<span title="Udostepnienia">🔁 ' + (post.shares || 0) + '</span>';
|
||||
html += '<span title="Reakcje">❤️ ' + (post.reactions_total || 0) + '</span>';
|
||||
html += '</div>';
|
||||
html += '<div class="fb-post-actions">';
|
||||
if (post.permalink_url) {
|
||||
html += '<a href="' + post.permalink_url + '" target="_blank" rel="noopener" class="btn btn-secondary btn-small" style="font-size:11px;">Zobacz na FB</a>';
|
||||
}
|
||||
html += '<button class="btn btn-secondary btn-small" style="font-size:11px;" onclick="loadPostInsights(' + companyId + ', \'' + post.id + '\', this)">Insights</button>';
|
||||
html += '</div>';
|
||||
html += '<div id="insights-' + post.id.replace(/\./g, '-') + '" style="display:none;"></div>';
|
||||
html += '</div></div>';
|
||||
});
|
||||
|
||||
// Remove old "load more" button before appending
|
||||
var oldMore = document.getElementById('fbLoadMore-' + companyId);
|
||||
if (oldMore) oldMore.remove();
|
||||
|
||||
if (append) {
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
} else {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Add "load more" button if there's a next page
|
||||
if (nextCursor) {
|
||||
var moreHtml = '<div id="fbLoadMore-' + companyId + '" style="text-align:center;margin-top:var(--spacing-md);">';
|
||||
moreHtml += '<button class="btn btn-secondary" onclick="loadFbPosts(' + companyId + ', this, \'' + nextCursor + '\')">Nastepna strona →</button>';
|
||||
moreHtml += '</div>';
|
||||
container.insertAdjacentHTML('beforeend', moreHtml);
|
||||
}
|
||||
}
|
||||
|
||||
function loadFbPosts(companyId, btn, afterCursor) {
|
||||
var origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Ladowanie...';
|
||||
var container = document.getElementById('fbPostsContainer-' + companyId);
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Pobieranie postow z Facebook API...</div>';
|
||||
var isAppend = !!afterCursor;
|
||||
|
||||
fetch('/admin/social-publisher/fb-posts/' + companyId)
|
||||
if (!isAppend) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Pobieranie postow z Facebook API...</div>';
|
||||
}
|
||||
|
||||
var url = '/admin/social-publisher/fb-posts/' + companyId;
|
||||
if (afterCursor) url += '?after=' + encodeURIComponent(afterCursor);
|
||||
|
||||
fetch(url)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
if (!data.success) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--error);">' + (data.error || 'Blad') + '</div>';
|
||||
if (!isAppend) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--error);">' + (data.error || 'Blad') + '</div>';
|
||||
} else {
|
||||
showToast(data.error || 'Blad pobierania', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!data.posts || data.posts.length === 0) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Brak postow na stronie.</div>';
|
||||
if (!isAppend) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Brak postow na stronie.</div>';
|
||||
} else {
|
||||
var oldMore = document.getElementById('fbLoadMore-' + companyId);
|
||||
if (oldMore) oldMore.innerHTML = '<span style="color:var(--text-secondary);font-size:var(--font-size-sm);">To juz wszystkie posty.</span>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
data.posts.forEach(function(post) {
|
||||
html += '<div class="fb-post-card">';
|
||||
if (post.full_picture) {
|
||||
html += '<img class="fb-post-thumb" src="' + post.full_picture + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
|
||||
}
|
||||
html += '<div class="fb-post-body">';
|
||||
html += '<div class="fb-post-date">' + formatFbDate(post.created_time);
|
||||
if (post.status_type) html += ' · ' + post.status_type;
|
||||
html += '</div>';
|
||||
if (post.message) {
|
||||
html += '<div class="fb-post-text">' + post.message.replace(/</g, '<').replace(/>/g, '>') + '</div>';
|
||||
} else {
|
||||
html += '<div class="fb-post-text" style="font-style:italic;color:var(--text-secondary);">(post bez tekstu)</div>';
|
||||
}
|
||||
html += '<div class="fb-post-metrics">';
|
||||
html += '<span title="Polubienia">👍 ' + (post.likes || 0) + '</span>';
|
||||
html += '<span title="Komentarze">💬 ' + (post.comments || 0) + '</span>';
|
||||
html += '<span title="Udostepnienia">🔁 ' + (post.shares || 0) + '</span>';
|
||||
html += '<span title="Reakcje">❤️ ' + (post.reactions_total || 0) + '</span>';
|
||||
html += '</div>';
|
||||
html += '<div class="fb-post-actions">';
|
||||
if (post.permalink_url) {
|
||||
html += '<a href="' + post.permalink_url + '" target="_blank" rel="noopener" class="btn btn-secondary btn-small" style="font-size:11px;">Zobacz na FB</a>';
|
||||
}
|
||||
html += '<button class="btn btn-secondary btn-small" style="font-size:11px;" onclick="loadPostInsights(' + companyId + ', \'' + post.id + '\', this)">Insights</button>';
|
||||
html += '</div>';
|
||||
html += '<div id="insights-' + post.id.replace(/\./g, '-') + '" style="display:none;"></div>';
|
||||
html += '</div></div>';
|
||||
});
|
||||
if (data.cached) {
|
||||
html += '<div style="text-align:right;font-size:11px;color:var(--text-secondary);margin-top:4px;">Dane z cache</div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
renderFbPosts(companyId, data.posts, data.next_cursor, isAppend);
|
||||
})
|
||||
.catch(function(err) {
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--error);">Blad polaczenia: ' + err.message + '</div>';
|
||||
if (!isAppend) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--error);">Blad polaczenia: ' + err.message + '</div>';
|
||||
} else {
|
||||
showToast('Blad polaczenia: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -791,6 +865,162 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Store chart instances for cleanup
|
||||
window._fbCharts = window._fbCharts || {};
|
||||
|
||||
async function loadAllFbPosts(companyId, btn) {
|
||||
var origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
var container = document.getElementById('fbPostsContainer-' + companyId);
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Pobieranie postow z Facebook API...</div>';
|
||||
|
||||
var allPosts = [];
|
||||
var cursor = null;
|
||||
var page = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
page++;
|
||||
btn.textContent = 'Ladowanie... (' + allPosts.length + ' postow, strona ' + page + ')';
|
||||
|
||||
var url = '/admin/social-publisher/fb-posts/' + companyId;
|
||||
if (cursor) url += '?after=' + encodeURIComponent(cursor);
|
||||
|
||||
var r = await fetch(url);
|
||||
var data = await r.json();
|
||||
|
||||
if (!data.success) {
|
||||
showToast(data.error || 'Blad pobierania', 'error');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!data.posts || data.posts.length === 0) break;
|
||||
|
||||
allPosts = allPosts.concat(data.posts);
|
||||
|
||||
if (!data.next_cursor) break;
|
||||
cursor = data.next_cursor;
|
||||
}
|
||||
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
|
||||
if (allPosts.length === 0) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-secondary);">Brak postow na stronie.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
renderFbPosts(companyId, allPosts, null, false);
|
||||
container.insertAdjacentHTML('beforeend',
|
||||
'<div style="text-align:center;margin-top:var(--spacing-md);color:var(--text-secondary);font-size:var(--font-size-sm);">Zaladowano wszystkie posty (' + allPosts.length + ')</div>');
|
||||
renderFbCharts(companyId, allPosts);
|
||||
} catch (err) {
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
showToast('Blad polaczenia: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFbCharts(companyId, posts) {
|
||||
// Sort chronologically (oldest first)
|
||||
var sorted = posts.slice().sort(function(a, b) {
|
||||
return new Date(a.created_time) - new Date(b.created_time);
|
||||
});
|
||||
|
||||
// Aggregate per month
|
||||
var months = {};
|
||||
sorted.forEach(function(p) {
|
||||
var d = new Date(p.created_time);
|
||||
var key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
|
||||
if (!months[key]) months[key] = { count: 0, likes: 0, comments: 0, shares: 0, reactions: 0 };
|
||||
months[key].count++;
|
||||
months[key].likes += (p.likes || 0);
|
||||
months[key].comments += (p.comments || 0);
|
||||
months[key].shares += (p.shares || 0);
|
||||
months[key].reactions += (p.reactions_total || 0);
|
||||
});
|
||||
var monthKeys = Object.keys(months).sort();
|
||||
|
||||
// Destroy old charts
|
||||
if (window._fbCharts[companyId]) {
|
||||
window._fbCharts[companyId].forEach(function(c) { c.destroy(); });
|
||||
}
|
||||
window._fbCharts[companyId] = [];
|
||||
|
||||
var baseOpts = {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 10, font: { size: 11 } } } },
|
||||
scales: { x: { ticks: { font: { size: 10 }, maxRotation: 45 } }, y: { beginAtZero: true, ticks: { font: { size: 10 } } } }
|
||||
};
|
||||
|
||||
// Chart 1: Engagement per post (line)
|
||||
var ctx1 = document.getElementById('engagementChart-' + companyId).getContext('2d');
|
||||
var postLabels = sorted.map(function(p) {
|
||||
var d = new Date(p.created_time);
|
||||
return d.toLocaleDateString('pl-PL', {day: '2-digit', month: '2-digit'});
|
||||
});
|
||||
var chart1 = new Chart(ctx1, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: postLabels,
|
||||
datasets: [
|
||||
{ label: 'Polubienia', data: sorted.map(function(p) { return p.likes || 0; }), borderColor: '#1877f2', backgroundColor: 'rgba(24,119,242,0.1)', tension: 0.3, pointRadius: 2 },
|
||||
{ label: 'Komentarze', data: sorted.map(function(p) { return p.comments || 0; }), borderColor: '#42b72a', backgroundColor: 'rgba(66,183,42,0.1)', tension: 0.3, pointRadius: 2 },
|
||||
{ label: 'Udostepnienia', data: sorted.map(function(p) { return p.shares || 0; }), borderColor: '#f5533d', backgroundColor: 'rgba(245,83,61,0.1)', tension: 0.3, pointRadius: 2 },
|
||||
{ label: 'Reakcje', data: sorted.map(function(p) { return p.reactions_total || 0; }), borderColor: '#e8a819', backgroundColor: 'rgba(232,168,25,0.1)', tension: 0.3, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: Object.assign({}, baseOpts, { plugins: Object.assign({}, baseOpts.plugins, { title: { display: false } }) })
|
||||
});
|
||||
window._fbCharts[companyId].push(chart1);
|
||||
|
||||
// Chart 2: Posts per month (bar)
|
||||
var ctx2 = document.getElementById('activityChart-' + companyId).getContext('2d');
|
||||
var chart2 = new Chart(ctx2, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: monthKeys,
|
||||
datasets: [{
|
||||
label: 'Liczba postow',
|
||||
data: monthKeys.map(function(k) { return months[k].count; }),
|
||||
backgroundColor: 'rgba(24,119,242,0.7)',
|
||||
borderColor: '#1877f2',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: baseOpts
|
||||
});
|
||||
window._fbCharts[companyId].push(chart2);
|
||||
|
||||
// Chart 3: Avg total engagement per month (line)
|
||||
var ctx3 = document.getElementById('avgEngagementChart-' + companyId).getContext('2d');
|
||||
var chart3 = new Chart(ctx3, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: monthKeys,
|
||||
datasets: [{
|
||||
label: 'Sredni engagement / post',
|
||||
data: monthKeys.map(function(k) {
|
||||
var m = months[k];
|
||||
var total = m.likes + m.comments + m.shares;
|
||||
return m.count > 0 ? Math.round(total / m.count * 10) / 10 : 0;
|
||||
}),
|
||||
borderColor: '#e8a819',
|
||||
backgroundColor: 'rgba(232,168,25,0.15)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: 3
|
||||
}]
|
||||
},
|
||||
options: baseOpts
|
||||
});
|
||||
window._fbCharts[companyId].push(chart3);
|
||||
|
||||
// Show charts section
|
||||
document.getElementById('fbChartsSection-' + companyId).style.display = 'block';
|
||||
}
|
||||
|
||||
function syncFacebookData(companyId, btn) {
|
||||
var origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user