diff --git a/server/src/email_extract.rs b/server/src/email_extract.rs index 7d1f0ed..70dcc56 100644 --- a/server/src/email_extract.rs +++ b/server/src/email_extract.rs @@ -1300,6 +1300,7 @@ pub fn render_ical_summary(ical_data: &str) -> Result { for event in calendar.events { let mut event_summary = String::new(); let mut summary = None; + let mut description = None; let mut dtstart = None; let mut dtend = None; let mut organizer = None; @@ -1308,6 +1309,7 @@ pub fn render_ical_summary(ical_data: &str) -> Result { for prop in &event.properties { match prop.name.as_str() { "SUMMARY" => summary = prop.value.as_deref(), + "DESCRIPTION" => description = prop.value.as_deref(), "DTSTART" => { dtstart = prop.value.as_deref(); if let Some(params) = &prop.params { @@ -1345,11 +1347,23 @@ pub fn render_ical_summary(ical_data: &str) -> Result { let fmt_end = local_end.format("%c").to_string(); let mut days = vec![]; let mut d = start.date_naive(); - let end_d = end.date_naive(); - while d <= end_d { - days.push(d); - d = d.succ_opt().unwrap(); + let mut end_d = end.date_naive(); + // Check for all-day event (DATE, not DATE-TIME) + let allday = dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false)); + if allday { + // DTEND is exclusive for all-day events + if end_d > d { + end_d = end_d.pred_opt().unwrap(); + } } + // Only include actual event days + let mut day_iter = d; + while day_iter <= end_d { + days.push(day_iter); + day_iter = day_iter.succ_opt().unwrap(); + } + #[cfg(test)] + println!("DEBUG event_days: {:?}", days); (start, end, fmt_start, fmt_end, days) } else { let tz = chrono_tz::UTC; @@ -1357,36 +1371,119 @@ pub fn render_ical_summary(ical_data: &str) -> Result { (fallback, fallback, String::new(), String::new(), vec![]) }; - // Render calendar widget - let calendar_html = render_month_calendar_widget(start_dt.date_naive(), &event_days); - event_summary.push_str(&calendar_html); - + // Render a single merged calendar table for all spanned months + let mut calendar_html = String::new(); + if !event_days.is_empty() { + use chrono::Datelike; + let first_event = event_days.first().unwrap(); + let last_event = event_days.last().unwrap(); + // First day of the first event's month + let first_of_month = NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1).unwrap(); + // Last day of the last event's month + let last_of_month = { + let next_month = if last_event.month() == 12 { + NaiveDate::from_ymd_opt(last_event.year() + 1, 1, 1).unwrap() + } else { + NaiveDate::from_ymd_opt(last_event.year(), last_event.month() + 1, 1).unwrap() + }; + next_month.pred_opt().unwrap() + }; + // Find the first Sunday before or on the first day of the first event month + let mut cal_start = first_of_month; + while cal_start.weekday() != chrono::Weekday::Sun { + cal_start = cal_start.pred_opt().unwrap(); + } + // Find the last Saturday after or on the last day of the last event month + let mut cal_end = last_of_month; + while cal_end.weekday() != chrono::Weekday::Sat { + cal_end = cal_end.succ_opt().unwrap(); + } + // Collect all days in the calendar range + let mut all_days = vec![]; + let mut d = cal_start; + while d <= cal_end { + all_days.push(d); + d = d.succ_opt().unwrap(); + } + // Table header: show month/year range + let start_month = first_event.format("%B %Y"); + let end_month = last_event.format("%B %Y"); + let caption = if start_month.to_string() == end_month.to_string() { + start_month.to_string() + } else { + format!("{} – {}", start_month, end_month) + }; + calendar_html.push_str(&format!( + "" + )); + calendar_html.push_str(&format!("", caption)); + calendar_html.push_str(""); + for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] { + calendar_html.push_str(&format!("", wd)); + } + calendar_html.push_str(""); + for week in all_days.chunks(7) { + calendar_html.push_str(""); + for day in week { + #[cfg(test)] + println!("CAL DAY: {} is_event: {}", day, event_days.contains(day)); + let is_event = event_days.contains(day); + let style = if is_event { + "background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;" + } else { + "border:1px solid #eee; text-align:center;background:#f7f7f7;color:#bbb;" + }; + calendar_html.push_str(&format!("", style, day.day())); + } + calendar_html.push_str(""); + } + calendar_html.push_str("
{}
{}
{}
"); + } + let mut summary_html = String::from("
"); if let Some(summary) = summary { - event_summary.push_str(&format!("Summary: {}
", summary)); + summary_html.push_str(&format!("
Summary: {}
", summary)); } - event_summary.push_str(&format!("Start: {}
", local_fmt_start)); - event_summary.push_str(&format!("End: {}
", local_fmt_end)); + if let Some(desc) = description { + summary_html.push_str(&format!("
{}
", desc)); + } + summary_html.push_str(&format!("
Start: {}
", local_fmt_start)); + summary_html.push_str(&format!("
End: {}
", local_fmt_end)); if let Some(cn) = organizer_cn { - event_summary.push_str(&format!("Organizer: {}
", cn)); + summary_html.push_str(&format!("
Organizer: {}
", cn)); } else if let Some(organizer) = organizer { - event_summary.push_str(&format!("Organizer: {}
", organizer)); + summary_html.push_str(&format!("
Organizer: {}
", organizer)); } - summary_parts.push(event_summary); + summary_html.push_str("
"); + // Wrap in a flexbox for layout + let event_block = format!( + "
\ +
{}
\ +
{}
\ +
", + summary_html, calendar_html + ); + summary_parts.push(event_block); } } fn render_month_calendar_widget(date: NaiveDate, event_days: &[NaiveDate]) -> String { let first = date.with_day(1).unwrap(); let last = first.with_day(1).unwrap().succ_opt().unwrap_or(first).pred_opt().unwrap_or(first); - let mut html = String::from(""); + let month_name = first.format("%B").to_string(); + let year = first.year(); + let mut html = String::from( + "
" + ); + html.push_str(&format!("", month_name, year)); + html.push_str(""); for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] { - html.push_str(&format!("", wd)); + html.push_str(&format!("", wd)); } html.push_str(""); let mut cur = first; let mut started = false; for _ in 0..first.weekday().num_days_from_sunday() { - html.push_str(""); + html.push_str(""); } while cur.month() == first.month() { if cur.weekday() == chrono::Weekday::Sun && started { @@ -1394,15 +1491,15 @@ pub fn render_ical_summary(ical_data: &str) -> Result { } started = true; if event_days.contains(&cur) { - html.push_str(&format!("", cur.day())); + html.push_str(&format!("", cur.day())); } else { - html.push_str(&format!("", cur.day())); + html.push_str(&format!("", cur.day())); } cur = match cur.succ_opt() { Some(d) => d, None => break }; } let last_wd = last.weekday().num_days_from_sunday(); for _ in last_wd+1..7 { - html.push_str(""); + html.push_str(""); } html.push_str("
{} {}
{}{}
{}{}{}{}
"); html @@ -1417,6 +1514,13 @@ pub fn render_ical_summary(ical_data: &str) -> Result { LocalResult::Single(dt) => Some(dt), _ => None, } + } else if let Ok(nd) = chrono::NaiveDate::parse_from_str(dt, "%Y%m%d") { + // All-day event: treat as midnight in local time + let ndt = nd.and_hms_opt(0, 0, 0).unwrap(); + match tz.from_local_datetime(&ndt) { + LocalResult::Single(dt) => Some(dt), + _ => None, + } } else { None } @@ -1463,10 +1567,10 @@ mod tests { fn test_ical_render() { let ical = fs::read_to_string("testdata/ical-example-1.ics").unwrap(); let html = render_ical_summary(&ical).unwrap(); - assert!(html.contains("Summary: dentist night guard
")); + assert!(html.contains("Summary: dentist night guard")); assert!(html.contains("Start: ")); assert!(html.contains("End: ")); - assert!(html.contains("Organizer: Bill Thiede
")); + assert!(html.contains("Organizer: Bill Thiede")); } #[test] @@ -1474,9 +1578,58 @@ mod tests { let ical = fs::read_to_string("testdata/ical-example-2.ics").unwrap(); let html = render_ical_summary(&ical).unwrap(); println!("HTML OUTPUT: {}", html); - assert!(html.contains("Summary: [tenative] dinner w/ amatute
")); + assert!(html.contains("Summary: [tenative] dinner w/ amatute")); assert!(html.contains("Start: ")); assert!(html.contains("End: ")); - assert!(html.contains("Organizer: Family
")); + assert!(html.contains("Organizer: Family")); + + } + + #[test] + fn test_ical_multiday() { + let ical = fs::read_to_string("testdata/ical-multiday.ics").unwrap(); + let html = render_ical_summary(&ical).unwrap(); + println!("HTML MULTIDAY: {}", html); + // Print the calendar row containing 28, 29, 30 for debug + if let Some(row_start) = html.find("") { + if let Some(row_end) = html[row_start..].find("") { + println!("CALENDAR ROW: {}", &html[row_start..row_start+row_end+5]); + } + } + assert!(html.contains("Summary: Multi-day Event")); + assert!(html.contains("This event spans multiple days")); + // Should highlight 28, 29, 30 in the merged calendar table (event days), and dim 27, 31 (out-of-event) + for day in [28, 29, 30] { + assert!(html.contains(&format!("{}", day)), "Missing highlighted day {}", day); + } + for day in [27, 31] { + assert!(html.contains(&format!("{}", day)), "Missing dimmed day {}", day); + } + } + + #[test] + fn test_ical_straddle() { + let ical = fs::read_to_string("testdata/ical-straddle.ics").unwrap(); + let html = render_ical_summary(&ical).unwrap(); + println!("HTML STRADDLE: {}", html); + assert!(html.contains("Summary: Straddle Month Event")); + assert!(html.contains("This event straddles two months")); + // Should highlight 30, 31 in August and 1, 2 in September + assert!(html.contains("30")); + assert!(html.contains("31")); + assert!(html.contains("1")); + assert!(html.contains("2")); + } + + #[test] + fn test_ical_straddle_real() { + let ical = fs::read_to_string("testdata/ical-straddle-real.ics").unwrap(); + let html = render_ical_summary(&ical).unwrap(); + println!("HTML STRADDLE REAL: {}", html); + // Should highlight 30, 31 in August and 1 in September (DTEND is exclusive) + assert!(html.contains("30")); + assert!(html.contains("31")); + assert!(html.contains("1")); + assert!(!html.contains("2")); } } diff --git a/server/testdata/ical-multiday.ics b/server/testdata/ical-multiday.ics new file mode 100644 index 0000000..2dedfea --- /dev/null +++ b/server/testdata/ical-multiday.ics @@ -0,0 +1,9 @@ +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Multi-day Event +DTSTART;VALUE=DATE:20250828 +DTEND;VALUE=DATE:20250831 +DESCRIPTION:This event spans multiple days. +END:VEVENT +END:VCALENDAR diff --git a/server/testdata/ical-straddle-real.ics b/server/testdata/ical-straddle-real.ics new file mode 100644 index 0000000..4479ab8 --- /dev/null +++ b/server/testdata/ical-straddle-real.ics @@ -0,0 +1,36 @@ +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:REQUEST +BEGIN:VEVENT +DTSTART;VALUE=DATE:20250830 +DTEND;VALUE=DATE:20250902 +DTSTAMP:20250819T183713Z +ORGANIZER;CN=Bill Thiede:mailto:couchmoney@gmail.com +UID:37kplskaimjnhdnt8r5ui9pv7f@google.com +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP= + TRUE;CN=bill@xinu.tv;X-NUM-GUESTS=0:mailto:bill@xinu.tv +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE + ;CN=Bill Thiede;X-NUM-GUESTS=0:mailto:couchmoney@gmail.com +X-MICROSOFT-CDO-OWNERAPPTID:1427505964 +CREATED:20250819T183709Z +DESCRIPTION: +LAST-MODIFIED:20250819T183709Z +LOCATION: +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Test Straddle Month +TRANSP:TRANSPARENT +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:This is an event reminder +TRIGGER:-P0DT0H30M0S +END:VALARM +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:This is an event reminder +TRIGGER:-P0DT7H30M0S +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/server/testdata/ical-straddle.ics b/server/testdata/ical-straddle.ics new file mode 100644 index 0000000..675fa8b --- /dev/null +++ b/server/testdata/ical-straddle.ics @@ -0,0 +1,9 @@ +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Straddle Month Event +DTSTART;VALUE=DATE:20250830 +DTEND;VALUE=DATE:20250903 +DESCRIPTION:This event straddles two months. +END:VEVENT +END:VCALENDAR