feat(social-audit): add computed Tier 1 metrics - health score, growth, activity status
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

Add Social Health Score (0-100) composite: activity 30%, engagement 25%,
completeness 20%, growth 15%, cross-platform 10%. Add followers growth
rate from JSONB history, activity status classification (active/moderate/
slow/dormant/abandoned). Display health score in dashboard table and
detail view with color-coded ring, growth indicators per platform,
and cross-platform coverage score.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-12 07:55:33 +01:00
parent 75b018808a
commit 4001b6402b
3 changed files with 317 additions and 2 deletions

View File

@ -21,6 +21,135 @@ from utils.decorators import role_required, is_audit_owner
logger = logging.getLogger(__name__)
def _compute_followers_growth(followers_history):
"""Compute followers growth rate (%) from JSONB history.
Returns: (growth_rate_pct, trend_direction)
- growth_rate_pct: float, percentage change over last 30 days
- trend_direction: 'up', 'down', 'stable', 'unknown'
"""
if not followers_history or not isinstance(followers_history, list) or len(followers_history) < 2:
return 0, 'unknown'
# Sort by date descending
try:
sorted_history = sorted(followers_history, key=lambda x: x.get('date', ''), reverse=True)
except (TypeError, AttributeError):
return 0, 'unknown'
latest = sorted_history[0].get('count', 0)
# Find entry ~30 days ago
previous = None
for entry in sorted_history[1:]:
previous = entry.get('count', 0)
break # Just take the previous entry
if not previous or previous == 0:
return 0, 'unknown'
rate = round((latest - previous) / previous * 100, 1)
if rate > 1:
return rate, 'up'
elif rate < -1:
return rate, 'down'
return rate, 'stable'
def _compute_activity_status(last_post_date):
"""Classify account activity based on last post date.
Returns: (status, label, color)
"""
if not last_post_date:
return 'unknown', 'Brak danych', '#9ca3af'
days = (datetime.now() - last_post_date).days
if days <= 14:
return 'active', 'Aktywne', '#22c55e'
elif days <= 30:
return 'moderate', 'Umiarkowane', '#84cc16'
elif days <= 90:
return 'slow', 'Sporadyczne', '#f59e0b'
elif days <= 365:
return 'dormant', 'Uśpione', '#ef4444'
return 'abandoned', 'Porzucone', '#991b1b'
def _compute_social_health_score(platform_details):
"""Compute composite social health score (0-100) for a company.
Weights:
- Activity (30%): based on last_post_date freshness
- Engagement (25%): based on engagement_rate
- Completeness (20%): based on profile_completeness_score
- Growth (15%): based on followers_history trend
- Cross-platform (10%): based on number of platforms
"""
if not platform_details:
return 0
# Activity score (0-100)
activity_scores = []
for p in platform_details:
if p.get('last_post_date'):
days = (datetime.now() - p['last_post_date']).days
if days <= 7:
activity_scores.append(100)
elif days <= 14:
activity_scores.append(85)
elif days <= 30:
activity_scores.append(65)
elif days <= 90:
activity_scores.append(35)
elif days <= 365:
activity_scores.append(10)
else:
activity_scores.append(0)
else:
activity_scores.append(0)
activity = sum(activity_scores) / len(activity_scores) if activity_scores else 0
# Engagement score (0-100): 5%+ = excellent, 1% = good, 0 = no data
engagement_rates = [p.get('engagement_rate', 0) for p in platform_details]
avg_engagement = sum(engagement_rates) / len(engagement_rates) if engagement_rates else 0
engagement = min(avg_engagement * 20, 100) # 5% engagement = 100 score
# Completeness score (0-100)
completeness_scores = [p.get('profile_completeness_score', 0) for p in platform_details]
completeness = sum(completeness_scores) / len(completeness_scores) if completeness_scores else 0
# Growth score (0-100)
growth_scores = []
for p in platform_details:
rate, _ = _compute_followers_growth(p.get('followers_history', []))
if rate > 10:
growth_scores.append(100)
elif rate > 5:
growth_scores.append(80)
elif rate > 0:
growth_scores.append(60)
elif rate == 0:
growth_scores.append(30)
else:
growth_scores.append(max(0, 30 + rate)) # Negative growth penalized
growth = sum(growth_scores) / len(growth_scores) if growth_scores else 30
# Cross-platform score (0-100)
num_platforms = len(platform_details)
cross_platform_map = {1: 20, 2: 45, 3: 70, 4: 85, 5: 95}
cross_platform = cross_platform_map.get(num_platforms, 100 if num_platforms >= 6 else 0)
# Weighted composite
score = (
activity * 0.30 +
engagement * 0.25 +
completeness * 0.20 +
growth * 0.15 +
cross_platform * 0.10
)
return round(score)
# ============================================================
# SOCIAL MEDIA ANALYTICS DASHBOARD
# ============================================================
@ -246,6 +375,18 @@ def admin_social_audit():
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
# Compute health score from platform data
platform_dicts = [
{
'last_post_date': p.get('last_post_date'),
'engagement_rate': p.get('engagement_rate', 0),
'profile_completeness_score': p.get('completeness', 0),
'followers_history': [],
}
for p in sm_data.values()
]
health_score = _compute_social_health_score(platform_dicts) if platform_dicts else 0
companies.append({
'id': row.id,
'name': row.name,
@ -267,7 +408,8 @@ def admin_social_audit():
'avg_completeness': avg_completeness,
'avg_engagement': avg_engagement,
'total_posts_30d': total_posts_30d,
'last_post': last_post
'last_post': last_post,
'health_score': health_score
})
# Platform statistics
@ -401,8 +543,45 @@ def admin_social_audit_detail(company_id):
'check_status': p.check_status,
'is_valid': p.is_valid
}
# Computed Tier 1 metrics
growth_rate, growth_trend = _compute_followers_growth(p.followers_history or [])
detail['followers_growth_rate'] = growth_rate
detail['followers_growth_trend'] = growth_trend
activity_status, activity_label, activity_color = _compute_activity_status(p.last_post_date)
detail['activity_status'] = activity_status
detail['activity_label'] = activity_label
detail['activity_color'] = activity_color
platform_details.append(detail)
# Company-level computed scores
health_score = _compute_social_health_score(platform_details)
# Cross-platform score
num_platforms = len(platform_details)
cross_platform_map = {0: 0, 1: 20, 2: 45, 3: 70, 4: 85, 5: 95}
cross_platform_score = cross_platform_map.get(num_platforms, 100 if num_platforms >= 6 else 0)
# Overall activity status (best of all platforms)
best_activity = 'unknown'
best_activity_label = 'Brak danych'
best_activity_color = '#9ca3af'
activity_priority = ['active', 'moderate', 'slow', 'dormant', 'abandoned', 'unknown']
for p in platform_details:
if activity_priority.index(p['activity_status']) < activity_priority.index(best_activity):
best_activity = p['activity_status']
best_activity_label = p['activity_label']
best_activity_color = p['activity_color']
company_scores = {
'health_score': health_score,
'cross_platform_score': cross_platform_score,
'activity_status': best_activity,
'activity_label': best_activity_label,
'activity_color': best_activity_color,
}
# Recommendations
recommendations = []
platform_names = {p['platform'] for p in platform_details}
@ -441,6 +620,7 @@ def admin_social_audit_detail(company_id):
platform_details=platform_details,
invalid_profiles=invalid_profiles,
recommendations=recommendations,
company_scores=company_scores,
now=datetime.now()
)
finally:

