aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMikkel Thestrup <mikkel_thestrup@mithe.dk>2025-12-09 14:52:58 +0100
committerMikkel Thestrup <mikkel_thestrup@mithe.dk>2025-12-09 17:17:56 +0100
commit147125358b66c2bf097ed11f82042e220a730090 (patch)
tree2b6957d01a1afb0553996e916faea5c646f5cc6e
parent4b1074193991a510fd2129513d5fcb7c6da933d2 (diff)
downloadkal-master.tar.gz
kal-master.zip
feat(domain): Add initial domain models for calendar, event, and recurrenceHEADmaster
- Added `calendar.rs` with Calendar entity and builder - Added `event.rs` with Event model and builder - Added `recurrence.rs` for recurrence rules - Added `mod.rs` to expose domain module structure These files establish the core domain layer structures for future business logic.
-rw-r--r--src/domain/calendar.rs108
-rw-r--r--src/domain/event.rs310
-rw-r--r--src/domain/mod.rs6
-rw-r--r--src/domain/recurrence.rs36
4 files changed, 460 insertions, 0 deletions
diff --git a/src/domain/calendar.rs b/src/domain/calendar.rs
new file mode 100644
index 0000000..9e6a7fe
--- /dev/null
+++ b/src/domain/calendar.rs
@@ -0,0 +1,108 @@
+use std::collections::BTreeMap;
+
+use chrono::{DateTime, NaiveDate, Utc};
+use getset::Getters;
+use uuid::Uuid;
+
+use crate::domain::Event;
+
+#[derive(Clone, Debug, Getters)]
+#[getset(get = "pub")]
+pub struct Calendar {
+ id: Uuid,
+ name: String,
+ description: Option<String>,
+ events: BTreeMap<NaiveDate, Vec<Event>>,
+ is_archived: bool,
+ created_at: DateTime<Utc>,
+ updated_at: DateTime<Utc>,
+}
+
+pub struct CalendarBuilder {
+ name: String,
+ description: Option<String>,
+ events: BTreeMap<NaiveDate, Vec<Event>>,
+ is_archived: bool,
+}
+
+impl CalendarBuilder {
+ pub fn new(name: String) -> Result<Self, String> {
+ if name.is_empty() {
+ Err("Name cannot be empty".to_string())
+ } else {
+ Ok(Self {
+ name,
+ description: None,
+ events: BTreeMap::new(),
+ is_archived: false
+ })
+ }
+ }
+
+ pub fn description(mut self, description: Option<String>) -> Self {
+ self.description = description;
+ self
+ }
+
+ pub fn events(mut self, events: BTreeMap<NaiveDate, Vec<Event>>) -> Self {
+ self.events = events;
+ self
+ }
+
+ pub fn is_archived(mut self, is_archived: bool) -> Self {
+ self.is_archived = is_archived;
+ self
+ }
+
+ pub fn build(self) -> Calendar {
+ let now = Utc::now();
+ Calendar {
+ id: Uuid::new_v4(),
+ name: self.name,
+ description: self.description,
+ events: self.events,
+ is_archived: self.is_archived,
+ created_at: now,
+ updated_at: now
+ }
+ }
+}
+
+impl Calendar {
+ pub fn add_events(&mut self, events: Vec<Event>) {
+ for event in events {
+ let date = event.start().date_naive();
+
+ self.events
+ .entry(date)
+ .or_insert_with(Vec::new)
+ .push(event);
+ }
+
+ self.touch()
+ }
+
+ pub fn update_name(&mut self, name: String) -> Result<(), String> {
+ if name.is_empty() {
+ Err("Name cannot be empty".to_string())
+ } else {
+ self.name = name;
+ self.touch();
+ Ok(())
+ }
+ }
+
+ pub fn update_description(&mut self, description: Option<String>) {
+ self.description = description;
+ self.touch()
+ }
+
+ pub fn update_archived(&mut self, is_archived: bool) {
+ self.is_archived = is_archived;
+ self.touch()
+ }
+
+ pub fn touch(&mut self) {
+ self.updated_at = Utc::now()
+ }
+}
diff --git a/src/domain/event.rs b/src/domain/event.rs
new file mode 100644
index 0000000..6ad24a4
--- /dev/null
+++ b/src/domain/event.rs
@@ -0,0 +1,310 @@
+//! Calendar event domain model.
+//!
+//! This module provides the core `Event` and `EventBuilder` types for representing
+//! calendar events with recurrence support, metadata tracking, and validation.
+//!
+//! # Overview
+//!
+//! The `Event` struct represents a single calendar entry
+//! with start and end times, optional recurrence rules, and metadata.
+//! Use the `EventBuilder` to construct events with validation of required fields
+//! and sensible defaults for optional ones.
+//!
+//! # Examples
+//!
+//! ```
+//! # use uuid::Uuid;
+//! # use chrono::Utc;
+//! # let cal_id = Uuid::new_v4();
+//! let event = EventBuilder::new(
+//! cal_id,
+//! "Team Meeting".to_string(),
+//! Utc::now(),
+//! Utc::now() + chrono::Duration::hours(1)
+//! )?
+//! .color(5)
+//! .description(Some("Quarterly sync".to_string()))
+//! .build();
+//! # Ok::<(), String>(())
+//! ```
+
+use chrono::{DateTime, TimeDelta, Utc};
+use getset::Getters;
+use uuid::Uuid;
+use crate::domain::recurrence::RecurrenceRule;
+
+/// A calendar event with optional recurrence, metadata, and state tracking.
+///
+/// `Event` represents a single calendar entry with start and end times,
+/// optional recurrence rules, and metadata like color and cancellation status.
+/// All mutation operations update the `updated_at` timestamp.
+///
+/// # Fields
+///
+/// * `id` - Unique identifier for the event
+/// * `calendar_id` - The calendar this event belongs to
+/// * `title` - Display title of the event
+/// * `description` - Optional detailed description
+/// * `start` - Event start time in UTC
+/// * `end` - Event end time in UTC
+/// * `recurring` - Optional recurrence rule for repeating events
+/// * `created_at` - When the event was created
+/// * `updated_at` - When the event was last modified
+/// * `color` - Color identifier (0-255)
+/// * `is_all_day` - Whether this is an all-day event
+/// * `is_cancelled` - Whether this event is cancelled
+#[derive(Clone, Debug, Getters)]
+#[getset(get = "pub")]
+pub struct Event {
+ id: Uuid,
+ calendar_id: Uuid,
+ title: String,
+ description: Option<String>,
+ start: DateTime<Utc>,
+ end: DateTime<Utc>,
+ recurring: Option<RecurrenceRule>,
+ created_at: DateTime<Utc>,
+ updated_at: DateTime<Utc>,
+ color: u8,
+ is_all_day: bool,
+ is_cancelled: bool
+}
+
+/// Builder for constructing `Event` instances with validation.
+///
+/// Provides an ergonomic way to create events with required field validation.
+/// Required fields are `calendar_id`, `title`, `start`, and `end`.
+/// Optional fields have sensible defaults.
+///
+/// # Examples
+///
+/// ```
+/// # use uuid::Uuid;
+/// # use chrono::Utc;
+/// # let cal_id = Uuid::new_v4();
+/// let event = EventBuilder::new(
+/// cal_id,
+/// "Team Meeting".to_string(),
+/// Utc::now(),
+/// Utc::now() + chrono::Duration::hours(1)
+/// )?
+/// .color(5)
+/// .description(Some("Quarterly sync".to_string()))
+/// .build();
+/// ```
+pub struct EventBuilder {
+ calendar_id: Uuid,
+ title: String,
+ start: DateTime<Utc>,
+ end: DateTime<Utc>,
+ description: Option<String>,
+ recurring: Option<RecurrenceRule>,
+ color: u8,
+ is_all_day: bool,
+ is_cancelled: bool,
+}
+
+impl EventBuilder {
+ /// Creates a new `EventBuilder` with required fields.
+ ///
+ /// # Arguments
+ ///
+ /// * `calendar_id` - The calendar to which this event belongs
+ /// * `title` - The display title of the event (cannot be empty)
+ /// * `start` - The event start time in UTC
+ /// * `end` - The event end time in UTC (must be after `start`)
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if:
+ /// - `title` is empty
+ /// - `start` is not strictly before `end`
+ pub fn new(
+ calendar_id: Uuid,
+ title: String,
+ start: DateTime<Utc>,
+ end: DateTime<Utc>
+ ) -> Result<Self, String> {
+ if title.is_empty() {
+ Err("Title cannot be empty".to_string())
+ } else if start >= end {
+ Err("Start time must be strictly before end time".to_string())
+ } else {
+ Ok(Self {
+ calendar_id,
+ title,
+ start,
+ end,
+ description: None,
+ recurring: None,
+ color: 0,
+ is_all_day: false,
+ is_cancelled: false
+ })
+ }
+ }
+
+ /// Sets the event description.
+ ///
+ /// # Arguments
+ ///
+ /// * `description` - Optional detailed description of the event
+ pub fn description(mut self, description: Option<String>) -> Self {
+ self.description = description;
+ self
+ }
+
+ /// Sets the recurrence rule for repeating events.
+ ///
+ /// # Arguments
+ ///
+ /// * `recurring` - Optional recurrence rule defining repetition pattern
+ pub fn recurring(mut self, recurring: Option<RecurrenceRule>) -> Self {
+ self.recurring = recurring;
+ self
+ }
+
+ /// Sets the color identifier for the event.
+ ///
+ /// # Arguments
+ ///
+ /// * `color` - Color identifier in range 0-255
+ pub fn color(mut self, color: u8) -> Self {
+ self.color = color;
+ self
+ }
+
+ /// Sets whether this is an all-day event.
+ ///
+ /// # Arguments
+ ///
+ /// * `is_all_day` - `true` if this is an all-day event, `false` otherwise
+ pub fn is_all_day(mut self, is_all_day: bool) -> Self {
+ self.is_all_day = is_all_day;
+ self
+ }
+
+ /// Sets whether this event is cancelled.
+ ///
+ /// # Arguments
+ ///
+ /// * `is_cancelled` - `true` if this event is cancelled, `false` otherwise
+ pub fn is_cancelled(mut self, is_cancelled: bool) -> Self {
+ self.is_cancelled = is_cancelled;
+ self
+ }
+
+ /// Builds and returns the constructed `Event`.
+ ///
+ /// Sets the `id`, `created_at`, and `updated_at` fields to automatically
+ /// generated values. The `id` is a new UUID, and both timestamps are set
+ /// to the current UTC time.
+ pub fn build(self) -> Event {
+ let now = Utc::now();
+ Event {
+ id: Uuid::new_v4(),
+ calendar_id: self.calendar_id,
+ title: self.title,
+ description: self.description,
+ start: self.start,
+ end: self.end,
+ recurring: self.recurring,
+ created_at: now,
+ updated_at: now,
+ color: self.color,
+ is_all_day: self.is_all_day,
+ is_cancelled: self.is_cancelled,
+ }
+ }
+}
+
+impl Event {
+ /// Updates the event title.
+ ///
+ /// # Errors
+ /// Returns an error if the title is empty.
+ pub fn update_title(&mut self, title: String) -> Result<(), String> {
+ if title.is_empty() {
+ Err("Title cannot be empty".to_string())
+ } else {
+ self.title = title;
+ self.touch();
+ Ok(())
+ }
+ }
+
+ /// Updates the event description.
+ pub fn update_description(&mut self, description: Option<String>) {
+ self.description = description;
+ self.touch();
+ }
+
+ /// Updates the recurrence rule.
+ pub fn update_recurring(&mut self, recurring: Option<RecurrenceRule>) {
+ self.recurring = recurring;
+ self.touch();
+ }
+
+ /// Reschedules the event to new start and end times.
+ ///
+ /// # Errors
+ /// Returns an error if `start` is not before `end`.
+ pub fn reschedule(
+ &mut self,
+ start: DateTime<Utc>,
+ end: DateTime<Utc>
+ ) -> Result<(), String> {
+ if start >= end {
+ Err("Start time must be strictly before end time".to_string())
+ } else {
+ self.start = start;
+ self.end = end;
+ self.touch();
+ Ok(())
+ }
+ }
+
+ /// Updates the color identifier.
+ pub fn update_color(&mut self, color: u8) {
+ self.color = color;
+ self.touch();
+ }
+
+ /// Updates whether this is an all-day event.
+ pub fn update_all_day(&mut self, is_all_day: bool) {
+ self.is_all_day = is_all_day;
+ self.touch();
+ }
+
+ /// Updates whether this event is cancelled.
+ pub fn update_is_cancelled(&mut self, is_cancelled: bool) {
+ self.is_cancelled = is_cancelled;
+ self.touch();
+ }
+
+ /// Returns the duration of the event.
+ pub fn get_duration(&self) -> TimeDelta {
+ self.end - self.start
+ }
+
+ /// Returns true if the event has ended.
+ pub fn is_past(&self) -> bool {
+ self.end < Utc::now()
+ }
+
+ /// Returns true if the event is currently happening.
+ pub fn is_ongoing(&self) -> bool {
+ let now = Utc::now();
+ self.start <= now && now < self.end
+ }
+
+ /// Returns true if the event has not yet started.
+ pub fn is_future(&self) -> bool {
+ self.start > Utc::now()
+ }
+
+ /// Updates the `updated_at` timestamp to the current time.
+ pub fn touch(&mut self) {
+ self.updated_at = Utc::now();
+ }
+}
diff --git a/src/domain/mod.rs b/src/domain/mod.rs
new file mode 100644
index 0000000..f31bc30
--- /dev/null
+++ b/src/domain/mod.rs
@@ -0,0 +1,6 @@
+pub mod event;
+pub mod recurrence;
+pub mod calendar;
+
+pub use event::Event;
+pub use recurrence::{RecurrenceRule, Frequency};
diff --git a/src/domain/recurrence.rs b/src/domain/recurrence.rs
new file mode 100644
index 0000000..807df4b
--- /dev/null
+++ b/src/domain/recurrence.rs
@@ -0,0 +1,36 @@
+use chrono::{DateTime, Utc};
+use getset::Getters;
+
+#[derive(Clone, Debug, Getters)]
+#[getset(get = "pub")]
+pub struct RecurrenceRule {
+ frequency: Frequency,
+ interval: u32,
+ end_date: Option<DateTime<Utc>>
+}
+
+#[derive(Clone, Debug)]
+pub enum Frequency {
+ DAILY,
+ WEEKLY,
+ MONTHLY,
+ YEARLY
+}
+
+impl RecurrenceRule {
+ pub fn new(
+ frequency: Frequency,
+ interval: u32,
+ end_date: Option<DateTime<Utc>>
+ ) -> Result<Self, String> {
+ let now = Utc::now();
+
+ if let Some(end) = end_date {
+ if now >= end {
+ return Err("End date has already passed".to_string());
+ }
+ }
+
+ Ok(Self { frequency, interval, end_date })
+ }
+}