feat(social-audit): add activity, completeness & engagement metrics to dashboard
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

Display existing but unused model fields: last_post_date activity indicator,
profile_completeness_score progress bar, engagement rate, posts count.
Add KPI cards for avg completeness, engagement, inactive companies.
Add activity filter. Fix Polish diacritics throughout template.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-11 22:02:04 +01:00
parent cb5c599d24
commit c1df642aad
2 changed files with 185 additions and 15 deletions

View File

@ -165,7 +165,12 @@ def admin_social_audit():
CompanySocialMedia.followers_count,
CompanySocialMedia.verified_at,
CompanySocialMedia.is_valid,
CompanySocialMedia.check_status
CompanySocialMedia.check_status,
CompanySocialMedia.profile_completeness_score,
CompanySocialMedia.engagement_rate,
CompanySocialMedia.last_post_date,
CompanySocialMedia.posts_count_30d,
CompanySocialMedia.posting_frequency_score
).filter(
or_(
CompanySocialMedia.is_valid == True,
@ -183,7 +188,12 @@ def admin_social_audit():
'url': sm.url,
'followers': sm.followers_count or 0,
'verified_at': sm.verified_at,
'needs_verification': sm.check_status == 'needs_verification'
'needs_verification': sm.check_status == 'needs_verification',
'completeness': sm.profile_completeness_score or 0,
'engagement_rate': sm.engagement_rate or 0,
'last_post_date': sm.last_post_date,
'posts_30d': sm.posts_count_30d or 0,
'posting_freq': sm.posting_frequency_score or 0
}
if sm.check_status == 'needs_verification':
needs_verification_items.append({
@ -223,6 +233,19 @@ def admin_social_audit():
if 'youtube' not in sm_data:
recommendations.append('Brak YouTube')
# Aggregate engagement & completeness across platforms
completeness_scores = [p.get('completeness', 0) for p in sm_data.values() if p.get('completeness')]
avg_completeness = round(sum(completeness_scores) / len(completeness_scores)) if completeness_scores else 0
engagement_rates = [p.get('engagement_rate', 0) for p in sm_data.values() if p.get('engagement_rate')]
avg_engagement = round(sum(engagement_rates) / len(engagement_rates), 2) if engagement_rates else 0
total_posts_30d = sum(p.get('posts_30d', 0) for p in sm_data.values())
# Last post date across all platforms
post_dates = [p.get('last_post_date') for p in sm_data.values() if p.get('last_post_date')]
last_post = max(post_dates) if post_dates else None
companies.append({
'id': row.id,
'name': row.name,
@ -240,7 +263,11 @@ def admin_social_audit():
'has_twitter': 'twitter' in sm_data,
'has_tiktok': 'tiktok' in sm_data,
'has_needs_verification': len(needs_verify_platforms) > 0,
'recommendations': recommendations
'recommendations': recommendations,
'avg_completeness': avg_completeness,
'avg_engagement': avg_engagement,
'total_posts_30d': total_posts_30d,
'last_post': last_post
})
# Platform statistics
@ -270,6 +297,18 @@ def admin_social_audit():
for item in needs_verification_items:
item['company_name'] = company_names.get(item['company_id'], 'Nieznana')
# Aggregate engagement & activity stats
all_completeness = [c['avg_completeness'] for c in companies if c['avg_completeness'] > 0]
avg_completeness_global = round(sum(all_completeness) / len(all_completeness)) if all_completeness else 0
all_engagement = [c['avg_engagement'] for c in companies if c['avg_engagement'] > 0]
avg_engagement_global = round(sum(all_engagement) / len(all_engagement), 2) if all_engagement else 0
now = datetime.now()
inactive_30d = len([c for c in companies if c['platform_count'] > 0 and (
not c['last_post'] or (now - c['last_post']).days > 30
)])
stats = {
'total_companies': total_companies,
'companies_with_sm': companies_with_sm,
@ -277,7 +316,10 @@ def admin_social_audit():
'total_profiles': total_profiles,
'total_followers': total_followers,
'needs_verification_count': len(needs_verification_items),
'platform_stats': platform_stats
'platform_stats': platform_stats,
'avg_completeness': avg_completeness_global,
'avg_engagement': avg_engagement_global,
'inactive_30d': inactive_30d
}
# Get unique categories

View File

@ -420,6 +420,52 @@
color: var(--primary);
}
/* Activity indicator */
.activity-dot {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--font-size-sm);
white-space: nowrap;
}
.activity-dot::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.activity-dot.active::before { background: #22c55e; }
.activity-dot.warning::before { background: #f59e0b; }
.activity-dot.inactive::before { background: #ef4444; }
.activity-dot.unknown::before { background: #d1d5db; }
/* Completeness bar (mini) */
.completeness-bar {
display: flex;
align-items: center;
gap: 6px;
}
.completeness-track {
width: 60px;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.completeness-fill {
height: 100%;
border-radius: 3px;
}
.completeness-fill.high { background: #22c55e; }
.completeness-fill.medium { background: #f59e0b; }
.completeness-fill.low { background: #ef4444; }
.completeness-value {
font-size: 12px;
font-weight: 500;
min-width: 30px;
}
/* Empty state */
.empty-state {
text-align: center;
@ -459,12 +505,12 @@
<div class="admin-header">
<div>
<h1>Panel Audyt Social Media</h1>
<p class="text-muted">Analiza obecnosci w mediach spolecznosciowych czlonkow Norda Biznes</p>
<p class="text-muted">Analiza obecności w mediach społecznościowych członków Norda Biznes</p>
<div class="data-source-info">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Dane z Facebook, Instagram, LinkedIn, YouTube, Twitter/X, TikTok</span>
<span>Dane z: Facebook, Instagram, LinkedIn, YouTube, Twitter/X, TikTok</span>
</div>
</div>
<div class="header-actions">
@ -472,7 +518,7 @@
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
Szczegoly
Szczegóły
</a>
</div>
</div>
@ -493,12 +539,26 @@
</div>
<div class="stat-card">
<span class="stat-number blue">{{ stats.total_profiles }}</span>
<span class="stat-label">Lacznie profili</span>
<span class="stat-label">Łącznie profili</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ "{:,}".format(stats.total_followers).replace(",", " ") }}</span>
<span class="stat-label">Lacznie obserwujacych</span>
<span class="stat-label">Łącznie obserwujących</span>
</div>
<div class="stat-card">
<span class="stat-number {{ 'green' if stats.avg_completeness >= 60 else ('yellow' if stats.avg_completeness >= 30 else 'red') }}">{{ stats.avg_completeness }}%</span>
<span class="stat-label">Śr. kompletność profilu</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.avg_engagement }}%</span>
<span class="stat-label">Śr. engagement</span>
</div>
{% if stats.inactive_30d > 0 %}
<div class="stat-card" style="border-top: 4px solid var(--error);">
<span class="stat-number red">{{ stats.inactive_30d }}</span>
<span class="stat-label">Nieaktywne &gt;30 dni</span>
</div>
{% endif %}
{% if stats.needs_verification_count > 0 %}
<div class="stat-card" style="border-top: 4px solid #f59e0b;">
<span class="stat-number yellow">{{ stats.needs_verification_count }}</span>
@ -514,7 +574,7 @@
<svg width="20" height="20" fill="none" stroke="#f59e0b" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
Profile do recznej weryfikacji
Profile do ręcznej weryfikacji
</h2>
<div style="display: grid; gap: var(--spacing-sm);">
{% for item in needs_verification %}
@ -565,7 +625,7 @@
<!-- Top Followers Section -->
{% if top_followers %}
<div class="top-followers">
<h2 class="section-title">Top 10 - Najwiecej obserwujacych</h2>
<h2 class="section-title">Top 10 — Najwięcej obserwujących</h2>
<div class="top-followers-list">
{% for company in top_followers %}
<div class="top-follower-item">
@ -574,7 +634,7 @@
<div class="top-company-name">
<a href="{{ url_for('company_detail', company_id=company.id) }}">{{ company.name }}</a>
</div>
<div class="top-followers-count">{{ "{:,}".format(company.total_followers).replace(",", " ") }} obserwujacych</div>
<div class="top-followers-count">{{ "{:,}".format(company.total_followers).replace(",", " ") }} obserwujących</div>
</div>
</div>
{% endfor %}
@ -606,6 +666,16 @@
<option value="none">Bez profili</option>
</select>
</div>
<div class="filter-group">
<label for="filterActivity">Aktywność:</label>
<select id="filterActivity" onchange="applyFilters()">
<option value="">Wszystkie</option>
<option value="active">Aktywne (&le;7 dni)</option>
<option value="warning">Spadek (&le;30 dni)</option>
<option value="inactive">Nieaktywne (&gt;30 dni)</option>
<option value="unknown">Brak danych</option>
</select>
</div>
<div class="filter-group">
<label for="filterSearch">Szukaj:</label>
<input type="text" id="filterSearch" placeholder="Nazwa firmy..." oninput="applyFilters()">
@ -632,7 +702,13 @@
Liczba <span class="sort-icon"></span>
</th>
<th data-sort="followers">
Obserwujacy <span class="sort-icon"></span>
Obserwujący <span class="sort-icon"></span>
</th>
<th data-sort="activity" class="hide-mobile">
Aktywność <span class="sort-icon"></span>
</th>
<th data-sort="completeness" class="hide-mobile">
Kompletność <span class="sort-icon"></span>
</th>
<th class="hide-mobile">Zalecenia</th>
<th data-sort="date" class="hide-mobile">
@ -643,10 +719,13 @@
</thead>
<tbody id="socialTableBody">
{% for company in companies %}
{% set activity_days = (now - company.last_post).days if company.last_post else 9999 %}
<tr data-category="{{ company.category }}"
data-name="{{ company.name|lower }}"
data-platforms="{{ company.platform_count }}"
data-followers="{{ company.total_followers }}"
data-activity="{{ activity_days }}"
data-completeness="{{ company.avg_completeness }}"
data-date="{{ company.last_verified.isoformat() if company.last_verified else '1970-01-01' }}"
data-has-facebook="{{ 'true' if company.has_facebook else 'false' }}"
data-has-instagram="{{ 'true' if company.has_instagram else 'false' }}"
@ -692,6 +771,40 @@
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="hide-mobile">
{% if company.platform_count > 0 %}
{% if company.last_post %}
{% set days = (now - company.last_post).days %}
{% if days <= 7 %}
<span class="activity-dot active" title="Ostatni post: {{ company.last_post.strftime('%d.%m.%Y') }}">{{ days }}d</span>
{% elif days <= 30 %}
<span class="activity-dot warning" title="Ostatni post: {{ company.last_post.strftime('%d.%m.%Y') }}">{{ days }}d</span>
{% else %}
<span class="activity-dot inactive" title="Ostatni post: {{ company.last_post.strftime('%d.%m.%Y') }}">{{ days }}d</span>
{% endif %}
{% else %}
<span class="activity-dot unknown">b/d</span>
{% endif %}
{% if company.total_posts_30d > 0 %}
<span style="font-size: 11px; color: var(--text-secondary); margin-left: 2px;" title="Posty w ostatnich 30 dniach">({{ company.total_posts_30d }})</span>
{% endif %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="hide-mobile">
{% if company.avg_completeness > 0 %}
<div class="completeness-bar">
<div class="completeness-track">
<div class="completeness-fill {{ 'high' if company.avg_completeness >= 60 else ('medium' if company.avg_completeness >= 30 else 'low') }}"
style="width: {{ company.avg_completeness }}%"></div>
</div>
<span class="completeness-value">{{ company.avg_completeness }}%</span>
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="hide-mobile" style="font-size: 11px; max-width: 200px;">
{% if company.recommendations %}
{% for rec in company.recommendations %}
@ -731,8 +844,8 @@
</div>
{% else %}
<div class="empty-state">
<h3>Brak firm do wyswietlenia</h3>
<p>Nie znaleziono firm z danymi Social Media.</p>
<h3>Brak firm do wyświetlenia</h3>
<p>Nie znaleziono firm z danymi social media.</p>
</div>
{% endif %}
{% endblock %}
@ -776,6 +889,10 @@ function sortTable(column) {
} else if (column === 'date') {
aVal = new Date(a.dataset.date).getTime();
bVal = new Date(b.dataset.date).getTime();
} else if (column === 'activity') {
// Lower days = more active, sort ascending by default
aVal = parseFloat(a.dataset.activity) || 9999;
bVal = parseFloat(b.dataset.activity) || 9999;
} else {
aVal = parseFloat(a.dataset[column]) || -1;
bVal = parseFloat(b.dataset[column]) || -1;
@ -799,6 +916,7 @@ document.querySelectorAll('.social-table th[data-sort]').forEach(th => {
function applyFilters() {
const category = document.getElementById('filterCategory').value;
const platform = document.getElementById('filterPlatform').value;
const activity = document.getElementById('filterActivity').value;
const search = document.getElementById('filterSearch').value.toLowerCase();
const rows = document.querySelectorAll('#socialTableBody tr');
@ -821,6 +939,15 @@ function applyFilters() {
}
}
// Activity filter
if (activity && show) {
const days = parseInt(row.dataset.activity) || 9999;
if (activity === 'active' && days > 7) show = false;
else if (activity === 'warning' && (days <= 7 || days > 30)) show = false;
else if (activity === 'inactive' && days <= 30) show = false;
else if (activity === 'unknown' && days !== 9999) show = false;
}
// Search filter
if (search && show) {
if (!row.dataset.name.includes(search)) {
@ -835,6 +962,7 @@ function applyFilters() {
function resetFilters() {
document.getElementById('filterCategory').value = '';
document.getElementById('filterPlatform').value = '';
document.getElementById('filterActivity').value = '';
document.getElementById('filterSearch').value = '';
applyFilters();
}