View File

@ -711,6 +711,9 @@
<th data-sort="completeness" class="hide-mobile">
Kompletność <span class="sort-icon"></span>
</th>
<th data-sort="health" class="hide-mobile">
Zdrowie SM <span class="sort-icon"></span>
</th>
<th class="hide-mobile">Zalecenia</th>
<th data-sort="date" class="hide-mobile">
Weryfikacja <span class="sort-icon"></span>
@ -727,6 +730,7 @@
data-followers="{{ company.total_followers }}"
data-activity="{{ activity_days }}"
data-completeness="{{ company.avg_completeness }}"
data-health="{{ company.health_score }}"
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' }}"
@ -806,6 +810,19 @@
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="hide-mobile">
{% if company.health_score > 0 %}
<div class="completeness-bar" title="Social Health Score: {{ company.health_score }}/100">
<div class="completeness-track">
<div class="completeness-fill {{ 'high' if company.health_score >= 60 else ('medium' if company.health_score >= 30 else 'low') }}"
style="width: {{ company.health_score }}%"></div>
</div>
<span class="completeness-value" style="font-weight: 600; color: {{ '#22c55e' if company.health_score >= 60 else ('#f59e0b' if company.health_score >= 30 else '#ef4444') }};">{{ company.health_score }}</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 %}

