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

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:
Maciej Pienczyn 2026-02-19 16:35:35 +01:00
parent f8a8e345ea
commit 9444c3484e

View File

@ -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 += ' &middot; ' + post.status_type;
html += '</div>';
if (post.message) {
html += '<div class="fb-post-text">' + post.message.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</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">&#128077; ' + (post.likes || 0) + '</span>';
html += '<span title="Komentarze">&#128172; ' + (post.comments || 0) + '</span>';
html += '<span title="Udostepnienia">&#128257; ' + (post.shares || 0) + '</span>';
html += '<span title="Reakcje">&#10084;&#65039; ' + (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 &rarr;</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 += ' &middot; ' + post.status_type;
html += '</div>';
if (post.message) {
html += '<div class="fb-post-text">' + post.message.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</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">&#128077; ' + (post.likes || 0) + '</span>';
html += '<span title="Komentarze">&#128172; ' + (post.comments || 0) + '</span>';
html += '<span title="Udostepnienia">&#128257; ' + (post.shares || 0) + '</span>';
html += '<span title="Reakcje">&#10084;&#65039; ' + (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;