Skip to main content

max / goingson

3.2 KB · 111 lines History Blame Raw
1 //! Milestone domain types and DTOs.
2
3 use chrono::{DateTime, Utc};
4 use serde::{Deserialize, Serialize};
5 use strum_macros::EnumString;
6 use crate::id_types::{MilestoneId, UserId, ProjectId};
7 use super::shared::{CssClass, DbValue, ParseableEnum};
8
9 // ============ Milestones ============
10
11 /// Lifecycle status of a milestone.
12 // `ascii_case_insensitive` so the lowercase `db_value()` form ("open"/"completed")
13 // round-trips back through `from_str_or_default`; without it a stored "completed"
14 // failed to parse and silently reverted to `Open` on every read.
15 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)]
16 #[strum(ascii_case_insensitive)]
17 pub enum MilestoneStatus {
18 /// Milestone is still being worked toward.
19 #[strum(serialize = "Open")]
20 #[default]
21 Open,
22 /// All milestone tasks are done.
23 #[strum(serialize = "Completed")]
24 Completed,
25 }
26
27 impl MilestoneStatus {
28 /// Returns a human-readable display string.
29 pub fn as_str(&self) -> &'static str {
30 match self {
31 MilestoneStatus::Open => "Open",
32 MilestoneStatus::Completed => "Completed",
33 }
34 }
35
36 }
37
38 impl ParseableEnum for MilestoneStatus {}
39
40 impl DbValue for MilestoneStatus {
41 fn db_value(&self) -> &'static str {
42 match self {
43 MilestoneStatus::Open => "open",
44 MilestoneStatus::Completed => "completed",
45 }
46 }
47 }
48
49 impl CssClass for MilestoneStatus {
50 fn css_class(&self) -> &'static str {
51 match self {
52 MilestoneStatus::Open => "milestone-open",
53 MilestoneStatus::Completed => "milestone-completed",
54 }
55 }
56 }
57
58 /// A project milestone representing a scope boundary.
59 #[derive(Debug, Clone, Serialize, Deserialize)]
60 #[serde(rename_all = "camelCase")]
61 pub struct Milestone {
62 /// Unique identifier.
63 pub id: MilestoneId,
64 /// Owner user ID.
65 pub user_id: UserId,
66 /// Parent project ID.
67 pub project_id: ProjectId,
68 /// Milestone name.
69 pub name: String,
70 /// Optional description.
71 pub description: String,
72 /// Display order (lower = first).
73 pub position: i32,
74 /// Target completion date.
75 pub target_date: Option<chrono::NaiveDate>,
76 /// Current status.
77 pub status: MilestoneStatus,
78 /// When the milestone was created.
79 pub created_at: DateTime<Utc>,
80 }
81
82 /// Data for creating a new milestone.
83 #[derive(Debug, Clone, Serialize, Deserialize)]
84 pub struct NewMilestone {
85 pub project_id: ProjectId,
86 pub name: String,
87 pub description: String,
88 pub position: i32,
89 pub target_date: Option<chrono::NaiveDate>,
90 }
91
92 #[cfg(test)]
93 mod tests {
94 use super::*;
95
96 #[test]
97 fn milestone_status_db_value_round_trips() {
98 // Regression: the lowercase db_value form must parse back to the same
99 // variant (was reverting Completed -> Open on read).
100 for status in [MilestoneStatus::Open, MilestoneStatus::Completed] {
101 let stored = status.db_value();
102 assert_eq!(MilestoneStatus::from_str_or_default(stored), status);
103 }
104 // The capitalized display form still parses too.
105 assert_eq!(
106 MilestoneStatus::from_str_or_default("Completed"),
107 MilestoneStatus::Completed
108 );
109 }
110 }
111