View File

@ -268,6 +268,72 @@
color: #7f1d1d;
}
/* Health score circle */
.health-ring {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-2xl);
font-weight: 700;
flex-shrink: 0;
}
.health-ring.high { background: #dcfce7; color: #15803d; border: 3px solid #22c55e; }
.health-ring.medium { background: #fef3c7; color: #92400e; border: 3px solid #f59e0b; }
.health-ring.low { background: #fef2f2; color: #991b1b; border: 3px solid #ef4444; }
.scores-strip {
display: flex;
gap: var(--spacing-lg);
align-items: center;
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
}
.score-details {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: var(--spacing-sm);
}
.score-item {
padding: var(--spacing-sm);
background: var(--background);
border-radius: var(--radius);
text-align: center;
}
.score-item-value {
font-size: var(--font-size-lg);
font-weight: 600;
}
.score-item-label {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.growth-indicator {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: var(--font-size-sm);
font-weight: 500;
}
.growth-indicator.up { color: #22c55e; }
.growth-indicator.down { color: #ef4444; }
.growth-indicator.stable { color: var(--text-secondary); }
.growth-indicator.unknown { color: var(--text-secondary); }
/* Empty state */
.empty-profiles {
text-align: center;
@ -343,6 +409,46 @@
</div>
</div>
<!-- Health Score & Computed Metrics -->
{% if company_scores and platform_details %}
<div class="scores-strip">
<div class="health-ring {{ 'high' if company_scores.health_score >= 60 else ('medium' if company_scores.health_score >= 30 else 'low') }}" title="Social Health Score">
{{ company_scores.health_score }}
</div>
<div style="flex-shrink: 0;">
<div style="font-weight: 600; font-size: var(--font-size-lg);">Social Health Score</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">
{% if company_scores.health_score >= 60 %}Dobra kondycja
{% elif company_scores.health_score >= 30 %}Wymaga uwagi
{% else %}Pilne do poprawy{% endif %}
</div>
</div>
<div class="score-details">
<div class="score-item">
<div class="score-item-value" style="color: {{ company_scores.activity_color }};">{{ company_scores.activity_label }}</div>
<div class="score-item-label">Aktywność</div>
</div>
<div class="score-item">
<div class="score-item-value">{{ company_scores.cross_platform_score }}%</div>
<div class="score-item-label">Cross-platform</div>
</div>
{% for p in platform_details %}
{% if p.followers_growth_trend != 'unknown' %}
<div class="score-item">
<div class="score-item-value">
<span class="growth-indicator {{ p.followers_growth_trend }}">
{% if p.followers_growth_trend == 'up' %}&#9650;{% elif p.followers_growth_trend == 'down' %}&#9660;{% else %}&#9679;{% endif %}
{{ p.followers_growth_rate }}%
</span>
</div>
<div class="score-item-label">{{ p.platform|capitalize }} wzrost</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- Recommendations -->
{% if recommendations %}
<div class="recommendations">
@ -393,13 +499,25 @@
<div class="platform-detail-body">
<div class="metrics-grid">
<div class="metric">
<div class="metric-value">{{ "{:,}".format(p.followers_count).replace(",", " ") }}</div>
<div class="metric-value">
{{ "{:,}".format(p.followers_count).replace(",", " ") }}
{% if p.followers_growth_trend != 'unknown' %}
<span class="growth-indicator {{ p.followers_growth_trend }}" style="font-size: 13px; margin-left: 4px;">
{% if p.followers_growth_trend == 'up' %}&#9650;{% elif p.followers_growth_trend == 'down' %}&#9660;{% else %}&#8212;{% endif %}
{{ p.followers_growth_rate }}%
</span>
{% endif %}
</div>
<div class="metric-label">Obserwujących</div>
</div>
<div class="metric">
<div class="metric-value">{{ p.engagement_rate }}%</div>
<div class="metric-label">Engagement rate</div>
</div>
<div class="metric">
<div class="metric-value" style="color: {{ p.activity_color }};">{{ p.activity_label }}</div>
<div class="metric-label">Status aktywności</div>
</div>
<div class="metric">
<div class="metric-value">{{ p.posts_count_30d }}</div>
<div class="metric-label">Postów (30 dni)</div>