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
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:
parent
75b018808a
commit
4001b6402b
@ -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:
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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' %}▲{% elif p.followers_growth_trend == 'down' %}▼{% else %}●{% 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' %}▲{% elif p.followers_growth_trend == 'down' %}▼{% else %}—{% 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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user