Skip to main content

shift_export/
lib.rs

1//! # export-engine
2//!
3//! **ICS (RFC 5545) calendar export** for shift schedules.
4//!
5//! Generates standard `.ics` files importable into Thunderbird, GNOME Calendar,
6//! Nextcloud, Apple Calendar, Google Calendar, Outlook.
7//!
8//! ## Design
9//!
10//! One VEVENT per day — simple, correct, no RRULE complexity.
11//! A full year produces ~365 VEVENTs (~50KB), well within ICS limits.
12//!
13//! The earlier RRULE approach was abandoned because shift types appear at
14//! multiple irregular positions within a cycle (e.g. Morning appears 7 times
15//! in 42 days), which can't be expressed as a single `FREQ=DAILY;INTERVAL=42`.
16
17use chrono::NaiveDate;
18use shift_algorithm::{get_shift_type_for_date, ShiftCycleConfig, ShiftType};
19use std::collections::HashMap;
20
21/// Configuration for alarm times. Key: shift type name ("morning", etc.), Value: (hour, minute).
22pub type AlarmConfig = HashMap<String, (u32, u32)>;
23
24/// Generate an ICS calendar file covering a date range.
25///
26/// One VEVENT per day. Night shifts cross midnight (DTSTART 22:00 → DTEND next day 06:00).
27/// Rest/Study are all-day events. Morning/Afternoon have configurable start times.
28pub fn generate_shift_ics(
29    start_date: NaiveDate,
30    end_date: NaiveDate,
31    config: &ShiftCycleConfig,
32    team_phase_offset: u32,
33    team_id: u32,
34    alarms: Option<&AlarmConfig>,
35    timezone: &str,
36) -> String {
37    let crlf = "\r\n";
38    let mut buf = String::new();
39
40    // VCALENDAR header
41    buf.push_str("BEGIN:VCALENDAR");
42    buf.push_str(crlf);
43    buf.push_str("VERSION:2.0");
44    buf.push_str(crlf);
45    buf.push_str("PRODID:-//班伴 ShiftMate//shift-core//EN");
46    buf.push_str(crlf);
47    buf.push_str("CALSCALE:GREGORIAN");
48    buf.push_str(crlf);
49    buf.push_str("METHOD:PUBLISH");
50    buf.push_str(crlf);
51    buf.push_str(&format!("X-WR-CALNAME:班伴 · {}排班", shift_algorithm::team_name(team_id)));
52    buf.push_str(crlf);
53    buf.push_str("X-WR-CALDESC:Auto-generated shift schedule by 班伴 (ShiftMate)");
54    buf.push_str(crlf);
55    buf.push_str(&format!("X-WR-TIMEZONE:{}", timezone));
56    buf.push_str(crlf);
57
58    // VTIMEZONE
59    buf.push_str("BEGIN:VTIMEZONE");
60    buf.push_str(crlf);
61    buf.push_str(&format!("TZID:{}", timezone));
62    buf.push_str(crlf);
63    buf.push_str("BEGIN:STANDARD");
64    buf.push_str(crlf);
65    buf.push_str("DTSTART:19700101T000000");
66    buf.push_str(crlf);
67    buf.push_str("TZOFFSETFROM:+0800");
68    buf.push_str(crlf);
69    buf.push_str("TZOFFSETTO:+0800");
70    buf.push_str(crlf);
71    buf.push_str("TZNAME:CST");
72    buf.push_str(crlf);
73    buf.push_str("END:STANDARD");
74    buf.push_str(crlf);
75    buf.push_str("END:VTIMEZONE");
76    buf.push_str(crlf);
77
78    // One VEVENT per day
79    let mut cursor = start_date;
80    while cursor <= end_date {
81        let shift_type = get_shift_type_for_date(cursor, config, team_phase_offset);
82
83        let (start_hhmm, end_hhmm, end_date) = match shift_type {
84            ShiftType::Morning => ("070000", "150000", cursor),
85            ShiftType::Afternoon => ("140000", "220000", cursor),
86            ShiftType::Night => ("220000", "060000", cursor + chrono::Duration::days(1)),
87            ShiftType::Rest | ShiftType::Study => ("000000", "235900", cursor),
88        };
89
90        let dtstart = format!("{}T{}", cursor.format("%Y%m%d"), start_hhmm);
91        let dtend = format!("{}T{}", end_date.format("%Y%m%d"), end_hhmm);
92
93        let summary = format!("{} · {}", shift_type.full_label(), shift_algorithm::team_name(team_id));
94
95        let day_info = shift_algorithm::get_shift_info(cursor, config, team_phase_offset);
96
97        buf.push_str("BEGIN:VEVENT");
98        buf.push_str(crlf);
99        buf.push_str(&format!("DTSTART;TZID={}:{}", timezone, dtstart));
100        buf.push_str(crlf);
101        buf.push_str(&format!("DTEND;TZID={}:{}", timezone, dtend));
102        buf.push_str(crlf);
103        buf.push_str(&format!("SUMMARY:{}", ics_escape(&summary)));
104        buf.push_str(crlf);
105        buf.push_str(&format!(
106            "DESCRIPTION:周期第 {}/{} 天",
107            day_info.day_of_cycle, config.cycle_length,
108        ));
109        buf.push_str(crlf);
110        buf.push_str(&format!(
111            "CATEGORIES:SHIFT_{}",
112            format!("{:?}", shift_type).to_uppercase()
113        ));
114        buf.push_str(crlf);
115
116        // VALARM if this shift type has an alarm configured
117        if let Some(alarm_cfg) = alarms {
118            let key = shift_alarm_key(&shift_type);
119            if let Some((hour, minute)) = alarm_cfg.get(&key) {
120                let shift_start_min = match shift_type {
121                    ShiftType::Morning => 7 * 60,
122                    ShiftType::Afternoon => 14 * 60,
123                    ShiftType::Night => 22 * 60,
124                    _ => 9 * 60,
125                };
126                let alarm_min = (*hour * 60 + *minute) as i32;
127                let trigger = (shift_start_min as i32 - alarm_min).abs();
128
129                buf.push_str("BEGIN:VALARM");
130                buf.push_str(crlf);
131                buf.push_str(&format!("TRIGGER:-PT{}M", trigger));
132                buf.push_str(crlf);
133                buf.push_str("ACTION:DISPLAY");
134                buf.push_str(crlf);
135                buf.push_str(&format!(
136                    "DESCRIPTION:{}提醒",
137                    ics_escape(shift_type.full_label())
138                ));
139                buf.push_str(crlf);
140                buf.push_str("END:VALARM");
141                buf.push_str(crlf);
142            }
143        }
144
145        buf.push_str("END:VEVENT");
146        buf.push_str(crlf);
147
148        cursor += chrono::Duration::days(1);
149    }
150
151    buf.push_str("END:VCALENDAR");
152    buf.push_str(crlf);
153
154    buf
155}
156
157/// Escape special characters in ICS text values.
158fn ics_escape(s: &str) -> String {
159    s.replace('\\', "\\\\")
160        .replace(';', "\\;")
161        .replace(',', "\\,")
162        .replace('\n', "\\n")
163}
164
165
166fn shift_alarm_key(st: &ShiftType) -> String {
167    match st {
168        ShiftType::Morning => "morning",
169        ShiftType::Afternoon => "afternoon",
170        ShiftType::Night => "night",
171        ShiftType::Rest => "rest",
172        ShiftType::Study => "study",
173    }
174    .to_string()
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use shift_algorithm::cycle::default_config;
181
182    #[test]
183    fn generates_valid_ics_structure() {
184        let config = default_config();
185        let start = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
186        let end = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap();
187        let ics = generate_shift_ics(start, end, &config, 0, 1, None, "Asia/Shanghai");
188
189        assert!(ics.starts_with("BEGIN:VCALENDAR\r\n"));
190        assert!(ics.contains("VERSION:2.0\r\n"));
191        assert!(ics.ends_with("END:VCALENDAR\r\n"));
192        // 30 days → 30 VEVENTs
193        let count = ics.matches("BEGIN:VEVENT\r\n").count();
194        assert_eq!(count, 30);
195    }
196
197    #[test]
198    fn every_begin_has_matching_end() {
199        let config = default_config();
200        let start = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
201        let end = NaiveDate::from_ymd_opt(2026, 6, 7).unwrap();
202        let ics = generate_shift_ics(start, end, &config, 0, 1, None, "Asia/Shanghai");
203
204        assert_eq!(ics.matches("BEGIN:").count(), ics.matches("END:").count());
205    }
206
207    #[test]
208    fn each_vevent_has_required_properties() {
209        let config = default_config();
210        let start = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
211        let end = start;
212        let ics = generate_shift_ics(start, end, &config, 0, 1, None, "Asia/Shanghai");
213
214        assert!(ics.contains("DTSTART;TZID=Asia/Shanghai:"));
215        assert!(ics.contains("DTEND;TZID=Asia/Shanghai:"));
216        assert!(ics.contains("SUMMARY:"));
217        // No RRULE — one VEVENT per day
218        assert!(!ics.contains("RRULE"));
219    }
220
221    #[test]
222    fn night_shift_crosses_midnight() {
223        let config = default_config();
224        // 2026-05-22 is Night for team 1
225        let date = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
226        let ics = generate_shift_ics(date, date, &config, 0, 1, None, "Asia/Shanghai");
227
228        assert!(ics.contains("DTSTART;TZID=Asia/Shanghai:20260522T220000"));
229        assert!(ics.contains("DTEND;TZID=Asia/Shanghai:20260523T060000"));
230    }
231
232    #[test]
233    fn morning_shift_has_correct_times() {
234        let config = default_config();
235        // 2025-12-15 is Morning for team 1 (reference date, day 1)
236        let date = NaiveDate::from_ymd_opt(2025, 12, 15).unwrap();
237        let ics = generate_shift_ics(date, date, &config, 0, 1, None, "Asia/Shanghai");
238
239        assert!(ics.contains("DTSTART;TZID=Asia/Shanghai:20251215T070000"));
240        assert!(ics.contains("DTEND;TZID=Asia/Shanghai:20251215T150000"));
241    }
242
243    #[test]
244    fn rest_day_is_all_day_event() {
245        let config = default_config();
246        // 2025-12-19 is Rest (day 5, index 4)
247        let date = NaiveDate::from_ymd_opt(2025, 12, 19).unwrap();
248        let ics = generate_shift_ics(date, date, &config, 0, 1, None, "Asia/Shanghai");
249
250        assert!(ics.contains("DTSTART;TZID=Asia/Shanghai:20251219T000000"));
251        assert!(ics.contains("DTEND;TZID=Asia/Shanghai:20251219T235900"));
252    }
253
254    #[test]
255    fn full_year_produces_expected_count() {
256        let config = default_config();
257        let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
258        let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
259        let ics = generate_shift_ics(start, end, &config, 0, 1, None, "Asia/Shanghai");
260
261        // 2026 has 365 days → 365 VEVENTs
262        assert_eq!(ics.matches("BEGIN:VEVENT\r\n").count(), 365);
263        // Must use CRLF
264        assert!(ics.contains("\r\n"));
265    }
266
267    #[test]
268    fn alarms_produce_valarm() {
269        let config = default_config();
270        let start = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
271        let mut alarms: AlarmConfig = HashMap::new();
272        alarms.insert("night".into(), (21, 30)); // 30 min before 22:00
273
274        let ics = generate_shift_ics(start, start, &config, 0, 1, Some(&alarms), "Asia/Shanghai");
275        assert!(ics.contains("BEGIN:VALARM\r\n"));
276        assert!(ics.contains("TRIGGER:-PT30M"));
277        assert!(ics.contains("ACTION:DISPLAY"));
278    }
279
280    #[test]
281    fn no_alarms_for_unconfigured_shift() {
282        let config = default_config();
283        let start = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
284        let mut alarms: AlarmConfig = HashMap::new();
285        alarms.insert("morning".into(), (6, 30)); // morning only, not night
286
287        let ics = generate_shift_ics(start, start, &config, 0, 1, Some(&alarms), "Asia/Shanghai");
288        // May 22 is Night — should NOT have VALARM since only morning is configured
289        assert!(!ics.contains("VALARM"));
290    }
291
292    #[test]
293    fn calendar_description_varies_by_team() {
294        let config = default_config();
295        let start = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
296        let ics_team1 = generate_shift_ics(start, start, &config, 0, 1, None, "Asia/Shanghai");
297        let ics_team3 = generate_shift_ics(start, start, &config,
298            config.team_phase_offset(3), 3, None, "Asia/Shanghai");
299
300        assert!(ics_team1.contains("一值"));
301        assert!(ics_team3.contains("三值"));
302    }
303
304    #[test]
305    fn single_day_range_works() {
306        let config = default_config();
307        let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
308        let ics = generate_shift_ics(date, date, &config, 0, 1, None, "Asia/Shanghai");
309        assert_eq!(ics.matches("BEGIN:VEVENT\r\n").count(), 1);
310    }
311}