1use chrono::NaiveDate;
9use serde::Serialize;
10use shift_algorithm::{get_shift_type_for_date, ShiftCycleConfig};
11
12#[derive(Debug, Clone, Serialize)]
29pub struct CommonRestResult {
30 pub team_a_id: u32,
32 pub team_b_id: u32,
34 pub next_common_rest_date: Option<NaiveDate>,
36 pub days_until_next: Option<u32>,
38 pub common_rest_dates: Vec<NaiveDate>,
40 pub total_count: u32,
42 pub count_in_30_days: u32,
44 pub count_in_60_days: u32,
46}
47
48pub 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); let today_plus_60 = today + chrono::Duration::days(59); 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}