Skip to main content

kernoid_client/
calendars.rs

1// SPDX-License-Identifier: MIT
2//! Calendars over JMAP (draft-ietf-jmap-calendars): the calendars of every account the user reaches, the
3//! instances of their events in a window of time, as JSCalendar; events made, changed and removed, and invitations
4//! answered – with `sendSchedulingMessages`, so participants hear of it (iTIP).
5
6use std::collections::BTreeMap;
7
8use chrono::{DateTime, NaiveDateTime, SecondsFormat, Utc};
9use serde::Deserialize;
10use serde_json::{Value, json};
11
12use crate::{CALENDARS, Client, Error, Result, ids_of};
13
14#[derive(Debug, Clone, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct Calendar {
17    pub id: String,
18    pub name: String,
19    #[serde(default)]
20    pub color: Option<String>,
21    #[serde(default)]
22    pub is_visible: Option<bool>,
23    #[serde(default)]
24    pub my_rights: Option<CalendarRights>,
25    /// The account it belongs to (not from the server).
26    #[serde(skip)]
27    pub account: String,
28    /// The name of the account, for calendars shared with the user (not from the server).
29    #[serde(skip)]
30    pub shared: Option<String>,
31}
32
33#[derive(Debug, Clone, Copy, Default, Deserialize)]
34#[serde(rename_all = "camelCase", default)]
35pub struct CalendarRights {
36    pub may_write_all: bool,
37    #[serde(rename = "mayRSVP")]
38    pub may_rsvp: bool,
39}
40
41impl Calendar {
42    /// Whether events may be made in it; without rights given, yes.
43    pub fn writable(&self) -> bool {
44        self.my_rights.is_none_or(|rights| rights.may_write_all)
45    }
46}
47
48#[derive(Debug, Clone, Default, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct Participant {
51    #[serde(default)]
52    pub name: Option<String>,
53    #[serde(default)]
54    pub calendar_address: Option<String>,
55    #[serde(default)]
56    pub participation_status: Option<String>,
57}
58
59#[derive(Debug, Clone, Default, Deserialize)]
60pub struct Location {
61    #[serde(default)]
62    pub name: Option<String>,
63}
64
65/// An event, or one instance of a recurring one.
66#[derive(Debug, Clone, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct Event {
69    pub id: String,
70    /// For an instance of a recurring event: the event it is of – changes go there.
71    #[serde(default)]
72    pub base_event_id: Option<String>,
73    /// For an instance: which one of its event's it is – where it would start but for an override.
74    #[serde(default)]
75    pub recurrence_id: Option<String>,
76    /// For a recurring event: its instances changed or left out, by recurrence id (RFC 8984, 4.3.5).
77    #[serde(default)]
78    pub recurrence_overrides: Option<serde_json::Map<String, Value>>,
79    #[serde(default)]
80    pub organizer_calendar_address: Option<String>,
81    /// `confirmed`, `tentative` or `cancelled` – an invitation the organizer called off stays, cancelled.
82    #[serde(default)]
83    pub status: Option<String>,
84    #[serde(default)]
85    pub calendar_ids: BTreeMap<String, bool>,
86    #[serde(default)]
87    pub title: Option<String>,
88    #[serde(default)]
89    pub description: Option<String>,
90    #[serde(default)]
91    pub start: Option<String>,
92    #[serde(default)]
93    pub time_zone: Option<String>,
94    #[serde(default)]
95    pub duration: Option<String>,
96    #[serde(default)]
97    pub show_without_time: bool,
98    #[serde(default)]
99    pub utc_start: Option<String>,
100    #[serde(default)]
101    pub utc_end: Option<String>,
102    #[serde(default)]
103    pub recurrence_rule: Option<Value>,
104    #[serde(default)]
105    pub locations: Option<BTreeMap<String, Location>>,
106    #[serde(default)]
107    pub participants: Option<BTreeMap<String, Participant>>,
108    #[serde(skip)]
109    pub account: String,
110}
111
112impl Event {
113    /// When it starts, in UTC: `utcStart`, or the start of an all-day event.
114    pub fn starts(&self) -> Option<DateTime<Utc>> {
115        if let Some(start) = self.utc_start.as_deref().and_then(|s| DateTime::parse_from_rfc3339(s).ok()) {
116            return Some(start.with_timezone(&Utc));
117        }
118        let start = self.start.as_deref()?;
119        chrono::NaiveDateTime::parse_from_str(start, "%Y-%m-%dT%H:%M:%S").ok().map(|naive| naive.and_utc())
120    }
121
122    pub fn ends(&self) -> Option<DateTime<Utc>> {
123        self.utc_end.as_deref().and_then(|s| DateTime::parse_from_rfc3339(s).ok()).map(|end| end.with_timezone(&Utc))
124    }
125
126    pub fn is_cancelled(&self) -> bool {
127        self.status.as_deref() == Some("cancelled")
128    }
129
130    /// The id changes go to: the event an instance is of, or the event itself.
131    pub fn event_id(&self) -> &str {
132        self.base_event_id.as_deref().unwrap_or(&self.id)
133    }
134
135    /// The participant that is the user – by one of their addresses –, with its key.
136    pub fn me<'a>(&'a self, addresses: &[String]) -> Option<(&'a str, &'a Participant)> {
137        self.participants.as_ref()?.iter().map(|(key, p)| (key.as_str(), p)).find(|(_, participant)| {
138            let address = participant.calendar_address.as_deref().unwrap_or_default();
139            let address = address.strip_prefix("mailto:").unwrap_or(address);
140            addresses.iter().any(|own| own.eq_ignore_ascii_case(address))
141        })
142    }
143
144    /// Whether the user organizes it (or nobody does: an event without participants).
145    pub fn organized_by(&self, addresses: &[String]) -> bool {
146        match self.organizer_calendar_address.as_deref() {
147            None => true,
148            Some(organizer) => {
149                let organizer = organizer.strip_prefix("mailto:").unwrap_or(organizer);
150                addresses.iter().any(|own| own.eq_ignore_ascii_case(organizer))
151            }
152        }
153    }
154
155    pub fn location(&self) -> Option<String> {
156        let names: Vec<_> = self.locations.as_ref()?.values().filter_map(|location| location.name.clone()).collect();
157        (!names.is_empty()).then(|| names.join(", "))
158    }
159}
160
161#[derive(Deserialize)]
162struct List<T> {
163    list: Vec<T>,
164}
165
166fn list<T: serde::de::DeserializeOwned>(value: Option<&Value>) -> Result<Vec<T>> {
167    let value = value.cloned().unwrap_or(json!({ "list": [] }));
168    serde_json::from_value::<List<T>>(value).map(|list| list.list).map_err(|error| Error::Protocol(error.to_string()))
169}
170
171/// The user's calendars and those shared with them.
172pub async fn calendars(client: &Client) -> Result<Vec<Calendar>> {
173    let mut out = Vec::new();
174    for (id, account) in client.session.accounts_with(CALENDARS) {
175        let result = client.call(&[CALENDARS], "Calendar/get", json!({ "accountId": id })).await?;
176        for mut calendar in list::<Calendar>(Some(&result))? {
177            calendar.account = id.to_owned();
178            calendar.shared = (!account.is_personal).then(|| account.name.clone());
179            out.push(calendar);
180        }
181    }
182    Ok(out)
183}
184
185fn utc(time: DateTime<Utc>) -> String {
186    time.to_rfc3339_opts(SecondsFormat::Secs, true)
187}
188
189/// One event as stored – for an instance, the recurring event it is of.
190pub async fn event(client: &Client, account: &str, id: &str) -> Result<Option<Event>> {
191    let result = client.call(&[CALENDARS], "CalendarEvent/get", json!({ "accountId": account, "ids": [id] })).await?;
192    Ok(list::<Event>(Some(&result))?.into_iter().next().map(|mut event| {
193        event.account = account.to_owned();
194        event
195    }))
196}
197
198/// The instances of the events of `calendars` between `after` and `before`, by start.
199pub async fn instances(
200    client: &Client,
201    calendars: &[Calendar],
202    after: DateTime<Utc>,
203    before: DateTime<Utc>,
204) -> Result<Vec<Event>> {
205    let mut accounts: Vec<&str> = calendars.iter().map(|calendar| calendar.account.as_str()).collect();
206    accounts.dedup();
207    let mut out = Vec::new();
208    for account in accounts {
209        let visible: Vec<&str> = calendars
210            .iter()
211            .filter(|calendar| calendar.account == account && calendar.is_visible.unwrap_or(true))
212            .map(|calendar| calendar.id.as_str())
213            .collect();
214        let responses = client
215            .request(
216                &[CALENDARS],
217                vec![
218                    (
219                        "CalendarEvent/query",
220                        json!({
221                            "accountId": account, "expandRecurrences": true,
222                            "filter": { "after": utc(after), "before": utc(before) },
223                        }),
224                        "q",
225                    ),
226                    (
227                        "CalendarEvent/get",
228                        json!({ "accountId": account, "#ids": ids_of("q", "CalendarEvent/query") }),
229                        "g",
230                    ),
231                ],
232            )
233            .await?;
234        for mut event in list::<Event>(responses.get("g"))? {
235            if event.calendar_ids.keys().any(|id| visible.contains(&id.as_str())) {
236                event.account = account.to_owned();
237                out.push(event);
238            }
239        }
240    }
241    out.sort_by_key(Event::starts);
242    Ok(out)
243}
244
245/// An ISO 8601 duration as JSCalendar writes it (`PT1H30M`, `P1D`, `P2W`, `P1DT2H`), in seconds.
246pub fn parse_duration(text: &str) -> Option<i64> {
247    let text = text.strip_prefix('P')?;
248    let (date, time) = text.split_once('T').unwrap_or((text, ""));
249    let mut seconds = 0i64;
250    let mut take = |part: &str, units: &[(char, i64)]| -> Option<()> {
251        let mut number = String::new();
252        for c in part.chars() {
253            if c.is_ascii_digit() {
254                number.push(c);
255            } else {
256                let factor = units.iter().find(|(unit, _)| *unit == c)?.1;
257                seconds += number.parse::<i64>().ok()? * factor;
258                number.clear();
259            }
260        }
261        number.is_empty().then_some(())
262    };
263    take(date, &[('W', 7 * 86_400), ('D', 86_400)])?;
264    take(time, &[('H', 3_600), ('M', 60), ('S', 1)])?;
265    Some(seconds)
266}
267
268/// Seconds as a JSCalendar duration: `PT1H30M`, `P1D`, `PT0M`.
269pub fn format_duration(seconds: i64) -> String {
270    let seconds = seconds.max(0);
271    let (days, rest) = (seconds / 86_400, seconds % 86_400);
272    let (hours, minutes) = (rest / 3_600, rest % 3_600 / 60);
273    let mut out = String::from("P");
274    if days > 0 {
275        out.push_str(&format!("{days}D"));
276    }
277    if hours > 0 || minutes > 0 || days == 0 {
278        out.push('T');
279        if hours > 0 {
280            out.push_str(&format!("{hours}H"));
281        }
282        if minutes > 0 || hours == 0 {
283            out.push_str(&format!("{minutes}M"));
284        }
285    }
286    out
287}
288
289/// How often an event repeats, as kernoid's apps offer it.
290pub const FREQUENCIES: [&str; 4] = ["daily", "weekly", "monthly", "yearly"];
291
292/// An event as the user writes it.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct EventForm {
295    pub title: String,
296    pub all_day: bool,
297    /// Local times in `time_zone`; for all-day events the days (the end is the last day).
298    pub start: NaiveDateTime,
299    pub end: NaiveDateTime,
300    pub time_zone: Option<String>,
301    pub location: String,
302    pub description: String,
303    /// Addresses of the people invited, besides the user.
304    pub participants: Vec<String>,
305    /// `daily`, `weekly`, `monthly`, `yearly` or none.
306    pub repeat: Option<String>,
307    /// For an event changed: the answers participants gave (address, status) and when it was – the answers stay
308    /// while the time does (RFC 5546, 2.1.4: a new time asks again).
309    pub answers: Vec<(String, String)>,
310    pub was: Option<(NaiveDateTime, NaiveDateTime, bool)>,
311}
312
313impl EventForm {
314    /// The form of an event, to change it.
315    pub fn of(event: &Event, own: &[String]) -> Option<Self> {
316        let start = NaiveDateTime::parse_from_str(event.start.as_deref()?, "%Y-%m-%dT%H:%M:%S").ok()?;
317        let seconds = event.duration.as_deref().and_then(parse_duration).unwrap_or(if event.show_without_time {
318            86_400
319        } else {
320            3_600
321        });
322        let mut end = start + chrono::Duration::seconds(seconds);
323        if event.show_without_time {
324            end -= chrono::Duration::days(1);
325        }
326        let participants = event
327            .participants
328            .iter()
329            .flat_map(|map| map.values())
330            .filter_map(|p| p.calendar_address.as_deref())
331            .map(|address| address.strip_prefix("mailto:").unwrap_or(address).to_owned())
332            .filter(|address| !own.iter().any(|o| o.eq_ignore_ascii_case(address)))
333            .collect();
334        let answers = event
335            .participants
336            .iter()
337            .flat_map(|map| map.values())
338            .filter_map(|p| {
339                let address = p.calendar_address.as_deref()?;
340                let address = address.strip_prefix("mailto:").unwrap_or(address).to_lowercase();
341                Some((address, p.participation_status.clone()?))
342            })
343            .collect();
344        let repeat = event
345            .recurrence_rule
346            .as_ref()
347            .and_then(|rule| rule.get("frequency"))
348            .and_then(Value::as_str)
349            .map(str::to_owned);
350        Some(Self {
351            title: event.title.clone().unwrap_or_default(),
352            all_day: event.show_without_time,
353            start,
354            end,
355            time_zone: event.time_zone.clone(),
356            location: event.location().unwrap_or_default(),
357            description: event.description.clone().unwrap_or_default(),
358            participants,
359            repeat,
360            answers,
361            was: Some((start, end, event.show_without_time)),
362        })
363    }
364
365    /// The JSCalendar object (without `calendarIds`); `own` is the organizer's address.
366    pub fn object(&self, own: &str) -> Value {
367        let (start, seconds) = if self.all_day {
368            let days = (self.end.date() - self.start.date()).num_days().max(0) + 1;
369            (self.start.date().and_hms_opt(0, 0, 0).unwrap_or(self.start), days * 86_400)
370        } else {
371            (self.start, (self.end - self.start).num_seconds())
372        };
373        let mut value = json!({
374            "@type": "Event",
375            "title": self.title,
376            "start": start.format("%Y-%m-%dT%H:%M:%S").to_string(),
377            "duration": format_duration(seconds),
378            "showWithoutTime": self.all_day,
379            "timeZone": if self.all_day { Value::Null } else { json!(self.time_zone) },
380            "description": if self.description.is_empty() { Value::Null } else { json!(self.description) },
381            "locations": if self.location.is_empty() { Value::Null } else {
382                json!({ "l1": { "@type": "Location", "name": self.location } })
383            },
384            "recurrenceRule": match &self.repeat {
385                Some(frequency) => json!({ "@type": "RecurrenceRule", "frequency": frequency }),
386                None => Value::Null,
387            },
388            "participants": Value::Null,
389            "organizerCalendarAddress": Value::Null,
390        });
391        let others: Vec<String> = self
392            .participants
393            .iter()
394            .map(|address| address.trim().to_lowercase())
395            .filter(|address| !address.is_empty() && !address.eq_ignore_ascii_case(own))
396            .collect();
397        if !others.is_empty() {
398            let mut participants = serde_json::Map::new();
399            participants.insert(
400                "p0".into(),
401                json!({ "calendarAddress": format!("mailto:{own}"), "roles": { "owner": true, "attendee": true },
402                        "participationStatus": "accepted" }),
403            );
404            let same_time = self.was == Some((self.start, self.end, self.all_day));
405            for (index, address) in others.iter().enumerate() {
406                let status = self
407                    .answers
408                    .iter()
409                    .find(|(known, _)| same_time && known == address)
410                    .map_or("needs-action", |(_, status)| status.as_str());
411                participants.insert(
412                    format!("p{}", index + 1),
413                    json!({ "calendarAddress": format!("mailto:{address}"), "roles": { "attendee": true },
414                            "participationStatus": status, "expectReply": true }),
415                );
416            }
417            value["participants"] = Value::Object(participants);
418            value["organizerCalendarAddress"] = json!(format!("mailto:{own}"));
419        }
420        value
421    }
422}
423
424const SCHEDULE: &str = "sendSchedulingMessages";
425
426/// Makes an event in a calendar; its id.
427pub async fn create(client: &Client, calendar: &Calendar, form: &EventForm, own: &str) -> Result<String> {
428    let mut object = form.object(own);
429    object["calendarIds"] = json!({ calendar.id.as_str(): true });
430    let result = client
431        .set(
432            &[CALENDARS],
433            "CalendarEvent",
434            &calendar.account,
435            json!({ "create": { "e": object } }),
436            json!({ SCHEDULE: true }),
437        )
438        .await?;
439    result
440        .pointer("/created/e/id")
441        .and_then(Value::as_str)
442        .map(str::to_owned)
443        .ok_or_else(|| Error::Protocol("the event has no id".into()))
444}
445
446/// Changes an event (all instances of a recurring one), and moves it to `calendar`.
447pub async fn update(client: &Client, calendar: &Calendar, id: &str, form: &EventForm, own: &str) -> Result<()> {
448    let mut object = form.object(own);
449    object["calendarIds"] = json!({ calendar.id.as_str(): true });
450    let change = json!({ "update": { id: object } });
451    client.set(&[CALENDARS], "CalendarEvent", &calendar.account, change, json!({ SCHEDULE: true })).await?;
452    Ok(())
453}
454
455/// An event as stored – every property –, for writing it to a file.
456pub async fn raw_event(client: &Client, account: &str, id: &str) -> Result<serde_json::Map<String, Value>> {
457    let result = client.call(&[CALENDARS], "CalendarEvent/get", json!({ "accountId": account, "ids": [id] })).await?;
458    match result.pointer("/list/0") {
459        Some(Value::Object(event)) => Ok(event.clone()),
460        _ => Err(Error::Protocol("the event is gone".into())),
461    }
462}
463
464/// Makes the events read from a file in `calendar`, sending no invitations – importing is not inviting; how many
465/// were made, and why the others were not.
466pub async fn import(
467    client: &Client,
468    calendar: &Calendar,
469    events: Vec<serde_json::Map<String, Value>>,
470) -> Result<(usize, Vec<String>)> {
471    let create: serde_json::Map<String, Value> = events
472        .into_iter()
473        .enumerate()
474        .map(|(n, mut event)| {
475            event.insert("calendarIds".into(), json!({ calendar.id.as_str(): true }));
476            (format!("i{n}"), Value::Object(event))
477        })
478        .collect();
479    let result = client
480        .call(
481            &[CALENDARS],
482            "CalendarEvent/set",
483            json!({ "accountId": calendar.account, "create": create, SCHEDULE: false }),
484        )
485        .await?;
486    Ok(made_and_refused(&result))
487}
488
489/// How many a `*/set` made, and the descriptions of what it did not.
490pub(crate) fn made_and_refused(result: &Value) -> (usize, Vec<String>) {
491    let made = result.get("created").and_then(Value::as_object).map_or(0, serde_json::Map::len);
492    let refused = result
493        .get("notCreated")
494        .and_then(Value::as_object)
495        .map(|failed| {
496            failed
497                .values()
498                .map(|error| {
499                    let kind = error["type"].as_str().unwrap_or("refused");
500                    error["description"].as_str().map_or_else(|| kind.to_owned(), |text| format!("{kind}: {text}"))
501                })
502                .collect()
503        })
504        .unwrap_or_default();
505    (made, refused)
506}
507
508/// What an instance of a series may differ in from its series.
509const OVERRIDDEN: [&str; 6] = ["title", "start", "duration", "showWithoutTime", "description", "locations"];
510
511/// Changes one instance of `series`: its override in the event's `recurrenceOverrides`.
512pub async fn update_instance(
513    client: &Client,
514    series: &Event,
515    recurrence_id: &str,
516    form: &EventForm,
517    own: &str,
518) -> Result<()> {
519    let whole = form.object(own);
520    let mut patch: serde_json::Map<String, Value> =
521        OVERRIDDEN.iter().map(|key| ((*key).to_owned(), whole[*key].clone())).collect();
522    if whole["timeZone"] != json!(series.time_zone) {
523        patch.insert("timeZone".into(), whole["timeZone"].clone());
524    }
525    override_instance(client, series, recurrence_id, Value::Object(patch)).await
526}
527
528/// Leaves one instance of `series` out.
529pub async fn destroy_instance(client: &Client, series: &Event, recurrence_id: &str) -> Result<()> {
530    override_instance(client, series, recurrence_id, json!({ "excluded": true })).await
531}
532
533async fn override_instance(client: &Client, series: &Event, recurrence_id: &str, patch: Value) -> Result<()> {
534    let mut overrides = series.recurrence_overrides.clone().unwrap_or_default();
535    overrides.insert(recurrence_id.to_owned(), patch);
536    let change = json!({ "update": { series.id.as_str(): { "recurrenceOverrides": overrides } } });
537    client.set(&[CALENDARS], "CalendarEvent", &series.account, change, json!({ SCHEDULE: true })).await?;
538    Ok(())
539}
540
541/// Removes an event (all instances of a recurring one).
542pub async fn destroy(client: &Client, account: &str, id: &str) -> Result<()> {
543    client.set(&[CALENDARS], "CalendarEvent", account, json!({ "destroy": [id] }), json!({ SCHEDULE: true })).await?;
544    Ok(())
545}
546
547/// Answers an invitation: the participant `key` (the user) gets `status` – `accepted`, `tentative` or `declined`.
548pub async fn answer(client: &Client, account: &str, id: &str, key: &str, status: &str) -> Result<()> {
549    let path = format!("participants/{key}/participationStatus");
550    let change = json!({ "update": { id: { path: status } } });
551    client.set(&[CALENDARS], "CalendarEvent", account, change, json!({ SCHEDULE: true })).await?;
552    Ok(())
553}
554
555/// The time zone of this machine (IANA name), for the events the user makes.
556pub fn local_time_zone() -> Option<String> {
557    iana_time_zone::get_timezone().ok()
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn durations() {
566        assert_eq!(parse_duration("PT1H30M"), Some(5_400));
567        assert_eq!(parse_duration("P1D"), Some(86_400));
568        assert_eq!(parse_duration("P1W"), Some(604_800));
569        assert_eq!(parse_duration("P1DT2H"), Some(93_600));
570        assert_eq!(parse_duration("1H"), None);
571        assert_eq!(parse_duration("PT1X"), None);
572        assert_eq!(format_duration(5_400), "PT1H30M");
573        assert_eq!(format_duration(3_600), "PT1H");
574        assert_eq!(format_duration(172_800), "P2D");
575        assert_eq!(format_duration(0), "PT0M");
576    }
577
578    #[test]
579    fn forms() {
580        let at = |s: &str| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M").unwrap();
581        let form = EventForm {
582            title: "Plans".into(),
583            all_day: false,
584            start: at("2026-10-29 10:00"),
585            end: at("2026-10-29 11:30"),
586            time_zone: Some("Europe/Berlin".into()),
587            location: "Room 1".into(),
588            description: String::new(),
589            participants: vec!["Bob@x.example".into(), "me@x.example".into()],
590            repeat: Some("weekly".into()),
591            answers: vec![("bob@x.example".into(), "accepted".into())],
592            was: Some((at("2026-10-29 10:00"), at("2026-10-29 11:30"), false)),
593        };
594        // The time stays: Bob's answer too; a new time asks again.
595        assert_eq!(form.object("me@x.example")["participants"]["p1"]["participationStatus"], "accepted");
596        let moved = EventForm { start: at("2026-10-29 10:30"), ..form.clone() };
597        assert_eq!(moved.object("me@x.example")["participants"]["p1"]["participationStatus"], "needs-action");
598        let object = form.object("me@x.example");
599        assert_eq!(object["start"], "2026-10-29T10:00:00");
600        assert_eq!(object["duration"], "PT1H30M");
601        assert_eq!(object["timeZone"], "Europe/Berlin");
602        assert_eq!(object["recurrenceRule"]["frequency"], "weekly");
603        assert_eq!(object["participants"]["p1"]["calendarAddress"], "mailto:bob@x.example");
604        assert!(object["participants"].get("p2").is_none());
605        assert_eq!(object["organizerCalendarAddress"], "mailto:me@x.example");
606        let all_day = EventForm { all_day: true, end: at("2026-10-30 00:00"), participants: vec![], ..form };
607        let object = all_day.object("me@x.example");
608        assert_eq!((object["duration"].as_str(), object["timeZone"].clone()), (Some("P2D"), Value::Null));
609        assert_eq!(object["participants"], Value::Null);
610    }
611}