//! Milestone domain types and DTOs. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use strum_macros::EnumString; use crate::id_types::{MilestoneId, UserId, ProjectId}; use super::shared::{CssClass, DbValue, ParseableEnum}; // ============ Milestones ============ /// Lifecycle status of a milestone. // `ascii_case_insensitive` so the lowercase `db_value()` form ("open"/"completed") // round-trips back through `from_str_or_default`; without it a stored "completed" // failed to parse and silently reverted to `Open` on every read. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)] #[strum(ascii_case_insensitive)] pub enum MilestoneStatus { /// Milestone is still being worked toward. #[strum(serialize = "Open")] #[default] Open, /// All milestone tasks are done. #[strum(serialize = "Completed")] Completed, } impl MilestoneStatus { /// Returns a human-readable display string. pub fn as_str(&self) -> &'static str { match self { MilestoneStatus::Open => "Open", MilestoneStatus::Completed => "Completed", } } } impl ParseableEnum for MilestoneStatus {} impl DbValue for MilestoneStatus { fn db_value(&self) -> &'static str { match self { MilestoneStatus::Open => "open", MilestoneStatus::Completed => "completed", } } } impl CssClass for MilestoneStatus { fn css_class(&self) -> &'static str { match self { MilestoneStatus::Open => "milestone-open", MilestoneStatus::Completed => "milestone-completed", } } } /// A project milestone representing a scope boundary. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Milestone { /// Unique identifier. pub id: MilestoneId, /// Owner user ID. pub user_id: UserId, /// Parent project ID. pub project_id: ProjectId, /// Milestone name. pub name: String, /// Optional description. pub description: String, /// Display order (lower = first). pub position: i32, /// Target completion date. pub target_date: Option, /// Current status. pub status: MilestoneStatus, /// When the milestone was created. pub created_at: DateTime, } /// Data for creating a new milestone. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewMilestone { pub project_id: ProjectId, pub name: String, pub description: String, pub position: i32, pub target_date: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn milestone_status_db_value_round_trips() { // Regression: the lowercase db_value form must parse back to the same // variant (was reverting Completed -> Open on read). for status in [MilestoneStatus::Open, MilestoneStatus::Completed] { let stored = status.db_value(); assert_eq!(MilestoneStatus::from_str_or_default(stored), status); } // The capitalized display form still parses too. assert_eq!( MilestoneStatus::from_str_or_default("Completed"), MilestoneStatus::Completed ); } }