Skip to main content

shift_algorithm/
calculator.rs

1//! Core shift calculation functions.
2//!
3//! All functions are **pure**: given the same inputs, always produce the same outputs.
4//! No platform dependencies, no I/O, no state.
5//!
6//! ## The algorithm
7//!
8//! ```text
9//! date → calculate_day_offset(date, reference_date)
10//!      → normalize_cycle_index(offset, cycle_length)
11//!      → cycle[index]  →  ShiftType
12//! ```
13//!
14//! Team phase offset is added to the day offset before normalization:
15//!
16//! ```text
17//! offset = (date - reference_date) + team_phase_offset
18//! ```
19
20use crate::types::{ShiftCycleConfig, ShiftInfo, ShiftType};
21use chrono::NaiveDate;
22
23/// Number of days between `date` and `reference_date`.
24///
25/// Positive means `date` is after the reference. Negative means before.
26///
27/// ```rust
28/// use shift_algorithm::calculate_day_offset;
29/// use chrono::NaiveDate;
30///
31/// let ref_date = NaiveDate::from_ymd_opt(2025, 12, 15).unwrap();
32/// let target = NaiveDate::from_ymd_opt(2025, 12, 20).unwrap();
33/// assert_eq!(calculate_day_offset(target, ref_date), 5);
34/// assert_eq!(calculate_day_offset(ref_date, target), -5);
35/// ```
36pub fn calculate_day_offset(date: NaiveDate, reference_date: NaiveDate) -> i64 {
37    (date - reference_date).num_days()
38}
39
40/// Normalize any offset into the range `0..cycle_length`.
41///
42/// Handles negative values correctly (wraps around).
43///
44/// ```rust
45/// use shift_algorithm::normalize_cycle_index;
46///
47/// assert_eq!(normalize_cycle_index(0, 42), 0);
48/// assert_eq!(normalize_cycle_index(42, 42), 0);
49/// assert_eq!(normalize_cycle_index(-1, 42), 41);
50/// assert_eq!(normalize_cycle_index(100, 42), 16);
51/// ```
52///
53/// # Panics
54///
55/// Panics if `cycle_length == 0`.
56pub 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
67/// Team phase offset in days.
68///
69/// For a 42-day, 6-team cycle, each team is offset by 7 days:
70///
71/// ```rust
72/// use shift_algorithm::team_phase_offset_for;
73///
74/// assert_eq!(team_phase_offset_for(1, 42, 6), 0);
75/// assert_eq!(team_phase_offset_for(2, 42, 6), 7);
76/// assert_eq!(team_phase_offset_for(6, 42, 6), 35);
77/// ```
78///
79/// # Panics
80///
81/// Panics if `total_teams == 0` or `team_id == 0`.
82pub 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
88/// Get the shift type for a given date.
89///
90/// This is the core function. Everything else — monthly stats, calendar generation,
91/// leave optimization, colleague mode — ultimately calls this.
92///
93/// ```rust
94/// use shift_algorithm::cycle::default_config;
95/// use shift_algorithm::get_shift_type_for_date;
96///
97/// let config = default_config();
98/// // 2025-12-15 is day 1 = Morning
99/// assert_eq!(
100///     get_shift_type_for_date(config.reference_date, &config, 0),
101///     shift_algorithm::ShiftType::Morning,
102/// );
103/// ```
104pub 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
114/// Get full shift info (date, day of cycle, cycle index, shift type) in one call.
115///
116/// Returns [`ShiftInfo`] which includes everything you typically need:
117///
118/// ```rust
119/// use shift_algorithm::cycle::default_config;
120/// use shift_algorithm::get_shift_info;
121/// use chrono::NaiveDate;
122///
123/// let config = default_config();
124/// let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
125/// let info = get_shift_info(today, &config, 0);
126///
127/// println!("{} · 第 {}/{} 天",
128///     info.shift_type.full_label(),
129///     info.day_of_cycle,
130///     config.cycle_length,
131/// );
132///
133/// assert_eq!(info.date, today);
134/// assert!(info.day_of_cycle >= 1);
135/// assert!(info.day_of_cycle <= 42);
136/// assert_eq!(info.cycle_index + 1, info.day_of_cycle);
137/// ```
138pub 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    // ── calculate_day_offset ──
159
160    #[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    // ── normalize_cycle_index ──
181
182    #[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    // ── get_shift_info ──
220
221    #[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    // ── Edge cases ──
294
295    #[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}