use serde::{Deserialize, Serialize};
use super::vitals::WebVitals;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PerformanceGrade {
Platinum,
Gold,
Silver,
Bronze,
NeedsImprovement,
}
impl PerformanceGrade {
pub fn from_score(score: u32) -> Self {
match score {
90..=100 => PerformanceGrade::Platinum,
75..=89 => PerformanceGrade::Gold,
60..=74 => PerformanceGrade::Silver,
50..=59 => PerformanceGrade::Bronze,
_ => PerformanceGrade::NeedsImprovement,
}
}
pub fn emoji(&self) -> &'static str {
match self {
PerformanceGrade::Platinum => "🏆",
PerformanceGrade::Gold => "🥇",
PerformanceGrade::Silver => "🥈",
PerformanceGrade::Bronze => "🥉",
PerformanceGrade::NeedsImprovement => "⚠️",
}
}
pub fn label(&self) -> &'static str {
match self {
PerformanceGrade::Platinum => "SEHR GUT",
PerformanceGrade::Gold => "GUT",
PerformanceGrade::Silver => "SOLIDE",
PerformanceGrade::Bronze => "AUSBAUFÄHIG",
PerformanceGrade::NeedsImprovement => "UNGENÜGEND",
}
}
}
impl std::fmt::Display for PerformanceGrade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.emoji(), self.label())
}
}
const W_FCP: u32 = 10;
const W_LCP: u32 = 25;
const W_TBT: u32 = 30;
const W_CLS: u32 = 25;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceScore {
pub overall: u32,
pub grade: PerformanceGrade,
pub lcp_score: Option<u32>,
pub fcp_score: Option<u32>,
pub cls_score: Option<u32>,
pub interactivity_score: Option<u32>,
pub metrics_available: u32,
}
pub fn calculate_performance_score(vitals: &WebVitals) -> PerformanceScore {
let lcp_score = vitals.lcp.as_ref().map(|v| score_lcp(v.value));
let fcp_score = vitals.fcp.as_ref().map(|v| score_fcp(v.value));
let cls_score = vitals.cls.as_ref().map(|v| score_cls(v.value));
let interactivity_score = vitals.tbt.as_ref().map(|v| score_interactivity(v.value));
let mut total = 0u32;
let mut max_possible = 0u32;
let mut metrics_available = 0u32;
if let Some(s) = lcp_score {
total += s;
max_possible += W_LCP;
metrics_available += 1;
}
if let Some(s) = fcp_score {
total += s;
max_possible += W_FCP;
metrics_available += 1;
}
if let Some(s) = cls_score {
total += s;
max_possible += W_CLS;
metrics_available += 1;
}
if let Some(s) = interactivity_score {
total += s;
max_possible += W_TBT;
metrics_available += 1;
}
let overall = (total * 100)
.checked_div(max_possible)
.map(|n| n.min(100))
.unwrap_or(0);
let grade = PerformanceGrade::from_score(overall);
PerformanceScore {
overall,
grade,
lcp_score,
fcp_score,
cls_score,
interactivity_score,
metrics_available,
}
}
fn log_normal_score(value: f64, p10: f64, p50: f64, max_points: u32) -> u32 {
if value <= 0.0 {
return max_points;
}
const Z90: f64 = 1.2815515655446004;
let p10_log = p10.ln();
let p50_log = p50.ln();
let value_log = value.ln();
let sigma = (p50_log - p10_log).abs() / Z90;
let z = (value_log - p50_log) / sigma;
let s = (1.0 - standard_normal_cdf(z)).clamp(0.0, 1.0);
(s * max_points as f64).round() as u32
}
fn standard_normal_cdf(x: f64) -> f64 {
0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
}
fn erf(x: f64) -> f64 {
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let a1 = 0.254829592;
let a2 = -0.284496736;
let a3 = 1.421413741;
let a4 = -1.453152027;
let a5 = 1.061405429;
let p = 0.3275911;
let t = 1.0 / (1.0 + p * x);
let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
sign * y
}
fn score_lcp(ms: f64) -> u32 {
log_normal_score(ms, 2500.0, 4000.0, W_LCP)
}
fn score_fcp(ms: f64) -> u32 {
log_normal_score(ms, 1800.0, 3000.0, W_FCP)
}
fn score_cls(value: f64) -> u32 {
if value > 0.5 {
return 0;
}
log_normal_score(value.max(0.0001), 0.1, 0.25, W_CLS)
}
fn score_interactivity(ms: f64) -> u32 {
log_normal_score(ms.max(1.0), 200.0, 600.0, W_TBT)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::performance::vitals::VitalMetric;
#[test]
fn test_performance_grade_from_score() {
assert_eq!(PerformanceGrade::from_score(95), PerformanceGrade::Platinum);
assert_eq!(PerformanceGrade::from_score(80), PerformanceGrade::Gold);
assert_eq!(PerformanceGrade::from_score(65), PerformanceGrade::Silver);
assert_eq!(PerformanceGrade::from_score(55), PerformanceGrade::Bronze);
assert_eq!(
PerformanceGrade::from_score(30),
PerformanceGrade::NeedsImprovement
);
}
#[test]
fn test_score_lcp_good() {
assert!(score_lcp(2500.0) >= 22);
assert_eq!(score_lcp(1000.0), 25);
}
#[test]
fn test_score_lcp_poor() {
let s_p50 = score_lcp(4000.0);
assert!(s_p50 >= 10 && s_p50 <= 14, "p50 score was {}", s_p50);
assert!(score_lcp(7000.0) <= 5);
assert!(score_lcp(10000.0) <= 1);
}
#[test]
fn test_score_cls_lighthouse_parity() {
assert!(score_cls(0.1) >= 22);
let s_p50 = score_cls(0.25);
assert!(s_p50 >= 10 && s_p50 <= 14);
assert_eq!(score_cls(1.7), 0);
}
#[test]
fn test_overall_caps_when_lcp_is_extreme() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(7432.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(2400.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.04, 0.1, 0.25)),
tbt: Some(VitalMetric::new(800.0, 200.0, 600.0)),
..Default::default()
};
let s = calculate_performance_score(&vitals);
assert!(s.overall < 50, "overall was {}", s.overall);
}
#[test]
fn test_calculate_performance_score() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(1500.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(1200.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.05, 0.1, 0.25)),
tbt: Some(VitalMetric::new(150.0, 200.0, 600.0)),
..Default::default()
};
let score = calculate_performance_score(&vitals);
assert!(score.overall >= 80);
assert!(matches!(
score.grade,
PerformanceGrade::Gold | PerformanceGrade::Platinum
));
}
}