Skip to main content

shift_statistics/
colleague.rs

1//! **Colleague mode**: find common rest days between two teams.
2//!
3//! The scenario: "I'm on team A, my friend/partner is on team B.
4//! When can we both rest on the same day?"
5//!
6//! Ported from Flutter `colleague_mode.dart` and Android `colleague_mode.kt`.
7
8use chrono::NaiveDate;
9use serde::Serialize;
10use shift_algorithm::{get_shift_type_for_date, ShiftCycleConfig};
11
12/// Result of a colleague-mode query.
13///
14/// ```rust
15/// use shift_algorithm::cycle::default_config;
16/// use shift_statistics::colleague::find_common_rest_days;
17/// use chrono::NaiveDate;
18///
19/// let config = default_config();
20/// let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
21/// let result = find_common_rest_days(1, 3, today, 90, &config);
22///
23/// if let Some(date) = result.next_common_rest_date {
24///     println!("下次共同休息: {}", date);
25/// }
26/// println!("未来30天: {} 次", result.count_in_30_days);
27/// ```
28#[derive(Debug, Clone, Serialize)]
29pub struct CommonRestResult {
30    /// First team ID (1-6).
31    pub team_a_id: u32,
32    /// Second team ID (1-6).
33    pub team_b_id: u32,
34    /// The next date when both teams rest.
35    pub next_common_rest_date: Option<NaiveDate>,
36    /// Days until the next common rest.
37    pub days_until_next: Option<u32>,
38    /// All common rest dates within the analysis window.
39    pub common_rest_dates: Vec<NaiveDate>,
40    /// Total common rest days found.
41    pub total_count: u32,
42    /// Common rests within the first 30 days.
43    pub count_in_30_days: u32,
44    /// Common rests within the first 60 days.
45    pub count_in_60_days: u32,
46}
47
48/// Find common rest days between two teams.
49///
50/// For each day from `today` to `today + days_to_analyze`, computes the shift
51/// for both teams. A day counts as "common rest" if both teams are on
52/// [`Rest`](shift_algorithm::ShiftType::Rest) or [`Study`](shift_algorithm::ShiftType::Study).
53///
54/// ```rust
55/// use shift_algorithm::cycle::default_config;
56/// use shift_statistics::colleague::find_common_rest_days;
57/// use chrono::NaiveDate;
58///
59/// let config = default_config();
60/// let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
61/// let result = find_common_rest_days(1, 3, today, 60, &config);
62///
63/// assert_eq!(result.team_a_id, 1);
64/// assert_eq!(result.team_b_id, 3);
65/// assert!(result.count_in_30_days <= result.count_in_60_days);
66/// ```
67pub fn find_common_rest_days(
68    team_a_id: u32,
69    team_b_id: u32,
70    today: NaiveDate,
71    days_to_analyze: u32,
72    config: &ShiftCycleConfig,
73) -> CommonRestResult {
74    let offset_a = config.team_phase_offset(team_a_id);
75    let offset_b = config.team_phase_offset(team_b_id);
76
77    let mut common_dates: Vec<NaiveDate> = Vec::new();
78    let today_plus_30 = today + chrono::Duration::days(29); // match Dart: diff < 30
79    let today_plus_60 = today + chrono::Duration::days(59); // match Dart: diff < 60
80
81    for d in 0..days_to_analyze {
82        let date = today + chrono::Duration::days(d as i64);
83        let shift_a = get_shift_type_for_date(date, config, offset_a);
84        let shift_b = get_shift_type_for_date(date, config, offset_b);
85
86        if shift_a.is_rest() && shift_b.is_rest() {
87            common_dates.push(date);
88        }
89    }
90
91    let next = common_dates.first().copied();
92    let days_until = next.map(|d| {
93        let diff = (d - today).num_days();
94        diff.max(0) as u32
95    });
96
97    let count_30 = common_dates
98        .iter()
99        .filter(|d| **d <= today_plus_30)
100        .count() as u32;
101    let count_60 = common_dates
102        .iter()
103        .filter(|d| **d <= today_plus_60)
104        .count() as u32;
105
106    CommonRestResult {
107        team_a_id,
108        team_b_id,
109        next_common_rest_date: next,
110        days_until_next: days_until,
111        common_rest_dates: common_dates,
112        total_count: count_60,
113        count_in_30_days: count_30,
114        count_in_60_days: count_60,
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use shift_algorithm::cycle::default_config;
122
123    #[test]
124    fn same_team_finds_all_rest_days() {
125        let config = default_config();
126        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
127        let result = find_common_rest_days(1, 1, today, 42, &config);
128        assert!(!result.common_rest_dates.is_empty());
129        assert_eq!(result.team_a_id, result.team_b_id);
130    }
131
132    #[test]
133    fn different_teams_produces_result() {
134        let config = default_config();
135        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
136        let result = find_common_rest_days(1, 3, today, 90, &config);
137        assert!(result.total_count <= 90);
138        assert!(result.count_in_30_days <= result.count_in_60_days);
139    }
140
141    #[test]
142    fn next_common_rest_is_in_future_or_none() {
143        let config = default_config();
144        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
145        let result = find_common_rest_days(1, 4, today, 180, &config);
146        if let Some(days) = result.days_until_next {
147            assert!(days < 180);
148        }
149    }
150
151    #[test]
152    fn all_common_dates_are_within_window() {
153        let config = default_config();
154        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
155        let window = 60u32;
156        let result = find_common_rest_days(2, 5, today, window, &config);
157        for date in &result.common_rest_dates {
158            let diff = (*date - today).num_days();
159            assert!(diff >= 0);
160            assert!(diff < window as i64);
161        }
162        assert_eq!(result.total_count, result.common_rest_dates.len() as u32);
163    }
164
165    #[test]
166    fn zero_day_window_returns_empty() {
167        let config = default_config();
168        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
169        let result = find_common_rest_days(1, 2, today, 0, &config);
170        assert!(result.common_rest_dates.is_empty());
171        assert_eq!(result.total_count, 0);
172        assert!(result.next_common_rest_date.is_none());
173    }
174
175    #[test]
176    fn both_teams_all_rest_cycle_finds_every_day() {
177        use shift_algorithm::ShiftType::*;
178        let config = ShiftCycleConfig {
179            cycle: vec![Rest; 7],
180            cycle_length: 7,
181            reference_date: shift_algorithm::cycle::default_reference_date(),
182            total_teams: 2,
183        };
184        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
185        let result = find_common_rest_days(1, 2, today, 10, &config);
186        assert_eq!(result.total_count, 10);
187        assert_eq!(result.common_rest_dates.len(), 10);
188    }
189
190    #[test]
191    fn both_teams_all_work_cycle_finds_nothing() {
192        use shift_algorithm::ShiftType::*;
193        let config = ShiftCycleConfig {
194            cycle: vec![Morning; 7],
195            cycle_length: 7,
196            reference_date: shift_algorithm::cycle::default_reference_date(),
197            total_teams: 2,
198        };
199        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
200        let result = find_common_rest_days(1, 2, today, 30, &config);
201        assert_eq!(result.total_count, 0);
202        assert!(result.common_rest_dates.is_empty());
203    }
204
205    #[test]
206    fn counts_do_not_exceed_window() {
207        let config = default_config();
208        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
209        let result = find_common_rest_days(1, 2, today, 20, &config);
210        assert!(result.count_in_30_days <= result.common_rest_dates.len() as u32);
211        assert!(result.count_in_60_days <= result.common_rest_dates.len() as u32);
212    }
213}