Skip to main content

leave_optimizer/
lib.rs

1//! # leave-optimizer
2//!
3//! **Leave strategy optimizer** using the *gap-merging algorithm*.
4//!
5//! Finds the best vacation strategies by bridging work gaps between rest blocks:
6//! "If I take N days off, what's the longest continuous break I can get?"
7//!
8//! ## How it works
9//!
10//! 1. Build daily status for each day from today to Dec 31 (shift + holidays + weekends)
11//! 2. Identify "rest blocks" (consecutive off days) and "work gaps" between them
12//! 3. For each work gap ≤ max_leave_days: bridge it → merge adjacent rest blocks
13//! 4. Score each strategy: 50% efficiency + 25% length + 25% family overlap
14//! 5. Deduplicate (same break range → keep fewest leave days) and sort by score
15//!
16//! ## Example
17//!
18//! ```rust
19//! use shift_algorithm::cycle::default_config;
20//! use leave_optimizer::find_best_leave_plans;
21//! use chrono::NaiveDate;
22//!
23//! let config = default_config();
24//! let today = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap();
25//! let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
26//!
27//! for (i, s) in plans.iter().take(3).enumerate() {
28//!     println!("{}: 请{}天 → 连休{}天 ({:.1}x)  {} – {}",
29//!         i + 1, s.leave_days, s.total_break_days,
30//!         s.efficiency, s.break_start, s.break_end);
31//! }
32//! ```
33
34use chrono::NaiveDate;
35use holiday_engine::{get_china_holidays, is_weekend, HolidayInfo};
36use serde::Serialize;
37use shift_algorithm::{get_shift_type_for_date, ShiftCycleConfig, ShiftType};
38use std::collections::{HashMap, HashSet};
39
40// ── Internal day status ──
41
42#[derive(Debug, Clone)]
43struct DayStatus {
44    date: NaiveDate,
45    is_rest: bool,
46    is_holiday: bool,
47    is_weekend: bool,
48    is_adjusted_work_day: bool,
49    holiday_name: Option<String>,
50}
51
52impl DayStatus {
53    fn is_off(&self) -> bool {
54        self.is_rest
55            || (self.is_holiday && !self.is_adjusted_work_day)
56            || (self.is_weekend && !self.is_adjusted_work_day)
57    }
58}
59
60// ── Public types ──
61
62/// A single leave strategy.
63///
64/// Returned by [`find_best_leave_plans`], sorted by score descending.
65#[derive(Debug, Clone, Serialize)]
66pub struct LeaveStrategy {
67    /// Number of leave days needed.
68    pub leave_days: u32,
69    /// Total consecutive break days achieved (rest + weekend + holiday + leave).
70    pub total_break_days: u32,
71    /// The specific dates to request leave.
72    pub leave_dates: Vec<NaiveDate>,
73    /// First day of the continuous break.
74    pub break_start: NaiveDate,
75    /// Last day of the continuous break.
76    pub break_end: NaiveDate,
77    /// Number of statutory holiday days within the break.
78    pub holiday_overlap: u32,
79    /// Number of weekend days within the break.
80    pub weekend_overlap: u32,
81    /// Names of overlapping holidays (e.g. "国庆节", "春节").
82    pub overlapping_holiday_names: Vec<String>,
83    /// Efficiency ratio = total_break_days / leave_days (higher is better).
84    pub efficiency: f64,
85    /// Composite score 0..1 (50% efficiency + 25% length + 25% family).
86    pub score: f64,
87}
88
89// ── Day status builder ──
90
91fn build_daily_status(
92    start_date: NaiveDate,
93    days: u32,
94    team_phase_offset: u32,
95    config: &ShiftCycleConfig,
96    holidays: &HashMap<NaiveDate, HolidayInfo>,
97) -> Vec<DayStatus> {
98    (0..days)
99        .map(|offset| {
100            let date = start_date + chrono::Duration::days(offset as i64);
101            let shift_type = get_shift_type_for_date(date, config, team_phase_offset);
102            let is_rest = matches!(shift_type, ShiftType::Rest | ShiftType::Study);
103            let holiday_info = holidays.get(&date);
104            let is_holiday = holiday_info.is_some_and(|h| h.is_holiday);
105            let is_adjusted_work_day = holiday_info.is_some_and(|h| !h.is_holiday);
106            let holiday_name = if is_holiday {
107                holiday_info.map(|h| h.name.to_string())
108            } else {
109                None
110            };
111            DayStatus { date, is_rest, is_holiday, is_weekend: is_weekend(date), is_adjusted_work_day, holiday_name }
112        })
113        .collect()
114}
115
116// ── Main algorithm ──
117
118/// Find the best leave strategies using gap-merging.
119///
120/// # Parameters
121///
122/// - `today` — analysis start date (typically `Local::now().date_naive()`)
123/// - `days_to_analyze` — number of days to scan (e.g. days until Dec 31)
124/// - `config` — shift cycle configuration
125/// - `team_phase_offset` — team offset from [`ShiftCycleConfig::team_phase_offset`]
126/// - `holidays` — holiday map; `None` uses built-in China holidays
127/// - `max_leave_days` — max leave days to consider (typically 3-5)
128///
129/// # Returns
130///
131/// Strategies sorted by score descending. Empty if no valid strategies found.
132///
133/// ```rust
134/// use shift_algorithm::cycle::default_config;
135/// use leave_optimizer::find_best_leave_plans;
136/// use chrono::NaiveDate;
137///
138/// let config = default_config();
139/// let today = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap();
140/// let plans = find_best_leave_plans(today, 60, &config, 0, None, 5);
141///
142/// assert!(!plans.is_empty());
143/// // Best strategy first
144/// for w in plans.windows(2) {
145///     assert!(w[0].score >= w[1].score);
146/// }
147/// ```
148pub fn find_best_leave_plans(
149    today: NaiveDate,
150    days_to_analyze: u32,
151    config: &ShiftCycleConfig,
152    team_phase_offset: u32,
153    holidays: Option<&HashMap<NaiveDate, HolidayInfo>>,
154    max_leave_days: u32,
155) -> Vec<LeaveStrategy> {
156    if days_to_analyze < 1 || max_leave_days < 1 {
157        return vec![];
158    }
159
160    let hols = holidays.cloned().unwrap_or_else(get_china_holidays);
161    let status = build_daily_status(today, days_to_analyze, team_phase_offset, config, &hols);
162    let n = status.len();
163
164    let mut rest_before = vec![0u32; n];
165    let mut rest_after = vec![0u32; n];
166
167    for i in 1..n {
168        rest_before[i] = if status[i - 1].is_off() { rest_before[i - 1] + 1 } else { 0 };
169    }
170    for i in (0..n - 1).rev() {
171        rest_after[i] = if status[i + 1].is_off() { rest_after[i + 1] + 1 } else { 0 };
172    }
173
174    let mut strategies: Vec<LeaveStrategy> = Vec::new();
175    let min_leave_days = if max_leave_days == 1 { 1 } else { 2 };
176
177    for leave_days in min_leave_days..=max_leave_days {
178        for start_idx in 0..=(n as i32 - leave_days as i32) {
179            let start_idx = start_idx as usize;
180
181            let has_shift_rest = (0..leave_days).any(|j| status[start_idx + j as usize].is_rest);
182            if has_shift_rest { continue; }
183
184            let left_rest = rest_before[start_idx];
185            let right_rest = rest_after[start_idx + leave_days as usize - 1];
186            let total_break = left_rest + leave_days + right_rest;
187            if total_break <= leave_days { continue; }
188
189            let gap_start = start_idx as i32 - left_rest as i32;
190            let gap_end = start_idx as i32 + leave_days as i32 - 1 + right_rest as i32;
191            let break_start_date = status[gap_start as usize].date;
192            let break_end_date = status[gap_end as usize].date;
193
194            let mut holiday_overlap = 0u32;
195            let mut weekend_overlap = 0u32;
196            let mut holiday_names: HashSet<String> = HashSet::new();
197
198            for idx in gap_start..=gap_end {
199                let ds = &status[idx as usize];
200                if ds.is_holiday { holiday_overlap += 1; if let Some(ref name) = ds.holiday_name { holiday_names.insert(name.clone()); } }
201                if ds.is_weekend && !ds.is_adjusted_work_day { weekend_overlap += 1; }
202            }
203
204            let leave_date_list: Vec<NaiveDate> = (0..leave_days)
205                .map(|j| status[start_idx + j as usize].date)
206                .collect();
207            let efficiency = total_break as f64 / leave_days as f64;
208
209            strategies.push(LeaveStrategy {
210                leave_days, total_break_days: total_break,
211                leave_dates: leave_date_list, break_start: break_start_date,
212                break_end: break_end_date, holiday_overlap, weekend_overlap,
213                overlapping_holiday_names: holiday_names.into_iter().collect(),
214                efficiency, score: 0.0,
215            });
216        }
217    }
218
219    // Dedup: same (break_start, break_end) → keep fewest leave days
220    let mut deduped: HashMap<String, LeaveStrategy> = HashMap::new();
221    for s in strategies {
222        let key = format!("{}_{}", s.break_start, s.break_end);
223        match deduped.get(&key) {
224            Some(existing) if existing.leave_days <= s.leave_days => {}
225            _ => { deduped.insert(key, s); }
226        }
227    }
228    if deduped.is_empty() { return vec![]; }
229
230    let deduped_list: Vec<LeaveStrategy> = deduped.into_values().collect();
231
232    let max_efficiency = deduped_list.iter().map(|s| s.efficiency).fold(0.0f64, f64::max);
233    let max_break = deduped_list.iter().map(|s| s.total_break_days).max().unwrap_or(1);
234    let max_family_bonus = deduped_list.iter().map(|s| s.holiday_overlap * 2 + s.weekend_overlap).max().unwrap_or(1).max(1);
235
236    let mut scored: Vec<LeaveStrategy> = deduped_list.into_iter().map(|s| {
237        let eff_score = if max_efficiency > 0.0 { s.efficiency / max_efficiency } else { 0.0 };
238        let len_score = if max_break > 0 { s.total_break_days as f64 / max_break as f64 } else { 0.0 };
239        let family_bonus = (s.holiday_overlap * 2 + s.weekend_overlap) as f64;
240        let fam_score = family_bonus / max_family_bonus as f64;
241        let score = 0.50 * eff_score + 0.25 * len_score + 0.25 * fam_score;
242        LeaveStrategy { score, ..s }
243    }).collect();
244
245    // Stable sort: score desc, then break_start asc (earlier first)
246    scored.sort_by(|a, b| {
247        b.score.partial_cmp(&a.score)
248            .unwrap_or(std::cmp::Ordering::Equal)
249            .then_with(|| a.break_start.cmp(&b.break_start))
250    });
251    scored
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use shift_algorithm::cycle::default_config;
258
259    #[test]
260    fn returns_empty_for_zero_analyze_days() {
261        let config = default_config();
262        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
263        let plans = find_best_leave_plans(today, 0, &config, 0, None, 5);
264        assert!(plans.is_empty());
265    }
266
267    #[test]
268    fn returns_empty_for_zero_max_leave() {
269        let config = default_config();
270        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
271        let plans = find_best_leave_plans(today, 90, &config, 0, None, 0);
272        assert!(plans.is_empty());
273    }
274
275    #[test]
276    fn produces_strategies_with_default_config() {
277        let config = default_config();
278        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
279        let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
280        assert!(!plans.is_empty());
281    }
282
283    #[test]
284    fn strategies_are_sorted_by_score_desc() {
285        let config = default_config();
286        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
287        let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
288        for w in plans.windows(2) {
289            assert!(w[0].score >= w[1].score);
290        }
291    }
292
293    #[test]
294    fn total_break_exceeds_leave_days() {
295        let config = default_config();
296        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
297        let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
298        for s in &plans {
299            assert!(s.total_break_days > s.leave_days);
300        }
301    }
302
303    #[test]
304    fn leave_dates_count_matches() {
305        let config = default_config();
306        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
307        let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
308        for s in &plans {
309            assert_eq!(s.leave_dates.len() as u32, s.leave_days);
310        }
311    }
312
313    #[test]
314    fn no_duplicate_break_ranges() {
315        let config = default_config();
316        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
317        let plans = find_best_leave_plans(today, 180, &config, 0, None, 5);
318        let mut seen: HashSet<(NaiveDate, NaiveDate)> = HashSet::new();
319        for s in &plans {
320            let key = (s.break_start, s.break_end);
321            assert!(seen.insert(key));
322        }
323    }
324
325    #[test]
326    fn respects_max_leave_days() {
327        let config = default_config();
328        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
329        let plans = find_best_leave_plans(today, 90, &config, 0, None, 3);
330        for s in &plans {
331            assert!(s.leave_days <= 3);
332        }
333    }
334
335    #[test]
336    fn dates_within_analysis_window() {
337        let config = default_config();
338        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
339        let window = 60u32;
340        let end = today + chrono::Duration::days(window as i64);
341        let plans = find_best_leave_plans(today, window, &config, 0, None, 5);
342        for s in &plans {
343            assert!(s.break_start >= today);
344            assert!(s.break_end < end);
345        }
346    }
347
348    #[test]
349    fn includes_national_day_strategies() {
350        let config = default_config();
351        let today = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap();
352        let plans = find_best_leave_plans(today, 60, &config, 0, None, 5);
353        let has_national_day = plans.iter().any(|s| {
354            s.overlapping_holiday_names.iter().any(|n| n.contains("国庆"))
355        });
356        assert!(has_national_day);
357    }
358
359    #[test]
360    fn different_team_produces_different_strategies() {
361        let config = default_config();
362        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
363        let plans_team1 = find_best_leave_plans(today, 90, &config, 0, None, 5);
364        let plans_team2 = find_best_leave_plans(today, 90, &config, 7, None, 5);
365        let keys1: HashSet<String> = plans_team1.iter().map(|s| format!("{}_{}", s.break_start, s.break_end)).collect();
366        let keys2: HashSet<String> = plans_team2.iter().map(|s| format!("{}_{}", s.break_start, s.break_end)).collect();
367        assert_ne!(keys1, keys2);
368    }
369
370    #[test]
371    fn custom_cycle_produces_strategies() {
372        use ShiftType::*;
373        let config = ShiftCycleConfig {
374            cycle: vec![Morning, Afternoon, Rest, Night, Rest, Morning, Afternoon],
375            cycle_length: 7,
376            reference_date: shift_algorithm::cycle::default_reference_date(),
377            total_teams: 2,
378        };
379        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
380        let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
381        assert!(!plans.is_empty());
382    }
383
384    #[test]
385    fn efficiency_is_at_least_one() {
386        let config = default_config();
387        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
388        let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
389        for s in &plans { assert!(s.efficiency >= 1.0); }
390    }
391
392    #[test]
393    fn score_is_between_zero_and_one() {
394        let config = default_config();
395        let today = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
396        let plans = find_best_leave_plans(today, 90, &config, 0, None, 5);
397        for s in &plans { assert!((0.0..=1.01).contains(&s.score)); }
398    }
399}