1use chrono::NaiveDate;
18use shift_algorithm::{get_shift_type_for_date, ShiftCycleConfig, ShiftType};
19use std::collections::HashMap;
20
21pub type AlarmConfig = HashMap<String, (u32, u32)>;
23
24pub 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 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 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 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 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
157fn 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 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 assert!(!ics.contains("RRULE"));
219 }
220
221 #[test]
222 fn night_shift_crosses_midnight() {
223 let config = default_config();
224 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 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 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 assert_eq!(ics.matches("BEGIN:VEVENT\r\n").count(), 365);
263 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)); 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)); let ics = generate_shift_ics(start, start, &config, 0, 1, Some(&alarms), "Asia/Shanghai");
288 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}