use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone)]
pub struct User {
pub id: String,
pub email: String,
pub password_hash: String,
pub display_name: String,
pub capacity_points: Option<i64>,
pub wip_limit: Option<i64>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Project {
pub id: String,
pub owner_id: String,
pub name: String,
pub description: String,
pub wip_limit_default: Option<i64>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueStatus {
Open,
InProgress,
Done,
}
impl IssueStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Open => "open",
Self::InProgress => "in_progress",
Self::Done => "done",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Open => "Open",
Self::InProgress => "In Progress",
Self::Done => "Done",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"open" => Some(Self::Open),
"in_progress" => Some(Self::InProgress),
"done" => Some(Self::Done),
_ => None,
}
}
pub fn all() -> [IssueStatus; 3] {
[Self::Open, Self::InProgress, Self::Done]
}
}
impl fmt::Display for IssueStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
Low,
Medium,
High,
Urgent,
}
impl Priority {
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Urgent => "urgent",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Low => "Low",
Self::Medium => "Medium",
Self::High => "High",
Self::Urgent => "Urgent",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"urgent" => Some(Self::Urgent),
_ => None,
}
}
pub fn all() -> [Priority; 4] {
[Self::Low, Self::Medium, Self::High, Self::Urgent]
}
pub fn badge_class(&self) -> &'static str {
match self {
Self::Low => "badge-ghost",
Self::Medium => "badge-info",
Self::High => "badge-warning",
Self::Urgent => "badge-error",
}
}
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct Issue {
pub id: String,
pub project_id: String,
pub author_id: String,
pub title: String,
pub description: String,
pub status: IssueStatus,
pub priority: Priority,
pub position: i64,
pub effort: Option<i64>,
pub assignee_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub const EFFORT_PRESETS: &[i64] = &[1, 2, 3, 5, 8, 13];
#[derive(Debug, Clone)]
pub struct AssigneeOption {
pub id: String,
pub display_name: String,
}
#[derive(Debug, Clone)]
pub struct UserLoad {
pub user_id: String,
pub display_name: String,
pub in_flight_points: i64,
pub capacity_points: Option<i64>,
pub in_flight_issues: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkloadState {
Unmonitored,
Healthy,
Strained,
Overloaded,
}
impl WorkloadState {
pub fn badge_class(&self) -> &'static str {
match self {
Self::Unmonitored => "badge-ghost",
Self::Healthy => "badge-success",
Self::Strained => "badge-warning",
Self::Overloaded => "badge-error",
}
}
}
pub fn workload_state(load: &UserLoad) -> WorkloadState {
let Some(cap) = load.capacity_points else {
return WorkloadState::Unmonitored;
};
if cap == 0 {
return WorkloadState::Unmonitored;
}
if load.in_flight_points > cap {
WorkloadState::Overloaded
} else if load.in_flight_points * 5 >= cap * 4 {
WorkloadState::Strained
} else {
WorkloadState::Healthy
}
}
pub fn projected_workload_state(load: &UserLoad, delta: i64) -> WorkloadState {
let projected = UserLoad {
in_flight_points: (load.in_flight_points + delta).max(0),
..load.clone()
};
workload_state(&projected)
}
#[derive(Debug, Clone)]
pub struct CurrentUser {
pub id: String,
pub email: String,
pub display_name: String,
}
impl From<User> for CurrentUser {
fn from(u: User) -> Self {
Self {
id: u.id,
email: u.email,
display_name: u.display_name,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HealthIndicator {
Insufficient,
Good,
Watch,
Concern,
}
impl HealthIndicator {
pub fn badge_class(&self) -> &'static str {
match self {
Self::Insufficient => "badge-ghost",
Self::Good => "badge-success",
Self::Watch => "badge-warning",
Self::Concern => "badge-error",
}
}
}
pub mod project_health {
use super::HealthIndicator;
pub const ACTIVITY_WINDOW_DAYS: i64 = 14;
pub const LONG_STALE_THRESHOLD_DAYS: i64 = ACTIVITY_WINDOW_DAYS;
#[derive(Debug, Clone, Default)]
pub struct ProjectHealthRaw {
pub total_issues: i64,
pub done_issues: i64,
pub oldest_in_flight_age_days: Option<i64>,
pub recent_activity_count: i64,
pub in_flight_issues: i64,
pub top_assignee_in_flight_issues: i64,
pub long_stale_in_flight_issues: i64,
pub wip_violators: i64,
pub active_assignees: i64,
}
pub type ProjectHealth = ProjectHealthRaw;
pub fn classify_throughput(h: &ProjectHealthRaw) -> HealthIndicator {
if h.total_issues == 0 {
return HealthIndicator::Insufficient;
}
let pct = (h.done_issues * 100) / h.total_issues;
if pct >= 60 {
HealthIndicator::Good
} else if pct >= 30 {
HealthIndicator::Watch
} else {
HealthIndicator::Concern
}
}
pub fn classify_staleness(h: &ProjectHealthRaw) -> HealthIndicator {
match h.oldest_in_flight_age_days {
None => HealthIndicator::Good,
Some(d) if d >= 28 => HealthIndicator::Concern,
Some(d) if d >= 14 => HealthIndicator::Watch,
Some(_) => HealthIndicator::Good,
}
}
pub fn classify_activity(h: &ProjectHealthRaw) -> HealthIndicator {
if h.total_issues == 0 {
return HealthIndicator::Insufficient;
}
if h.recent_activity_count >= 5 {
HealthIndicator::Good
} else if h.recent_activity_count >= 1 {
HealthIndicator::Watch
} else {
HealthIndicator::Concern
}
}
pub fn classify_bus_factor(h: &ProjectHealthRaw) -> HealthIndicator {
if h.in_flight_issues == 0 {
return HealthIndicator::Insufficient;
}
if h.active_assignees <= 1 {
return HealthIndicator::Watch;
}
let pct = (h.top_assignee_in_flight_issues * 100) / h.in_flight_issues;
if pct >= 80 {
HealthIndicator::Concern
} else if pct >= 60 {
HealthIndicator::Watch
} else {
HealthIndicator::Good
}
}
pub fn classify_long_stale(h: &ProjectHealthRaw) -> HealthIndicator {
if h.in_flight_issues == 0 {
return HealthIndicator::Insufficient;
}
let pct = (h.long_stale_in_flight_issues * 100) / h.in_flight_issues;
if pct >= 40 {
HealthIndicator::Concern
} else if pct >= 20 {
HealthIndicator::Watch
} else {
HealthIndicator::Good
}
}
pub fn classify_wip_compliance(h: &ProjectHealthRaw) -> HealthIndicator {
if h.active_assignees == 0 {
return HealthIndicator::Insufficient;
}
let pct = (h.wip_violators * 100) / h.active_assignees;
if pct >= 50 {
HealthIndicator::Concern
} else if pct >= 1 {
HealthIndicator::Watch
} else {
HealthIndicator::Good
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndicatorKind {
Throughput,
Staleness,
Activity,
BusFactor,
LongStale,
WipCompliance,
}
impl IndicatorKind {
pub fn label(&self) -> &'static str {
match self {
Self::Throughput => "Throughput",
Self::Staleness => "Oldest in-flight",
Self::Activity => "Activity (14d)",
Self::BusFactor => "Bus factor",
Self::LongStale => "Long-stale",
Self::WipCompliance => "WIP compliance",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Throughput => {
"Share of issues that have reached Done."
}
Self::Staleness => {
"Age of the oldest issue still Open or In Progress."
}
Self::Activity => {
"Issues created or finished in the last 14 days."
}
Self::BusFactor => {
"Concentration of in-flight work on a single user."
}
Self::LongStale => {
"Share of in-flight issues untouched for over two weeks."
}
Self::WipCompliance => {
"Share of active users currently over their WIP limit."
}
}
}
}
#[derive(Debug, Clone)]
pub struct Indicator {
pub kind: IndicatorKind,
pub label: &'static str,
pub value_display: String,
pub state: HealthIndicator,
pub normalized: f64,
pub weight: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct HealthWeights {
pub throughput: f64,
pub staleness: f64,
pub activity: f64,
pub bus_factor: f64,
pub long_stale: f64,
pub wip_compliance: f64,
}
impl HealthWeights {
pub const DEFAULT: HealthWeights = HealthWeights {
throughput: 0.20,
staleness: 0.20,
activity: 0.15,
bus_factor: 0.15,
long_stale: 0.15,
wip_compliance: 0.15,
};
fn for_kind(&self, kind: IndicatorKind) -> f64 {
match kind {
IndicatorKind::Throughput => self.throughput,
IndicatorKind::Staleness => self.staleness,
IndicatorKind::Activity => self.activity,
IndicatorKind::BusFactor => self.bus_factor,
IndicatorKind::LongStale => self.long_stale,
IndicatorKind::WipCompliance => self.wip_compliance,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trend {
Unavailable,
Up { delta: u8 },
Down { delta: u8 },
Flat,
}
#[derive(Debug, Clone)]
pub struct HealthScore {
pub value: u8,
pub state: HealthIndicator,
pub summary: String,
pub trend: Trend,
}
#[derive(Debug, Clone)]
pub struct ProjectHealthReport {
pub score: HealthScore,
pub indicators: Vec<Indicator>,
pub raw: ProjectHealthRaw,
}
pub fn normalize(kind: IndicatorKind, raw: &ProjectHealthRaw) -> f64 {
match kind {
IndicatorKind::Throughput => normalize_throughput(raw),
IndicatorKind::Staleness => normalize_staleness(raw),
IndicatorKind::Activity => normalize_activity(raw),
IndicatorKind::BusFactor => normalize_bus_factor(raw),
IndicatorKind::LongStale => normalize_long_stale(raw),
IndicatorKind::WipCompliance => normalize_wip_compliance(raw),
}
}
fn normalize_throughput(h: &ProjectHealthRaw) -> f64 {
if h.total_issues == 0 {
return 0.5;
}
let pct = (h.done_issues * 100) / h.total_issues;
let pct = pct as f64;
if pct >= 60.0 {
0.85 + (pct - 60.0) / 60.0 * 0.15
} else if pct >= 30.0 {
0.5 + (pct - 30.0) / 30.0 * 0.35
} else {
pct / 60.0
}
.clamp(0.0, 1.0)
}
fn normalize_staleness(h: &ProjectHealthRaw) -> f64 {
match h.oldest_in_flight_age_days {
None => 1.0,
Some(d) if d <= 7 => 1.0,
Some(d) if d < 14 => 0.85 - (d - 7) as f64 / 7.0 * 0.15,
Some(d) if d < 28 => 0.7 - (d - 14) as f64 / 14.0 * 0.5,
Some(_) => 0.0,
}
.clamp(0.0, 1.0)
}
fn normalize_activity(h: &ProjectHealthRaw) -> f64 {
if h.total_issues == 0 {
return 0.5;
}
match h.recent_activity_count {
n if n >= 5 => 1.0,
n if n >= 1 => 0.5 + (n - 1) as f64 / 4.0 * 0.4,
_ => 0.0,
}
.clamp(0.0, 1.0)
}
fn normalize_bus_factor(h: &ProjectHealthRaw) -> f64 {
if h.in_flight_issues == 0 {
return 0.5;
}
if h.active_assignees <= 1 {
return 0.4;
}
let pct = (h.top_assignee_in_flight_issues * 100) / h.in_flight_issues;
let pct = pct as f64;
if pct < 60.0 {
1.0
} else if pct < 80.0 {
1.0 - (pct - 60.0) / 20.0 * 0.7
} else {
(0.3 - (pct - 80.0) / 20.0 * 0.3).max(0.0)
}
.clamp(0.0, 1.0)
}
fn normalize_long_stale(h: &ProjectHealthRaw) -> f64 {
if h.in_flight_issues == 0 {
return 1.0;
}
let pct = (h.long_stale_in_flight_issues * 100) / h.in_flight_issues;
let pct = pct as f64;
if pct < 20.0 {
1.0 - pct / 20.0 * 0.15
} else if pct < 40.0 {
0.85 - (pct - 20.0) / 20.0 * 0.55
} else {
(0.3 - (pct - 40.0) / 60.0 * 0.3).max(0.0)
}
.clamp(0.0, 1.0)
}
fn normalize_wip_compliance(h: &ProjectHealthRaw) -> f64 {
if h.active_assignees == 0 {
return 1.0;
}
let pct = (h.wip_violators * 100) / h.active_assignees;
let pct = pct as f64;
if pct == 0.0 {
1.0
} else if pct < 50.0 {
0.85 - pct / 50.0 * 0.55
} else {
(0.3 - (pct - 50.0) / 50.0 * 0.3).max(0.0)
}
.clamp(0.0, 1.0)
}
pub fn format_value(kind: IndicatorKind, raw: &ProjectHealthRaw) -> String {
match kind {
IndicatorKind::Throughput => {
if raw.total_issues == 0 {
"—".to_string()
} else {
let pct = (raw.done_issues * 100) / raw.total_issues;
format!("{} / {} ({}%)", raw.done_issues, raw.total_issues, pct)
}
}
IndicatorKind::Staleness => match raw.oldest_in_flight_age_days {
None => "—".to_string(),
Some(d) => format!("{d} d"),
},
IndicatorKind::Activity => format!("{}", raw.recent_activity_count),
IndicatorKind::BusFactor => {
if raw.in_flight_issues == 0 {
"—".to_string()
} else if raw.active_assignees <= 1 {
"solo".to_string()
} else {
let pct = (raw.top_assignee_in_flight_issues * 100) / raw.in_flight_issues;
format!("{}% on top", pct)
}
}
IndicatorKind::LongStale => {
if raw.in_flight_issues == 0 {
"—".to_string()
} else {
format!(
"{} / {}",
raw.long_stale_in_flight_issues, raw.in_flight_issues
)
}
}
IndicatorKind::WipCompliance => {
if raw.active_assignees == 0 {
"—".to_string()
} else if raw.wip_violators == 0 {
"all within".to_string()
} else {
format!("{} over", raw.wip_violators)
}
}
}
}
pub fn classify(kind: IndicatorKind, raw: &ProjectHealthRaw) -> HealthIndicator {
match kind {
IndicatorKind::Throughput => classify_throughput(raw),
IndicatorKind::Staleness => classify_staleness(raw),
IndicatorKind::Activity => classify_activity(raw),
IndicatorKind::BusFactor => classify_bus_factor(raw),
IndicatorKind::LongStale => classify_long_stale(raw),
IndicatorKind::WipCompliance => classify_wip_compliance(raw),
}
}
pub const ALL_INDICATORS: &[IndicatorKind] = &[
IndicatorKind::Throughput,
IndicatorKind::Staleness,
IndicatorKind::Activity,
IndicatorKind::BusFactor,
IndicatorKind::LongStale,
IndicatorKind::WipCompliance,
];
pub fn compute_report(raw: ProjectHealthRaw) -> ProjectHealthReport {
compute_report_with_weights(raw, HealthWeights::DEFAULT)
}
pub fn compute_report_with_weights(
raw: ProjectHealthRaw,
weights: HealthWeights,
) -> ProjectHealthReport {
compute_report_full(raw, weights, &[])
}
pub fn compute_report_with_trend(
raw: ProjectHealthRaw,
past_scores: &[u8],
) -> ProjectHealthReport {
compute_report_full(raw, HealthWeights::DEFAULT, past_scores)
}
fn compute_report_full(
raw: ProjectHealthRaw,
weights: HealthWeights,
past_scores: &[u8],
) -> ProjectHealthReport {
let indicators: Vec<Indicator> = ALL_INDICATORS
.iter()
.map(|&kind| Indicator {
kind,
label: kind.label(),
value_display: format_value(kind, &raw),
state: classify(kind, &raw),
normalized: normalize(kind, &raw),
weight: weights.for_kind(kind),
})
.collect();
let score_value = composite_score(&indicators);
let state = classify_score(score_value);
let summary = summarize(&indicators);
let trend = classify_trend(score_value, past_scores);
ProjectHealthReport {
score: HealthScore {
value: score_value,
state,
summary,
trend,
},
indicators,
raw,
}
}
pub const TREND_FLAT_THRESHOLD: u8 = 5;
pub const TREND_PAST_WINDOW_MIN_DAYS: i64 = 7;
pub const TREND_PAST_WINDOW_MAX_DAYS: i64 = 14;
pub fn classify_trend(current: u8, past_scores: &[u8]) -> Trend {
if past_scores.is_empty() {
return Trend::Unavailable;
}
let mut sorted: Vec<u8> = past_scores.to_vec();
sorted.sort_unstable();
let n = sorted.len();
let median: u16 = if n % 2 == 1 {
sorted[n / 2] as u16
} else {
(sorted[n / 2 - 1] as u16 + sorted[n / 2] as u16) / 2
};
let diff = (current as i16) - (median as i16);
if diff.unsigned_abs() < TREND_FLAT_THRESHOLD as u16 {
Trend::Flat
} else if diff > 0 {
Trend::Up {
delta: diff.min(100) as u8,
}
} else {
Trend::Down {
delta: (-diff).min(100) as u8,
}
}
}
fn composite_score(indicators: &[Indicator]) -> u8 {
let mut weighted_sum = 0.0;
let mut weight_total = 0.0;
for ind in indicators {
if matches!(ind.state, HealthIndicator::Insufficient) {
continue;
}
weighted_sum += ind.normalized * ind.weight;
weight_total += ind.weight;
}
if weight_total == 0.0 {
return 50; }
((weighted_sum / weight_total) * 100.0).round().clamp(0.0, 100.0) as u8
}
fn classify_score(value: u8) -> HealthIndicator {
if value >= 75 {
HealthIndicator::Good
} else if value >= 50 {
HealthIndicator::Watch
} else {
HealthIndicator::Concern
}
}
pub fn summarize(indicators: &[Indicator]) -> String {
let mut bad: Vec<&Indicator> = indicators
.iter()
.filter(|i| {
matches!(i.state, HealthIndicator::Concern | HealthIndicator::Watch)
})
.collect();
bad.sort_by(|a, b| {
let order = |s| match s {
HealthIndicator::Concern => 0,
HealthIndicator::Watch => 1,
_ => 2,
};
order(a.state)
.cmp(&order(b.state))
.then_with(|| {
b.weight
.partial_cmp(&a.weight)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
if bad.is_empty() {
return "Looking healthy.".to_string();
}
let lead = bad[0];
match (bad.len(), lead.state) {
(1, HealthIndicator::Concern) => {
format!("{} is a concern.", lead.label)
}
(1, _) => format!("{} is worth a glance.", lead.label),
(_, HealthIndicator::Concern) => {
let second = bad[1];
format!(
"{} is a concern; {} also needs attention.",
lead.label, second.label
)
}
_ => {
let second = bad[1];
format!("{} and {} are worth a glance.", lead.label, second.label)
}
}
}
}
pub mod personal_metrics {
use super::HealthIndicator;
pub const DEFAULT_WIP_LIMIT: i64 = 3;
pub const PERSONAL_ACTIVITY_WINDOW_DAYS: i64 =
super::project_health::ACTIVITY_WINDOW_DAYS;
#[derive(Debug, Clone)]
pub struct PersonalMetrics {
pub user_id: String,
pub display_name: String,
pub effective_wip_limit: i64,
pub current_wip: i64,
pub in_flight_points: i64,
pub capacity_points: Option<i64>,
pub recent_done_count: i64,
pub long_stale_count: i64,
pub estimation_skew_days_per_point: Option<f64>,
}
pub fn classify_wip(m: &PersonalMetrics) -> HealthIndicator {
if m.current_wip == 0 {
return HealthIndicator::Good;
}
if m.current_wip > m.effective_wip_limit {
HealthIndicator::Concern
} else if m.current_wip == m.effective_wip_limit {
HealthIndicator::Watch
} else {
HealthIndicator::Good
}
}
pub fn classify_long_stale(m: &PersonalMetrics) -> HealthIndicator {
match m.long_stale_count {
0 => HealthIndicator::Good,
1 => HealthIndicator::Watch,
_ => HealthIndicator::Concern,
}
}
}