server: improved calendar widget rendering
This commit is contained in:
parent
8acf541d53
commit
00ce9267c1
@ -1300,6 +1300,7 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
for event in calendar.events {
|
for event in calendar.events {
|
||||||
let mut event_summary = String::new();
|
let mut event_summary = String::new();
|
||||||
let mut summary = None;
|
let mut summary = None;
|
||||||
|
let mut description = None;
|
||||||
let mut dtstart = None;
|
let mut dtstart = None;
|
||||||
let mut dtend = None;
|
let mut dtend = None;
|
||||||
let mut organizer = None;
|
let mut organizer = None;
|
||||||
@ -1308,6 +1309,7 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
for prop in &event.properties {
|
for prop in &event.properties {
|
||||||
match prop.name.as_str() {
|
match prop.name.as_str() {
|
||||||
"SUMMARY" => summary = prop.value.as_deref(),
|
"SUMMARY" => summary = prop.value.as_deref(),
|
||||||
|
"DESCRIPTION" => description = prop.value.as_deref(),
|
||||||
"DTSTART" => {
|
"DTSTART" => {
|
||||||
dtstart = prop.value.as_deref();
|
dtstart = prop.value.as_deref();
|
||||||
if let Some(params) = &prop.params {
|
if let Some(params) = &prop.params {
|
||||||
@ -1345,11 +1347,23 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
let fmt_end = local_end.format("%c").to_string();
|
let fmt_end = local_end.format("%c").to_string();
|
||||||
let mut days = vec![];
|
let mut days = vec![];
|
||||||
let mut d = start.date_naive();
|
let mut d = start.date_naive();
|
||||||
let end_d = end.date_naive();
|
let mut end_d = end.date_naive();
|
||||||
while d <= end_d {
|
// Check for all-day event (DATE, not DATE-TIME)
|
||||||
days.push(d);
|
let allday = dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false));
|
||||||
d = d.succ_opt().unwrap();
|
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)
|
(start, end, fmt_start, fmt_end, days)
|
||||||
} else {
|
} else {
|
||||||
let tz = chrono_tz::UTC;
|
let tz = chrono_tz::UTC;
|
||||||
@ -1357,36 +1371,119 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
(fallback, fallback, String::new(), String::new(), vec![])
|
(fallback, fallback, String::new(), String::new(), vec![])
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render calendar widget
|
// Render a single merged calendar table for all spanned months
|
||||||
let calendar_html = render_month_calendar_widget(start_dt.date_naive(), &event_days);
|
let mut calendar_html = String::new();
|
||||||
event_summary.push_str(&calendar_html);
|
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!(
|
||||||
|
"<table class='ical-month' style='border-collapse:collapse; min-width:220px; background:#fff; box-shadow:0 2px 8px #bbb; font-size:14px; margin:0; float:right;'>"
|
||||||
|
));
|
||||||
|
calendar_html.push_str(&format!("<caption style='caption-side:top; text-align:center; font-weight:bold; font-size:16px; padding:8px 0;'>{}</caption>", caption));
|
||||||
|
calendar_html.push_str("<thead><tr>");
|
||||||
|
for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] {
|
||||||
|
calendar_html.push_str(&format!("<th style='padding:4px 6px; border-bottom:1px solid #ccc; color:#666; font-weight:600; background:#f7f7f7'>{}</th>", wd));
|
||||||
|
}
|
||||||
|
calendar_html.push_str("</tr></thead><tbody>");
|
||||||
|
for week in all_days.chunks(7) {
|
||||||
|
calendar_html.push_str("<tr>");
|
||||||
|
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!("<td style='{}'>{}</td>", style, day.day()));
|
||||||
|
}
|
||||||
|
calendar_html.push_str("</tr>");
|
||||||
|
}
|
||||||
|
calendar_html.push_str("</tbody></table>");
|
||||||
|
}
|
||||||
|
let mut summary_html = String::from("<div style='background:#f7f7f7; border-radius:8px; box-shadow:0 2px 8px #bbb; padding:16px 18px; margin:0 0 8px 0; min-width:220px; max-width:400px; font-size:15px; color:#222;'>");
|
||||||
if let Some(summary) = summary {
|
if let Some(summary) = summary {
|
||||||
event_summary.push_str(&format!("<b>Summary:</b> {}<br>", summary));
|
summary_html.push_str(&format!("<div style='font-size:17px; font-weight:bold; margin-bottom:8px; color:#333;'><b>Summary:</b> {}</div>", summary));
|
||||||
}
|
}
|
||||||
event_summary.push_str(&format!("<b>Start:</b> {}<br>", local_fmt_start));
|
if let Some(desc) = description {
|
||||||
event_summary.push_str(&format!("<b>End:</b> {}<br>", local_fmt_end));
|
summary_html.push_str(&format!("<div style='margin-bottom:8px; color:#444;'>{}</div>", desc));
|
||||||
|
}
|
||||||
|
summary_html.push_str(&format!("<div style='margin-bottom:4px;'><b>Start:</b> {}</div>", local_fmt_start));
|
||||||
|
summary_html.push_str(&format!("<div style='margin-bottom:4px;'><b>End:</b> {}</div>", local_fmt_end));
|
||||||
if let Some(cn) = organizer_cn {
|
if let Some(cn) = organizer_cn {
|
||||||
event_summary.push_str(&format!("<b>Organizer:</b> {}<br>", cn));
|
summary_html.push_str(&format!("<div style='margin-bottom:4px;'><b>Organizer:</b> {}</div>", cn));
|
||||||
} else if let Some(organizer) = organizer {
|
} else if let Some(organizer) = organizer {
|
||||||
event_summary.push_str(&format!("<b>Organizer:</b> {}<br>", organizer));
|
summary_html.push_str(&format!("<div style='margin-bottom:4px;'><b>Organizer:</b> {}</div>", organizer));
|
||||||
}
|
}
|
||||||
summary_parts.push(event_summary);
|
summary_html.push_str("</div>");
|
||||||
|
// Wrap in a flexbox for layout
|
||||||
|
let event_block = format!(
|
||||||
|
"<div style=\"display: flex; align-items: flex-start; gap: 1.5em;\">\
|
||||||
|
<div style=\"flex: 1;\">{}</div>\
|
||||||
|
<div style=\"flex: none;\">{}</div>\
|
||||||
|
</div>",
|
||||||
|
summary_html, calendar_html
|
||||||
|
);
|
||||||
|
summary_parts.push(event_block);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_month_calendar_widget(date: NaiveDate, event_days: &[NaiveDate]) -> String {
|
fn render_month_calendar_widget(date: NaiveDate, event_days: &[NaiveDate]) -> String {
|
||||||
let first = date.with_day(1).unwrap();
|
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 last = first.with_day(1).unwrap().succ_opt().unwrap_or(first).pred_opt().unwrap_or(first);
|
||||||
let mut html = String::from("<table class='ical-month'><thead><tr>");
|
let month_name = first.format("%B").to_string();
|
||||||
|
let year = first.year();
|
||||||
|
let mut html = String::from(
|
||||||
|
"<table class='ical-month' style='border-collapse:collapse; min-width:220px; background:#fff; box-shadow:0 2px 8px #bbb; font-size:14px; margin:0; float:right;'>"
|
||||||
|
);
|
||||||
|
html.push_str(&format!("<caption style='caption-side:top; text-align:center; font-weight:bold; font-size:16px; padding:8px 0;'>{} {}</caption>", month_name, year));
|
||||||
|
html.push_str("<thead><tr>");
|
||||||
for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] {
|
for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] {
|
||||||
html.push_str(&format!("<th>{}</th>", wd));
|
html.push_str(&format!("<th style='padding:4px 6px; border-bottom:1px solid #ccc; color:#666; font-weight:600; background:#f7f7f7'>{}</th>", wd));
|
||||||
}
|
}
|
||||||
html.push_str("</tr></thead><tbody><tr>");
|
html.push_str("</tr></thead><tbody><tr>");
|
||||||
let mut cur = first;
|
let mut cur = first;
|
||||||
let mut started = false;
|
let mut started = false;
|
||||||
for _ in 0..first.weekday().num_days_from_sunday() {
|
for _ in 0..first.weekday().num_days_from_sunday() {
|
||||||
html.push_str("<td></td>");
|
html.push_str("<td style='background:#f7f7f7'></td>");
|
||||||
}
|
}
|
||||||
while cur.month() == first.month() {
|
while cur.month() == first.month() {
|
||||||
if cur.weekday() == chrono::Weekday::Sun && started {
|
if cur.weekday() == chrono::Weekday::Sun && started {
|
||||||
@ -1394,15 +1491,15 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
}
|
}
|
||||||
started = true;
|
started = true;
|
||||||
if event_days.contains(&cur) {
|
if event_days.contains(&cur) {
|
||||||
html.push_str(&format!("<td class='ical-event-day' style='background: #ffd700; font-weight: bold'>{}</td>", cur.day()));
|
html.push_str(&format!("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>{}</td>", cur.day()));
|
||||||
} else {
|
} else {
|
||||||
html.push_str(&format!("<td>{}</td>", cur.day()));
|
html.push_str(&format!("<td style='border:1px solid #eee; text-align:center;'>{}</td>", cur.day()));
|
||||||
}
|
}
|
||||||
cur = match cur.succ_opt() { Some(d) => d, None => break };
|
cur = match cur.succ_opt() { Some(d) => d, None => break };
|
||||||
}
|
}
|
||||||
let last_wd = last.weekday().num_days_from_sunday();
|
let last_wd = last.weekday().num_days_from_sunday();
|
||||||
for _ in last_wd+1..7 {
|
for _ in last_wd+1..7 {
|
||||||
html.push_str("<td></td>");
|
html.push_str("<td style='background:#f7f7f7'></td>");
|
||||||
}
|
}
|
||||||
html.push_str("</tr></tbody></table>");
|
html.push_str("</tr></tbody></table>");
|
||||||
html
|
html
|
||||||
@ -1417,6 +1514,13 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
LocalResult::Single(dt) => Some(dt),
|
LocalResult::Single(dt) => Some(dt),
|
||||||
_ => None,
|
_ => 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 {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@ -1463,10 +1567,10 @@ mod tests {
|
|||||||
fn test_ical_render() {
|
fn test_ical_render() {
|
||||||
let ical = fs::read_to_string("testdata/ical-example-1.ics").unwrap();
|
let ical = fs::read_to_string("testdata/ical-example-1.ics").unwrap();
|
||||||
let html = render_ical_summary(&ical).unwrap();
|
let html = render_ical_summary(&ical).unwrap();
|
||||||
assert!(html.contains("<b>Summary:</b> dentist night guard<br>"));
|
assert!(html.contains("<b>Summary:</b> dentist night guard"));
|
||||||
assert!(html.contains("<b>Start:</b> "));
|
assert!(html.contains("<b>Start:</b> "));
|
||||||
assert!(html.contains("<b>End:</b> "));
|
assert!(html.contains("<b>End:</b> "));
|
||||||
assert!(html.contains("<b>Organizer:</b> Bill Thiede<br>"));
|
assert!(html.contains("<b>Organizer:</b> Bill Thiede"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1474,9 +1578,58 @@ mod tests {
|
|||||||
let ical = fs::read_to_string("testdata/ical-example-2.ics").unwrap();
|
let ical = fs::read_to_string("testdata/ical-example-2.ics").unwrap();
|
||||||
let html = render_ical_summary(&ical).unwrap();
|
let html = render_ical_summary(&ical).unwrap();
|
||||||
println!("HTML OUTPUT: {}", html);
|
println!("HTML OUTPUT: {}", html);
|
||||||
assert!(html.contains("<b>Summary:</b> [tenative] dinner w/ amatute<br>"));
|
assert!(html.contains("<b>Summary:</b> [tenative] dinner w/ amatute"));
|
||||||
assert!(html.contains("<b>Start:</b> "));
|
assert!(html.contains("<b>Start:</b> "));
|
||||||
assert!(html.contains("<b>End:</b> "));
|
assert!(html.contains("<b>End:</b> "));
|
||||||
assert!(html.contains("<b>Organizer:</b> Family<br>"));
|
assert!(html.contains("<b>Organizer:</b> 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("<tr>") {
|
||||||
|
if let Some(row_end) = html[row_start..].find("</tr>") {
|
||||||
|
println!("CALENDAR ROW: {}", &html[row_start..row_start+row_end+5]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(html.contains("<b>Summary:</b> 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!("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>{}</td>", day)), "Missing highlighted day {}", day);
|
||||||
|
}
|
||||||
|
for day in [27, 31] {
|
||||||
|
assert!(html.contains(&format!("<td style='border:1px solid #eee; text-align:center;background:#f7f7f7;color:#bbb;'>{}</td>", 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("<b>Summary:</b> 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("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>30</td>"));
|
||||||
|
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>31</td>"));
|
||||||
|
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>1</td>"));
|
||||||
|
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>2</td>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>30</td>"));
|
||||||
|
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>31</td>"));
|
||||||
|
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>1</td>"));
|
||||||
|
assert!(!html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>2</td>"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
server/testdata/ical-multiday.ics
vendored
Normal file
9
server/testdata/ical-multiday.ics
vendored
Normal file
@ -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
|
||||||
36
server/testdata/ical-straddle-real.ics
vendored
Normal file
36
server/testdata/ical-straddle-real.ics
vendored
Normal file
@ -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
|
||||||
9
server/testdata/ical-straddle.ics
vendored
Normal file
9
server/testdata/ical-straddle.ics
vendored
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user