Skip to main content

shift_statistics/
metrics.rs

1//! Monthly shift counts and work/rest tracking.
2//!
3//! All functions iterate day-by-day through the target range and call
4//! [`get_shift_type_for_date`] for each day.
5//!
6//! Ported from Flutter `shift_metrics.dart` and Android `shift_metrics.kt`.
7
8use chrono::NaiveDate;
9use shift_algorithm::{get_shift_type_for_date, ShiftCycleConfig, ShiftType};
10
11/// Count how many days of a given shift type occur in a month.
12///
13/// Works correctly for all month lengths (28, 29, 30, 31 days) and
14/// handles December→January transition.
15///
16/// ```rust
17/// use shift_algorithm::cycle::default_config;
18/// use shift_statistics::metrics::count_shift_type_in_month;
19/// use shift_algorithm::ShiftType;
20///
21/// let config = default_config();
22/// let morning_count = count_shift_type_in_month(2026, 5, ShiftType::Morning, &config, 0);
23/// // May 2026 has several morning shifts for team 1
24/// assert!(morning_count > 0);
25/// assert!(morning_count <= 31);
26/// ```
27pub fn count_shift_type_in_month(
28    year: i32,
29    month: u32,
30    shift_type: ShiftType,
31    config: &ShiftCycleConfig,
32    team_phase_offset: u32,
33) -> u32 {
34    let start = NaiveDate::from_ymd_opt(year, month, 1).unwrap();
35    let end = if month == 12 {
36        NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
37    } else {
38        NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
39    };
40
41    let mut count = 0u32;
42    let mut current = start;
43    while current < end {
44        if get_shift_type_for_date(current, config, team_phase_offset) == shift_type {
45            count += 1;
46        }
47        current += chrono::Duration::days(1);
48    }
49    count
50}
51
52/// Count total working days (Morning + Afternoon + Night) in a month.
53///
54/// ```rust
55/// use shift_algorithm::cycle::default_config;
56/// use shift_statistics::metrics::count_work_days_in_month;
57///
58/// let config = default_config();
59/// let work = count_work_days_in_month(2026, 5, &config, 0);
60/// // Sum of Morning, Afternoon, Night counts for the month
61/// assert!(work <= 31);
62/// ```
63pub fn count_work_days_in_month(
64    year: i32,
65    month: u32,
66    config: &ShiftCycleConfig,
67    team_phase_offset: u32,
68) -> u32 {
69    let start = NaiveDate::from_ymd_opt(year, month, 1).unwrap();
70    let end = if month == 12 {
71        NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
72    } else {
73        NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
74    };
75
76    let mut count = 0u32;
77    let mut current = start;
78    while current < end {
79        let st = get_shift_type_for_date(current, config, team_phase_offset);
80        if st.is_work() {
81            count += 1;
82        }
83        current += chrono::Duration::days(1);
84    }
85    count
86}
87
88/// Count consecutive work days looking backward from `today` (inclusive).
89///
90/// Stops at the first non-work day. Returns 0 if today is a rest day.
91///
92/// Bounded to 10,000 iterations to prevent infinite loops on all-work cycles.
93///
94/// ```rust
95/// use shift_algorithm::cycle::default_config;
96/// use shift_statistics::metrics::consecutive_work_days;
97/// use chrono::NaiveDate;
98///
99/// let config = default_config();
100/// let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
101/// let consec = consecutive_work_days(today, &config, 0);
102/// println!("连续上班 {} 天", consec);
103/// ```
104pub fn consecutive_work_days(
105    today: NaiveDate,
106    config: &ShiftCycleConfig,
107    team_phase_offset: u32,
108) -> u32 {
109    const MAX_ITERATIONS: u32 = 10000;
110    let mut count = 0u32;
111    let mut current = today;
112    for _ in 0..MAX_ITERATIONS {
113        let st = get_shift_type_for_date(current, config, team_phase_offset);
114        if st.is_work() {
115            count += 1;
116        } else {
117            break;
118        }
119        current -= chrono::Duration::days(1);
120    }
121    count
122}
123
124/// Days until the next rest day, starting from tomorrow (excludes today).
125///
126/// Returns 0 if tomorrow is already a rest day.
127/// Does not consider whether today itself is rest — use
128/// [`get_shift_info`](shift_algorithm::get_shift_info) to check that.
129///
130/// Bounded to 10,000 iterations.
131///
132/// ```rust
133/// use shift_algorithm::cycle::default_config;
134/// use shift_statistics::metrics::days_until_next_rest;
135/// use chrono::NaiveDate;
136///
137/// let config = default_config();
138/// let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
139/// let days = days_until_next_rest(today, &config, 0);
140///
141/// if days == 0 {
142///     println!("明天休息!");
143/// } else {
144///     println!("距休 {} 天", days);
145/// }
146/// ```
147pub fn days_until_next_rest(
148    today: NaiveDate,
149    config: &ShiftCycleConfig,
150    team_phase_offset: u32,
151) -> u32 {
152    const MAX_ITERATIONS: u32 = 10000;
153    let mut count = 0u32;
154    let mut current = today + chrono::Duration::days(1);
155    for _ in 0..MAX_ITERATIONS {
156        let st = get_shift_type_for_date(current, config, team_phase_offset);
157        if st.is_rest() {
158            return count;
159        }
160        count += 1;
161        current += chrono::Duration::days(1);
162    }
163    count
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use shift_algorithm::cycle::default_config;
170
171    #[test]
172    fn count_morning_in_may_2026() {
173        let config = default_config();
174        let count = count_shift_type_in_month(2026, 5, ShiftType::Morning, &config, 0);
175        assert!(count > 0);
176        assert!(count <= 31);
177    }
178
179    #[test]
180    fn count_work_days_in_31_day_month() {
181        let config = default_config();
182        let work = count_work_days_in_month(2026, 5, &config, 0);
183        let total = count_shift_type_in_month(2026, 5, ShiftType::Morning, &config, 0)
184            + count_shift_type_in_month(2026, 5, ShiftType::Afternoon, &config, 0)
185            + count_shift_type_in_month(2026, 5, ShiftType::Night, &config, 0);
186        assert_eq!(work, total);
187    }
188
189    #[test]
190    fn consecutive_work_days_non_negative() {
191        let config = default_config();
192        let date = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
193        let c = consecutive_work_days(date, &config, 0);
194        assert!(c < 365);
195    }
196
197    #[test]
198    fn days_until_rest_positive() {
199        let config = default_config();
200        let date = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
201        let d = days_until_next_rest(date, &config, 0);
202        assert!(d < 42);
203    }
204
205    // ── Edge cases ──
206
207    #[test]
208    fn all_work_cycle_consecutive_is_bounded() {
209        use ShiftType::*;
210        let config = ShiftCycleConfig {
211            cycle: vec![Morning; 3],
212            cycle_length: 3,
213            reference_date: shift_algorithm::cycle::default_reference_date(),
214            total_teams: 1,
215        };
216        let date = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
217        let c = consecutive_work_days(date, &config, 0);
218        assert_eq!(c, 10000);
219    }
220
221    #[test]
222    fn all_work_cycle_days_until_rest_bounded() {
223        use ShiftType::*;
224        let config = ShiftCycleConfig {
225            cycle: vec![Morning; 3],
226            cycle_length: 3,
227            reference_date: shift_algorithm::cycle::default_reference_date(),
228            total_teams: 1,
229        };
230        let date = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
231        let d = days_until_next_rest(date, &config, 0);
232        assert_eq!(d, 10000);
233    }
234
235    #[test]
236    fn all_rest_cycle_consecutive_is_zero() {
237        use ShiftType::*;
238        let config = ShiftCycleConfig {
239            cycle: vec![Rest; 5],
240            cycle_length: 5,
241            reference_date: shift_algorithm::cycle::default_reference_date(),
242            total_teams: 1,
243        };
244        let date = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
245        assert_eq!(consecutive_work_days(date, &config, 0), 0);
246    }
247
248    #[test]
249    fn all_rest_cycle_count_work_is_zero() {
250        use ShiftType::*;
251        let config = ShiftCycleConfig {
252            cycle: vec![Rest; 5],
253            cycle_length: 5,
254            reference_date: shift_algorithm::cycle::default_reference_date(),
255            total_teams: 1,
256        };
257        let work = count_work_days_in_month(2026, 5, &config, 0);
258        assert_eq!(work, 0);
259    }
260
261    #[test]
262    fn february_2026_has_28_days() {
263        let config = default_config();
264        let morning = count_shift_type_in_month(2026, 2, ShiftType::Morning, &config, 0);
265        let afternoon = count_shift_type_in_month(2026, 2, ShiftType::Afternoon, &config, 0);
266        let rest = count_shift_type_in_month(2026, 2, ShiftType::Rest, &config, 0);
267        let night = count_shift_type_in_month(2026, 2, ShiftType::Night, &config, 0);
268        let study = count_shift_type_in_month(2026, 2, ShiftType::Study, &config, 0);
269        assert_eq!(morning + afternoon + rest + night + study, 28);
270    }
271
272    #[test]
273    fn december_2026_transition() {
274        let config = default_config();
275        let total = count_shift_type_in_month(2026, 12, ShiftType::Morning, &config, 0)
276            + count_shift_type_in_month(2026, 12, ShiftType::Afternoon, &config, 0)
277            + count_shift_type_in_month(2026, 12, ShiftType::Rest, &config, 0)
278            + count_shift_type_in_month(2026, 12, ShiftType::Night, &config, 0)
279            + count_shift_type_in_month(2026, 12, ShiftType::Study, &config, 0);
280        assert_eq!(total, 31);
281    }
282}