1use crate::types::{ShiftCycleConfig, ShiftInfo, ShiftType};
21use chrono::NaiveDate;
22
23pub fn calculate_day_offset(date: NaiveDate, reference_date: NaiveDate) -> i64 {
37 (date - reference_date).num_days()
38}
39
40pub fn normalize_cycle_index(offset_days: i64, cycle_length: u32) -> u32 {
57 assert!(cycle_length >= 1, "cycle_length must be >= 1, got 0");
58 let len = cycle_length as i64;
59 let normalized = offset_days % len;
60 if normalized < 0 {
61 (normalized + len) as u32
62 } else {
63 normalized as u32
64 }
65}
66
67pub fn team_phase_offset_for(team_id: u32, cycle_length: u32, total_teams: u32) -> u32 {
83 assert!(total_teams >= 1, "total_teams must be >= 1, got 0");
84 assert!(team_id >= 1, "team_id must be >= 1, got 0");
85 (team_id - 1) * (cycle_length / total_teams)
86}
87
88pub fn get_shift_type_for_date(
105 date: NaiveDate,
106 config: &ShiftCycleConfig,
107 team_phase_offset: u32,
108) -> ShiftType {
109 let offset = calculate_day_offset(date, config.reference_date) + team_phase_offset as i64;
110 let index = normalize_cycle_index(offset, config.cycle_length);
111 config.cycle[index as usize]
112}
113
114pub fn get_shift_info(
139 date: NaiveDate,
140 config: &ShiftCycleConfig,
141 team_phase_offset: u32,
142) -> ShiftInfo {
143 let offset = calculate_day_offset(date, config.reference_date) + team_phase_offset as i64;
144 let index = normalize_cycle_index(offset, config.cycle_length);
145 ShiftInfo {
146 date,
147 day_of_cycle: index + 1,
148 shift_type: config.cycle[index as usize],
149 cycle_index: index,
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::cycle::default_config;
157
158 #[test]
161 fn offset_zero_for_reference_date() {
162 let ref_date = crate::cycle::default_reference_date();
163 assert_eq!(calculate_day_offset(ref_date, ref_date), 0);
164 }
165
166 #[test]
167 fn offset_positive_one_day_after() {
168 let ref_date = crate::cycle::default_reference_date();
169 let target = ref_date + chrono::Duration::days(5);
170 assert_eq!(calculate_day_offset(target, ref_date), 5);
171 }
172
173 #[test]
174 fn offset_negative_one_day_before() {
175 let ref_date = crate::cycle::default_reference_date();
176 let target = ref_date - chrono::Duration::days(1);
177 assert_eq!(calculate_day_offset(target, ref_date), -1);
178 }
179
180 #[test]
183 fn normalize_zero_is_zero() {
184 assert_eq!(normalize_cycle_index(0, 42), 0);
185 }
186
187 #[test]
188 fn normalize_one_is_one() {
189 assert_eq!(normalize_cycle_index(1, 42), 1);
190 }
191
192 #[test]
193 fn normalize_42_wraps_to_0() {
194 assert_eq!(normalize_cycle_index(42, 42), 0);
195 }
196
197 #[test]
198 fn normalize_43_wraps_to_1() {
199 assert_eq!(normalize_cycle_index(43, 42), 1);
200 }
201
202 #[test]
203 fn normalize_negative_one_is_41() {
204 assert_eq!(normalize_cycle_index(-1, 42), 41);
205 }
206
207 #[test]
208 fn normalize_negative_42_is_0() {
209 assert_eq!(normalize_cycle_index(-42, 42), 0);
210 }
211
212 #[test]
213 fn normalize_custom_cycle_length_7() {
214 assert_eq!(normalize_cycle_index(7, 7), 0);
215 assert_eq!(normalize_cycle_index(8, 7), 1);
216 assert_eq!(normalize_cycle_index(-1, 7), 6);
217 }
218
219 #[test]
222 fn reference_date_is_day_1_morning() {
223 let config = default_config();
224 let info = get_shift_info(config.reference_date, &config, 0);
225 assert_eq!(info.day_of_cycle, 1);
226 assert_eq!(info.shift_type, ShiftType::Morning);
227 }
228
229 #[test]
230 fn reference_date_plus_41_is_day_42_rest() {
231 let config = default_config();
232 let date = config.reference_date + chrono::Duration::days(41);
233 let info = get_shift_info(date, &config, 0);
234 assert_eq!(info.day_of_cycle, 42);
235 assert_eq!(info.shift_type, ShiftType::Rest);
236 }
237
238 #[test]
239 fn reference_date_plus_4_is_day_5_rest() {
240 let config = default_config();
241 let date = config.reference_date + chrono::Duration::days(4);
242 let info = get_shift_info(date, &config, 0);
243 assert_eq!(info.day_of_cycle, 5);
244 assert_eq!(info.shift_type, ShiftType::Rest);
245 }
246
247 #[test]
248 fn team_phase_offset_changes_shift() {
249 let config = default_config();
250 assert_eq!(
251 get_shift_type_for_date(config.reference_date, &config, 0),
252 ShiftType::Morning
253 );
254 assert_eq!(
255 get_shift_type_for_date(config.reference_date, &config, 7),
256 ShiftType::Rest
257 );
258 }
259
260 #[test]
261 fn custom_cycle_7_days() {
262 use ShiftType::*;
263 let config = ShiftCycleConfig {
264 cycle: vec![Morning, Afternoon, Rest, Night, Rest, Morning, Afternoon],
265 cycle_length: 7,
266 reference_date: crate::cycle::default_reference_date(),
267 total_teams: 2,
268 };
269 let info = get_shift_info(config.reference_date, &config, 0);
270 assert_eq!(info.day_of_cycle, 1);
271 assert_eq!(info.shift_type, Morning);
272
273 let d7 = config.reference_date + chrono::Duration::days(6);
274 let info7 = get_shift_info(d7, &config, 0);
275 assert_eq!(info7.day_of_cycle, 7);
276 assert_eq!(info7.shift_type, Afternoon);
277
278 let d8 = config.reference_date + chrono::Duration::days(7);
279 let info8 = get_shift_info(d8, &config, 0);
280 assert_eq!(info8.day_of_cycle, 1);
281 assert_eq!(info8.shift_type, Morning);
282 }
283
284 #[test]
285 fn known_date_cross_check_with_android() {
286 let config = default_config();
287 let date = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
288 let info = get_shift_info(date, &config, 0);
289 assert_eq!(info.day_of_cycle, 33);
290 assert_eq!(info.shift_type, config.cycle[32]);
291 }
292
293 #[test]
296 fn normalize_cycle_length_1() {
297 assert_eq!(normalize_cycle_index(0, 1), 0);
298 assert_eq!(normalize_cycle_index(1, 1), 0);
299 assert_eq!(normalize_cycle_index(100, 1), 0);
300 assert_eq!(normalize_cycle_index(-1, 1), 0);
301 }
302
303 #[test]
304 #[should_panic(expected = "cycle_length must be >= 1")]
305 fn normalize_cycle_length_zero_panics() {
306 normalize_cycle_index(0, 0);
307 }
308
309 #[test]
310 fn team_phase_offset_large_values() {
311 let config = default_config();
312 let idx = normalize_cycle_index(i64::MAX, config.cycle_length);
313 assert!(idx < 42);
314 }
315
316 #[test]
317 #[should_panic(expected = "total_teams must be >= 1")]
318 fn team_phase_offset_zero_teams_panics() {
319 team_phase_offset_for(1, 42, 0);
320 }
321
322 #[test]
323 fn shift_info_day_of_cycle_range() {
324 let config = default_config();
325 let mut date = config.reference_date;
326 for _ in 0..1000 {
327 let info = get_shift_info(date, &config, 0);
328 assert!(info.day_of_cycle >= 1);
329 assert!(info.day_of_cycle <= 42);
330 assert_eq!(info.cycle_index + 1, info.day_of_cycle);
331 date += chrono::Duration::days(1);
332 }
333 }
334
335 #[test]
336 fn all_same_shift_type_cycle() {
337 use ShiftType::*;
338 let config = ShiftCycleConfig {
339 cycle: vec![Night; 5],
340 cycle_length: 5,
341 reference_date: crate::cycle::default_reference_date(),
342 total_teams: 1,
343 };
344 let info = get_shift_info(config.reference_date, &config, 0);
345 assert_eq!(info.shift_type, Night);
346 assert_eq!(info.day_of_cycle, 1);
347
348 let d5 = config.reference_date + chrono::Duration::days(4);
349 let info5 = get_shift_info(d5, &config, 0);
350 assert_eq!(info5.day_of_cycle, 5);
351 assert_eq!(info5.shift_type, Night);
352 }
353}