Skip to main content

shift_algorithm/
types.rs

1//! Core data types for the shift scheduling system.
2
3use serde::{Deserialize, Serialize};
4
5/// The five shift types in a standard Chinese rotating shift system.
6///
7/// # Variants
8///
9/// | Variant | Label | Full Label | Category |
10/// |---------|-------|-----------|----------|
11/// | [`Morning`](ShiftType::Morning) | 早 | 早班 | Work |
12/// | [`Afternoon`](ShiftType::Afternoon) | 中 | 中班 | Work |
13/// | [`Rest`](ShiftType::Rest) | 休 | 休班 | Rest |
14/// | [`Night`](ShiftType::Night) | 夜 | 夜班 | Work |
15/// | [`Study`](ShiftType::Study) | 学 | 学习班 | Rest |
16///
17/// # Example
18///
19/// ```rust
20/// use shift_algorithm::ShiftType;
21///
22/// assert!(ShiftType::Morning.is_work());
23/// assert!(!ShiftType::Rest.is_work());
24/// assert!(ShiftType::Rest.is_rest());
25/// assert!(ShiftType::Study.is_rest());
26/// ```
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
29pub enum ShiftType {
30    /// 早班 — morning shift
31    Morning,
32    /// 中班 — afternoon shift
33    Afternoon,
34    /// 休班 — rest day
35    Rest,
36    /// 夜班 — night shift
37    Night,
38    /// 学习班 — study/training day (counts as rest for scheduling purposes)
39    Study,
40}
41
42impl ShiftType {
43    /// Short Chinese label (single character).
44    pub fn label(&self) -> &'static str {
45        match self {
46            ShiftType::Morning => "早",
47            ShiftType::Afternoon => "中",
48            ShiftType::Rest => "休",
49            ShiftType::Night => "夜",
50            ShiftType::Study => "学",
51        }
52    }
53
54    /// Full Chinese label.
55    pub fn full_label(&self) -> &'static str {
56        match self {
57            ShiftType::Morning => "早班",
58            ShiftType::Afternoon => "中班",
59            ShiftType::Rest => "休班",
60            ShiftType::Night => "夜班",
61            ShiftType::Study => "学习班",
62        }
63    }
64
65    /// Short English label.
66    pub fn label_en(&self) -> &'static str {
67        match self {
68            ShiftType::Morning => "AM",
69            ShiftType::Afternoon => "PM",
70            ShiftType::Rest => "R ",
71            ShiftType::Night => "NT",
72            ShiftType::Study => "TR",
73        }
74    }
75
76    /// Padded English label (3 chars for alignment).
77    pub fn label_en_padded(&self) -> &'static str {
78        match self {
79            ShiftType::Morning => "AM ",
80            ShiftType::Afternoon => "PM ",
81            ShiftType::Rest => "R ",
82            ShiftType::Night => "NT ",
83            ShiftType::Study => "TR ",
84        }
85    }
86
87    /// Full English label.
88    pub fn full_label_en(&self) -> &'static str {
89        match self {
90            ShiftType::Morning => "Morning",
91            ShiftType::Afternoon => "Afternoon",
92            ShiftType::Rest => "Rest",
93            ShiftType::Night => "Night",
94            ShiftType::Study => "Study",
95        }
96    }
97
98    /// Returns `true` if this is a working shift (Morning, Afternoon, or Night).
99    ///
100    /// Used for counting work days, consecutive work stats, etc.
101    pub fn is_work(&self) -> bool {
102        matches!(self, ShiftType::Morning | ShiftType::Afternoon | ShiftType::Night)
103    }
104
105    /// Returns `true` if this counts as rest (Rest or Study).
106    ///
107    /// Study days are treated as rest because the worker is not on duty.
108    pub fn is_rest(&self) -> bool {
109        matches!(self, ShiftType::Rest | ShiftType::Study)
110    }
111}
112
113/// Result of querying what shift falls on a given date.
114///
115/// Returned by [`get_shift_info`](crate::get_shift_info).
116///
117/// # Fields
118///
119/// | Field | Type | Range | Description |
120/// |-------|------|-------|-------------|
121/// | `date` | `NaiveDate` | — | The queried date |
122/// | `day_of_cycle` | `u32` | `1..=cycle_length` | Which day in the cycle (1-based) |
123/// | `cycle_index` | `u32` | `0..=cycle_length-1` | Zero-based index into the cycle array |
124/// | `shift_type` | [`ShiftType`] | — | The shift type for this date |
125///
126/// ```rust
127/// use shift_algorithm::cycle::default_config;
128/// use shift_algorithm::get_shift_info;
129///
130/// let config = default_config();
131/// let info = get_shift_info(config.reference_date, &config, 0);
132///
133/// assert_eq!(info.day_of_cycle, 1);
134/// assert_eq!(info.cycle_index, 0);
135/// ```
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ShiftInfo {
138    /// The queried date.
139    pub date: chrono::NaiveDate,
140    /// Day index within the cycle (1-based, 1..=cycle_length).
141    pub day_of_cycle: u32,
142    /// Zero-based index into the cycle array (0..=cycle_length-1).
143    pub cycle_index: u32,
144    /// The shift type for this date.
145    pub shift_type: ShiftType,
146}
147
148/// Runtime shift cycle configuration.
149///
150/// The default 42-day, 6-team configuration is available via
151/// [`default_config()`](crate::cycle::default_config).
152///
153/// # Custom cycles
154///
155/// ```rust
156/// use shift_algorithm::{ShiftCycleConfig, ShiftType};
157/// use chrono::NaiveDate;
158///
159/// let config = ShiftCycleConfig {
160///     cycle: vec![ShiftType::Morning, ShiftType::Afternoon, ShiftType::Rest],
161///     cycle_length: 3,
162///     reference_date: NaiveDate::from_ymd_opt(2025, 12, 15).unwrap(),
163///     total_teams: 1,
164/// };
165/// ```
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct ShiftCycleConfig {
168    /// The ordered list of shift types defining one full cycle.
169    /// Must have length == `cycle_length`.
170    pub cycle: Vec<ShiftType>,
171    /// Number of days in one full cycle (= `cycle.len()`).
172    pub cycle_length: u32,
173    /// The anchor reference date. Day 1 of the cycle falls on this date.
174    /// Default: 2025-12-15.
175    pub reference_date: chrono::NaiveDate,
176    /// Total number of teams sharing this cycle.
177    /// Each team is offset by `cycle_length / total_teams` days.
178    /// Default: 6.
179    pub total_teams: u32,
180}
181
182/// Chinese team name for a team ID.
183///
184/// ```rust
185/// use shift_algorithm::team_name;
186/// assert_eq!(team_name(1), "一值");
187/// assert_eq!(team_name(3), "三值");
188/// assert_eq!(team_name(6), "六值");
189/// ```
190pub fn team_name(id: u32) -> String {
191    let prefix = match id {
192        1 => "一", 2 => "二", 3 => "三",
193        4 => "四", 5 => "五", 6 => "六",
194        _ => return format!("{}值", id),
195    };
196    format!("{}值", prefix)
197}
198
199/// The team that follows yours in the circular rotation order.
200///
201/// In a 6-team rotation, the successor of team N is team N+1 (with wraparound):
202/// - Team 1 → Team 2, Team 2 → Team 3, ..., Team 6 → Team 1
203///
204/// **Note**: this reflects the circular **team numbering**, not a guarantee about
205/// shift status. Whether the successor is working or resting on a given day
206/// depends on the cycle position and is not always opposite.
207///
208/// ```rust
209/// use shift_algorithm::successor_team_id;
210///
211/// assert_eq!(successor_team_id(1, 6), 2);
212/// assert_eq!(successor_team_id(6, 6), 1);
213/// assert_eq!(successor_team_id(3, 6), 4);
214/// assert_eq!(successor_team_id(1, 1), 1); // single-team: wraps to self
215/// ```
216pub fn successor_team_id(team_id: u32, total_teams: u32) -> u32 {
217    assert!(total_teams >= 1, "total_teams must be >= 1");
218    assert!(team_id >= 1, "team_id must be >= 1");
219    (team_id % total_teams) + 1
220}
221
222/// The team that yours follows in the circular rotation order.
223///
224/// The **predecessor** of team N is the team whose shift your team takes over.
225/// In a 6-team rotation, the predecessor of team N is team N-1 (with wraparound):
226/// - Team 1 ← Team 6, Team 2 ← Team 1, ..., Team 6 ← Team 5
227///
228/// Formula: `(team_id + total_teams - 2) % total_teams + 1`
229///
230/// ```rust
231/// use shift_algorithm::predecessor_team_id;
232///
233/// assert_eq!(predecessor_team_id(1, 6), 6); // Team 1 takes over from Team 6
234/// assert_eq!(predecessor_team_id(2, 6), 1); // Team 2 takes over from Team 1
235/// assert_eq!(predecessor_team_id(3, 6), 2);
236/// assert_eq!(predecessor_team_id(1, 1), 1); // single-team: wraps to self
237/// ```
238pub fn predecessor_team_id(team_id: u32, total_teams: u32) -> u32 {
239    assert!(total_teams >= 1, "total_teams must be >= 1");
240    assert!(team_id >= 1, "team_id must be >= 1");
241    (team_id + total_teams - 2) % total_teams + 1
242}
243
244impl ShiftCycleConfig {
245    /// Create a new config, validating that `cycle.len() == cycle_length`.
246    ///
247    /// # Panics
248    /// Panics if `cycle.len() != cycle_length as usize`.
249    pub fn new(cycle: Vec<ShiftType>, reference_date: chrono::NaiveDate, total_teams: u32) -> Self {
250        let cycle_length = cycle.len() as u32;
251        assert!(cycle_length >= 1, "cycle must be non-empty");
252        assert!(total_teams >= 1, "total_teams must be >= 1");
253        Self { cycle, cycle_length, reference_date, total_teams }
254    }
255
256    /// The team that follows yours in the circular rotation order.
257    ///
258    /// Convenience wrapper around [`successor_team_id`] using `self.total_teams`.
259    ///
260    /// ```rust
261    /// use shift_algorithm::cycle::default_config;
262    ///
263    /// let config = default_config();
264    /// assert_eq!(config.successor_of(1), 2);
265    /// assert_eq!(config.successor_of(6), 1);
266    /// ```
267    pub fn successor_of(&self, team_id: u32) -> u32 {
268        successor_team_id(team_id, self.total_teams)
269    }
270
271    /// The team that yours follows in the circular rotation order.
272    ///
273    /// The predecessor is the team whose shift your team takes over.
274    /// Convenience wrapper around [`predecessor_team_id`] using `self.total_teams`.
275    ///
276    /// ```rust
277    /// use shift_algorithm::cycle::default_config;
278    ///
279    /// let config = default_config();
280    /// assert_eq!(config.predecessor_of(1), 6); // Team 1 takes over from Team 6
281    /// assert_eq!(config.predecessor_of(2), 1); // Team 2 takes over from Team 1
282    /// assert_eq!(config.predecessor_of(6), 5);
283    /// ```
284    pub fn predecessor_of(&self, team_id: u32) -> u32 {
285        predecessor_team_id(team_id, self.total_teams)
286    }
287
288    /// Team phase offset in days.
289    ///
290    /// Formula: `(team_id - 1) * (cycle_length / total_teams)`.
291    ///
292    /// For a 42-day, 6-team cycle:
293    /// - Team 1 (一值): offset 0
294    /// - Team 2 (二值): offset 7
295    /// - Team 3 (三值): offset 14
296    /// - ...
297    /// - Team 6 (六值): offset 35
298    ///
299    /// ```rust
300    /// use shift_algorithm::cycle::default_config;
301    ///
302    /// let config = default_config();
303    /// assert_eq!(config.team_phase_offset(1), 0);
304    /// assert_eq!(config.team_phase_offset(2), 7);
305    /// assert_eq!(config.team_phase_offset(6), 35);
306    /// ```
307    pub fn team_phase_offset(&self, team_id: u32) -> u32 {
308        (team_id - 1) * (self.cycle_length / self.total_teams)
309    }
310
311    /// Find which team you take over from and which team takes over from you.
312    ///
313    /// Shift handover happens **within a single day** between different shift types:
314    /// - 夜 → 早 → 中 → 夜 (cyclical)
315    /// - If you are on 休 or 学, there is no handover (you're not working).
316    ///
317    /// Returns `(predecessor_team_id, successor_team_id)` — the teams whose shifts
318    /// you take over from and who takes over from you, respectively.
319    ///
320    /// ```rust
321    /// use shift_algorithm::cycle::default_config;
322    /// use chrono::NaiveDate;
323    ///
324    /// let config = default_config();
325    /// let date = NaiveDate::from_ymd_opt(2026, 6, 26).unwrap();
326    ///
327    /// // If team 1 is working 早班 today, predecessor should be the team on 夜班,
328    /// // successor should be the team on 中班.
329    /// if let Some((pred, succ)) = config.shift_handover(date, 1) {
330    ///     println!("Take over from team {}, hand over to team {}", pred, succ);
331    /// }
332    /// ```
333    pub fn shift_handover(
334        &self,
335        date: chrono::NaiveDate,
336        team_id: u32,
337    ) -> Option<(u32, u32)> {
338        use crate::calculator::get_shift_type_for_date;
339
340        let my_shift = get_shift_type_for_date(date, self, self.team_phase_offset(team_id));
341        if my_shift.is_rest() {
342            return None; // not working, no handover
343        }
344
345        // Shift handover order: 夜 → 早 → 中 → 夜
346        let (pred_shift, succ_shift) = match my_shift {
347            ShiftType::Morning => (ShiftType::Night, ShiftType::Afternoon),
348            ShiftType::Afternoon => (ShiftType::Morning, ShiftType::Night),
349            ShiftType::Night => (ShiftType::Afternoon, ShiftType::Morning),
350            _ => unreachable!(), // is_rest() already handled
351        };
352
353        // Scan all teams to find who is on pred_shift / succ_shift today
354        let mut pred_team: Option<u32> = None;
355        let mut succ_team: Option<u32> = None;
356
357        for t in 1..=self.total_teams {
358            if t == team_id {
359                continue;
360            }
361            let shift = get_shift_type_for_date(date, self, self.team_phase_offset(t));
362            if shift == pred_shift {
363                pred_team = Some(t);
364            }
365            if shift == succ_shift {
366                succ_team = Some(t);
367            }
368            if pred_team.is_some() && succ_team.is_some() {
369                break;
370            }
371        }
372
373        match (pred_team, succ_team) {
374            (Some(p), Some(s)) => Some((p, s)),
375            _ => None,
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::cycle::default_config;
384
385    // ── successor_team_id ──
386
387    #[test]
388    fn successor_team_1_is_2() {
389        assert_eq!(successor_team_id(1, 6), 2);
390    }
391
392    #[test]
393    fn successor_team_6_wraps_to_1() {
394        assert_eq!(successor_team_id(6, 6), 1);
395    }
396
397    #[test]
398    fn successor_team_3_is_4() {
399        assert_eq!(successor_team_id(3, 6), 4);
400    }
401
402    #[test]
403    fn successor_single_team_wraps_to_self() {
404        assert_eq!(successor_team_id(1, 1), 1);
405    }
406
407    // ── predecessor_team_id ──
408
409    #[test]
410    fn predecessor_team_1_is_6() {
411        assert_eq!(predecessor_team_id(1, 6), 6);
412    }
413
414    #[test]
415    fn predecessor_team_2_is_1() {
416        assert_eq!(predecessor_team_id(2, 6), 1);
417    }
418
419    #[test]
420    fn predecessor_team_6_is_5() {
421        assert_eq!(predecessor_team_id(6, 6), 5);
422    }
423
424    #[test]
425    fn predecessor_team_3_is_2() {
426        assert_eq!(predecessor_team_id(3, 6), 2);
427    }
428
429    #[test]
430    fn predecessor_single_team_wraps_to_self() {
431        assert_eq!(predecessor_team_id(1, 1), 1);
432    }
433
434    // ── ShiftCycleConfig methods ──
435
436    #[test]
437    fn config_successor_of() {
438        let config = default_config();
439        assert_eq!(config.successor_of(1), 2);
440        assert_eq!(config.successor_of(6), 1);
441    }
442
443    #[test]
444    fn config_predecessor_of() {
445        let config = default_config();
446        assert_eq!(config.predecessor_of(1), 6);
447        assert_eq!(config.predecessor_of(2), 1);
448    }
449
450    // ── completeness ──
451
452    #[test]
453    fn all_successors_are_unique() {
454        let mut succs: Vec<u32> = (1..=6).map(|t| successor_team_id(t, 6)).collect();
455        succs.sort();
456        assert_eq!(succs, vec![1, 2, 3, 4, 5, 6]);
457    }
458
459    #[test]
460    fn all_predecessors_are_unique() {
461        let mut preds: Vec<u32> = (1..=6).map(|t| predecessor_team_id(t, 6)).collect();
462        preds.sort();
463        assert_eq!(preds, vec![1, 2, 3, 4, 5, 6]);
464    }
465
466    #[test]
467    fn pred_succ_cycle() {
468        // predecessor(successor(team)) == team
469        for t in 1..=6 {
470            let succ = successor_team_id(t, 6);
471            assert_eq!(predecessor_team_id(succ, 6), t,
472                "predecessor(successor({})) should be {}", t, t);
473        }
474    }
475}