Skip to main content

pidge_core/
event.rs

1//! Normalized calendar event representation, provider-agnostic.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::message::BodyContentType;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Event {
10    pub account: String,
11    pub calendar_id: String,
12    pub id: String,
13    pub subject: String,
14    pub start: EventTime,
15    pub end: EventTime,
16    #[serde(default)]
17    pub all_day: bool,
18    #[serde(default)]
19    pub location: Option<String>,
20    pub organizer: Attendee,
21    #[serde(default)]
22    pub attendees: Vec<Attendee>,
23    #[serde(default)]
24    pub body_preview: String,
25    #[serde(default)]
26    pub body_content: String,
27    #[serde(default)]
28    pub body_content_type: BodyContentType,
29    #[serde(default)]
30    pub recurrence: Option<RecurrencePattern>,
31    #[serde(default)]
32    pub is_organizer: bool,
33    #[serde(default)]
34    pub response_status: ResponseStatus,
35    #[serde(default)]
36    pub online_meeting_url: Option<String>,
37    #[serde(default)]
38    pub series_master_id: Option<String>,
39}
40
41/// A calendar instant. `at` is the canonical UTC time; `tz` is the IANA zone
42/// the event was scheduled in (e.g. `"Europe/Stockholm"`). Display layers
43/// honour `tz` so a Stockholm-scheduled meeting still says "10:00 Stockholm"
44/// when read from a laptop in Sydney.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct EventTime {
47    pub at: DateTime<Utc>,
48    pub tz: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Attendee {
53    pub name: String,
54    pub address: String,
55    #[serde(default)]
56    pub kind: AttendeeKind,
57    #[serde(default)]
58    pub response: ResponseStatus,
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub enum AttendeeKind {
64    #[default]
65    Required,
66    Optional,
67    Resource,
68}
69
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub enum ResponseStatus {
73    #[default]
74    None,
75    Organizer,
76    Accepted,
77    Tentative,
78    Declined,
79    NotResponded,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct RecurrencePattern {
84    pub freq: RecurrenceFreq,
85    /// 1 = every freq, 2 = every other, etc.
86    pub interval: u32,
87    /// Only meaningful when `freq == Weekly`.
88    #[serde(default)]
89    pub by_weekday: Vec<Weekday>,
90    pub range: RecurrenceRange,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub enum RecurrenceFreq {
96    Daily,
97    Weekly,
98    Monthly,
99    Yearly,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub enum Weekday {
105    Monday,
106    Tuesday,
107    Wednesday,
108    Thursday,
109    Friday,
110    Saturday,
111    Sunday,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
116pub enum RecurrenceRange {
117    EndDate(NaiveDate),
118    Count(u32),
119    NoEnd,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct Calendar {
124    pub account: String,
125    pub id: String,
126    pub name: String,
127    #[serde(default)]
128    pub is_default: bool,
129    #[serde(default)]
130    pub color: Option<String>,
131    #[serde(default)]
132    pub can_edit: bool,
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn event_roundtrips_through_json() {
141        let e = Event {
142            account: "u@e.com".into(),
143            calendar_id: "cal-1".into(),
144            id: "evt-1".into(),
145            subject: "Team sync".into(),
146            start: EventTime {
147                at: chrono::DateTime::parse_from_rfc3339("2026-05-22T13:00:00Z")
148                    .unwrap()
149                    .to_utc(),
150                tz: "Europe/Stockholm".into(),
151            },
152            end: EventTime {
153                at: chrono::DateTime::parse_from_rfc3339("2026-05-22T14:00:00Z")
154                    .unwrap()
155                    .to_utc(),
156                tz: "Europe/Stockholm".into(),
157            },
158            all_day: false,
159            location: Some("Office".into()),
160            organizer: Attendee {
161                name: "Kristofer".into(),
162                address: "k@mklab.se".into(),
163                kind: AttendeeKind::Required,
164                response: ResponseStatus::Organizer,
165            },
166            attendees: vec![],
167            body_preview: "Weekly".into(),
168            body_content: "Weekly sync".into(),
169            body_content_type: BodyContentType::Text,
170            recurrence: Some(RecurrencePattern {
171                freq: RecurrenceFreq::Weekly,
172                interval: 1,
173                by_weekday: vec![Weekday::Monday],
174                range: RecurrenceRange::NoEnd,
175            }),
176            is_organizer: true,
177            response_status: ResponseStatus::Organizer,
178            online_meeting_url: None,
179            series_master_id: None,
180        };
181        let json = serde_json::to_string(&e).unwrap();
182        let e2: Event = serde_json::from_str(&json).unwrap();
183        assert_eq!(e, e2);
184    }
185
186    #[test]
187    fn recurrence_range_roundtrips_tagged() {
188        for r in [
189            RecurrenceRange::NoEnd,
190            RecurrenceRange::Count(10),
191            RecurrenceRange::EndDate(NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()),
192        ] {
193            let j = serde_json::to_string(&r).unwrap();
194            let r2: RecurrenceRange = serde_json::from_str(&j).unwrap();
195            assert_eq!(r, r2);
196        }
197    }
198
199    #[test]
200    fn response_status_serializes_camel_case() {
201        assert_eq!(
202            serde_json::to_string(&ResponseStatus::NotResponded).unwrap(),
203            "\"notResponded\""
204        );
205    }
206}