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 team_id: Option<String>,
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 parent_issue_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Issue {
pub fn is_sub_issue(&self) -> bool {
self.parent_issue_id.is_some()
}
pub fn is_top_level(&self) -> bool {
self.parent_issue_id.is_none()
}
}
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,
}
impl Indicator {
pub fn human_explanation(&self) -> Option<String> {
match self.state {
HealthIndicator::Good | HealthIndicator::Insufficient => return None,
HealthIndicator::Watch | HealthIndicator::Concern => {}
}
let value = &self.value_display;
Some(match self.kind {
IndicatorKind::Throughput => format!(
"Throughput is {value} — fewer issues are reaching Done than the rest of the project's history."
),
IndicatorKind::Staleness => format!(
"The oldest in-flight issue has been open for {value}."
),
IndicatorKind::Activity => format!(
"Issue activity in the last two weeks is {value}."
),
IndicatorKind::BusFactor => format!(
"{value} of in-flight work is concentrated on one person."
),
IndicatorKind::LongStale => format!(
"{value} of in-flight issues haven't been touched in over two weeks."
),
IndicatorKind::WipCompliance => format!(
"{value} of active assignees are over their WIP limit."
),
})
}
}
#[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,
}
}
}
pub mod user_burnout {
use super::HealthIndicator;
pub const DRIFT_WINDOW_DAYS: i64 = 28;
pub const DRIFT_STEADY_THRESHOLD_RATIO: f64 = 0.25;
pub const SWITCHING_WINDOW_DAYS: i64 = 14;
pub const SWITCHING_MIN_EVENTS: i64 = 5;
pub const OVERLOAD_STREAK_WATCH: i64 = 8;
pub const STALLED_WATCH_DAYS: i64 = 14;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DriftDirection {
Up,
Down,
Steady,
}
#[derive(Debug, Clone)]
pub struct EstimationDriftTrend {
pub recent_median_days_per_point: f64,
pub older_median_days_per_point: f64,
pub direction: DriftDirection,
pub window_days: i64,
}
#[derive(Debug, Clone)]
pub struct CognitiveSwitchingPattern {
pub switches_per_day_median: f64,
pub total_events_observed: i64,
pub window_days: i64,
}
#[derive(Debug, Clone)]
pub struct UserBurnoutSignals {
pub overload_streak_days: i64,
pub stalled_assigned_max_days: i64,
pub window_days: i64,
pub estimation_drift: Option<EstimationDriftTrend>,
pub cognitive_switching: Option<CognitiveSwitchingPattern>,
}
pub fn classify_overload_streak(s: &UserBurnoutSignals) -> HealthIndicator {
match s.overload_streak_days {
n if n >= OVERLOAD_STREAK_WATCH => HealthIndicator::Watch,
_ => HealthIndicator::Good,
}
}
pub fn classify_stalled(s: &UserBurnoutSignals) -> HealthIndicator {
match s.stalled_assigned_max_days {
d if d >= STALLED_WATCH_DAYS => HealthIndicator::Watch,
_ => HealthIndicator::Good,
}
}
pub fn classify_drift(drift: &EstimationDriftTrend) -> DriftDirection {
drift.direction
}
pub fn summarize(signals: &UserBurnoutSignals) -> String {
let overload = classify_overload_streak(signals);
let stalled = classify_stalled(signals);
let any_watch = matches!(overload, HealthIndicator::Watch)
|| matches!(stalled, HealthIndicator::Watch);
if !any_watch {
return "Steady so far.".to_string();
}
let mut parts: Vec<String> = Vec::new();
if matches!(overload, HealthIndicator::Watch) {
parts.push(format!(
"you've been over capacity for {} recent snapshots — \
consider whether some work can wait or move",
signals.overload_streak_days
));
}
if matches!(stalled, HealthIndicator::Watch) {
parts.push(format!(
"an assigned issue has been stuck for {} days — \
worth a quick check whether it's blocked",
signals.stalled_assigned_max_days
));
}
parts.join("; ")
}
}
pub mod notifications {
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Watch,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Self::Info => "info",
Self::Watch => "watch",
}
}
pub fn from_storage_str(s: &str) -> Self {
match s {
"watch" => Self::Watch,
_ => Self::Info,
}
}
pub fn meets_minimum(self, minimum: Severity) -> bool {
match (self, minimum) {
(Self::Watch, _) => true,
(Self::Info, Self::Info) => true,
(Self::Info, Self::Watch) => false,
}
}
}
pub mod channel {
pub const IN_APP: &str = "in_app";
pub const EMAIL: &str = "email";
pub const WEBHOOK: &str = "webhook";
pub const ALL_CHANNELS: &[&str] = &[IN_APP, EMAIL, WEBHOOK];
pub fn human_name(id: &str) -> &str {
match id {
IN_APP => "In-app",
EMAIL => "Email",
WEBHOOK => "Webhook",
_ => id,
}
}
pub fn all() -> &'static [&'static str] {
ALL_CHANNELS
}
}
pub mod kind {
pub const GLOBAL: &str = "_global";
pub const BURNOUT_OVERLOAD: &str = "burnout_overload";
pub const BURNOUT_STALLED: &str = "burnout_stalled";
pub const PROJECT_TREND_DECLINE: &str = "project_trend_decline";
pub fn human_name(k: &str) -> &str {
match k {
BURNOUT_OVERLOAD => "Sustained over-capacity streak",
BURNOUT_STALLED => "Long-stalled assigned work",
PROJECT_TREND_DECLINE => "Project health decline",
_ => k,
}
}
pub fn all_user_facing() -> &'static [&'static str] {
&[BURNOUT_OVERLOAD, BURNOUT_STALLED, PROJECT_TREND_DECLINE]
}
}
#[derive(Debug, Clone)]
pub struct Notification {
pub id: String,
pub user_id: String,
pub kind: String,
pub severity: Severity,
pub title: String,
pub body: String,
pub payload_json: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub read_at: Option<chrono::DateTime<chrono::Utc>>,
pub dispatched_via: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Preference {
pub user_id: String,
pub kind: String,
pub channels: Vec<String>,
pub min_severity: Severity,
}
pub struct EffectivePreference<'a> {
pub channels: &'a [&'a str],
pub min_severity: Severity,
}
pub const DEFAULT_CHANNELS: &[&str] = &[channel::IN_APP];
pub const DEFAULT_MIN_SEVERITY: Severity = Severity::Info;
pub fn is_edge_into_watch_burnout_overload(
prior_streak_days: i64,
current_streak_days: i64,
) -> bool {
let threshold = crate::user_burnout::OVERLOAD_STREAK_WATCH;
prior_streak_days < threshold && current_streak_days >= threshold
}
pub fn is_edge_into_watch_burnout_stalled(
prior_max_days: i64,
current_max_days: i64,
) -> bool {
let threshold = crate::user_burnout::STALLED_WATCH_DAYS;
prior_max_days < threshold && current_max_days >= threshold
}
pub const COOLDOWN_HOURS: i64 = 24;
}
pub mod teams {
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TeamRole {
Admin,
Member,
Viewer,
}
impl TeamRole {
pub fn as_str(self) -> &'static str {
match self {
Self::Admin => "admin",
Self::Member => "member",
Self::Viewer => "viewer",
}
}
pub fn human_name(self) -> &'static str {
match self {
Self::Admin => "Admin",
Self::Member => "Member",
Self::Viewer => "Viewer",
}
}
pub fn from_storage_str(s: &str) -> Option<Self> {
match s {
"admin" => Some(Self::Admin),
"member" => Some(Self::Member),
"viewer" => Some(Self::Viewer),
_ => None,
}
}
pub fn can_write(self) -> bool {
matches!(self, Self::Admin | Self::Member)
}
pub fn can_manage_team(self) -> bool {
matches!(self, Self::Admin)
}
}
#[derive(Debug, Clone)]
pub struct Team {
pub id: String,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub struct TeamMembership {
pub team_id: String,
pub user_id: String,
pub role: TeamRole,
pub joined_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
pub const SLUG_MAX_LEN: usize = 64;
pub fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut last_was_hyphen = true; for ch in name.chars().flat_map(|c| c.to_lowercase()) {
if ch.is_ascii_alphanumeric() {
out.push(ch);
last_was_hyphen = false;
} else if !last_was_hyphen {
out.push('-');
last_was_hyphen = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.len() > SLUG_MAX_LEN {
out.truncate(SLUG_MAX_LEN);
while out.ends_with('-') {
out.pop();
}
}
out
}
}
pub mod sprints {
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SprintStatus {
Planned,
Active,
Completed,
}
impl SprintStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Planned => "planned",
Self::Active => "active",
Self::Completed => "completed",
}
}
pub fn human_name(self) -> &'static str {
match self {
Self::Planned => "Planned",
Self::Active => "Active",
Self::Completed => "Completed",
}
}
pub fn from_storage_str(s: &str) -> Self {
match s {
"active" => Self::Active,
"completed" => Self::Completed,
_ => Self::Planned,
}
}
}
#[derive(Debug, Clone)]
pub struct Sprint {
pub id: String,
pub team_id: String,
pub name: String,
pub goal: Option<String>,
pub starts_on: chrono::NaiveDate,
pub ends_on: chrono::NaiveDate,
pub status: SprintStatus,
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub struct SprintSummary {
pub sprint_id: String,
pub committed_points: i64,
pub completed_points: i64,
pub committed_count: i64,
pub completed_count: i64,
pub carried_over_points: i64,
pub carried_over_count: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct BurndownPoint {
pub day: chrono::NaiveDate,
pub cumulative_committed: i64,
pub cumulative_completed: i64,
}
pub const VELOCITY_MEDIAN_WINDOW: usize = 5;
}