Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] - 2026-03-18

### Added
- **Calendar view iterators**: `Calendar::days()`, `days_back()`, `weeks()`, and `weeks_back()` provide lazy day/week traversal for UI-oriented calendar rendering.
- **Owned day/week view models**: Added `DayView`, `WeekView`, and `OwnedEventOccurrence` so iterator output can be collected and moved independently of the source `Calendar`.
- **Calendar views example**: Added `examples/calendar_views.rs` to demonstrate day/week traversal and UI-friendly mapping patterns.

### Changed
- **Fallible view iteration**: `DayIterator` and `WeekIterator` now yield `Result<DayView>` / `Result<WeekView>` items instead of silently stopping on expansion errors.
- **Week back-navigation**: `WeekIterator::backward()` now yields proper contiguous Monday-Sunday blocks when walking into the past.
- **Explicit day boundaries**: `DayView::end()` now returns the exclusive next-midnight boundary, with `end_inclusive()` available for display-only scenarios.
- `Calendar::events_on_date()` and `Event::occurs_on()` now use an exclusive next-day boundary, improving correctness for occurrences that start exactly at midnight boundaries and for DST-length days.

## [0.4.0] - 2026-03-07

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "eventix"
version = "0.4.0"
version = "0.5.0"
edition = "2021"
authors = ["Raj Sarkar <ariajsarkar@gmail.com>"]
description = "High-level calendar & recurrence crate with timezone-aware scheduling, exceptions, and ICS import/export"
Expand Down
53 changes: 51 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Eventix 📅

A high-level calendar and recurrence library for Rust with timezone-aware scheduling, exceptions, and ICS import/export.
A high-level calendar and recurrence library for Rust with timezone-aware scheduling, exceptions, ICS import/export, and lazy calendar views.

[![Crates.io](https://img.shields.io/crates/v/eventix.svg)](https://crates.io/crates/eventix)
[![Documentation](https://docs.rs/eventix/badge.svg)](https://docs.rs/eventix)
Expand All @@ -11,6 +11,7 @@ A high-level calendar and recurrence library for Rust with timezone-aware schedu

- 🌍 **Timezone-aware events** - Full support for timezones and DST handling using `chrono-tz`
- 🔄 **Recurrence patterns** - All seven RFC 5545 frequencies (secondly, minutely, hourly, daily, weekly, monthly, yearly) with advanced rules
- 🗓️ **Calendar view iterators** - Lazy day/week traversal for UI rendering and infinite-scroll agendas
- 🚫 **Exception handling** - Skip specific dates, weekends, or custom holiday lists
- 🚦 **Booking workflow** - Manage event status (`Confirmed`, `Tentative`, `Cancelled`) with smart gap validation
- 📅 **ICS support** - Import and export events using the iCalendar (`.ics`) format
Expand All @@ -28,14 +29,15 @@ A high-level calendar and recurrence library for Rust with timezone-aware schedu
| **Booking State** | ✅ Confirmed/Cancelled | ❌ No Concept | ❌ No Concept |
| **Timezone/DST** | ✅ Built-in (`chrono-tz`) | ⚠️ Partial | ✅ Built-in |
| **Recurrence** | ✅ RRule + Exdates | ✅ RRule | ❌ None |
| **View Iterators** | ✅ Day/Week lazy APIs | ❌ Manual Grouping | ❌ Manual Logic |

## Quick Start

Add eventix to your `Cargo.toml`:

```toml
[dependencies]
eventix = "0.4.0"
eventix = "0.5.0"
```

### Basic Usage
Expand Down Expand Up @@ -180,6 +182,49 @@ let mondays: Vec<_> = daily.occurrences(start)
.collect();
```

### Calendar View Iterators

For UI rendering, `Calendar::days()` and `Calendar::weeks()` lazily bucket
active occurrences into day/week views. This avoids choosing a large query
window up front and maps cleanly into frontend components. The iterators yield
`Result<DayView>` / `Result<WeekView>` so expansion errors stay explicit.

```rust
use eventix::{Calendar, Event, Recurrence, timezone};

let mut cal = Calendar::new("Personal");
cal.add_event(
Event::builder()
.title("Standup")
.start("2025-11-04 10:00:00", "America/New_York")
.duration_minutes(15)
.recurrence(Recurrence::daily().count(10))
.build()?
);

let tz = timezone::parse_timezone("America/New_York")?;
let start = timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz)?;

let busy_days: Vec<_> = cal.days(start)
.take(14)
.collect::<eventix::Result<Vec<_>>>()?
.into_iter()
.filter(|day| !day.is_empty())
.collect();

for week in cal.weeks(start).take(2) {
let week = week?;
println!("{} -> {}", week.start_date(), week.event_count());
}
```

`DayView::start()` and `DayView::end()` expose the actual half-open day window
`[start, end)`, so `end()` is the next midnight. Use `end_inclusive()` only for
display formatting. Day and week views are built by interval intersection, so
overnight events appear on every day they overlap. If you're passing large
`DayView` values through Yew props, wrapping them in `Rc<DayView>` can avoid
expensive prop clones.

### Booking Status

```rust
Expand Down Expand Up @@ -329,6 +374,9 @@ Run the examples:
# Basic calendar usage
cargo run --example basic

# Day/week calendar views
cargo run --example calendar_views

# Recurrence patterns
cargo run --example recurrence

Expand All @@ -352,6 +400,7 @@ The crate is organized into several modules:
- **`calendar`** - Calendar container for managing events
- **`event`** - Event types and builder API
- **`recurrence`** - Recurrence rules and patterns
- **`views`** - Lazy day/week calendar view iterators
- **`ics`** - ICS format import/export
- **`timezone`** - Timezone handling and DST support
- **`gap_validation`** - Schedule analysis, gap detection, conflict resolution
Expand Down
7 changes: 7 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,10 @@ This document outlines the strategic technical direction for the `eventix` crate
- [ ] **Property-Based Testing** `(Priority: High)`
- **Action**: Implement `proptest` suites for `gap_validation`.
- **Goal**: mathematically guarantee no overlapping slots are missed under edge-case conditions (e.g., DST transitions).

## 5. Calendar View API
*Objective: Expose lazy, UI-friendly calendar traversal primitives for day/week rendering.*

- [x] **Day and Week View Iterators** `(Priority: Medium)`
- **Feature**: Add lazy `Calendar::days()` / `days_back()` and `Calendar::weeks()` / `weeks_back()` iterators that yield pre-bucketed `DayView` and `WeekView` values.
- **Use Case**: Lets consumers render personal calendar UIs in frameworks like Yew, Leptos, and Dioxus without eagerly expanding wide date ranges or manually grouping occurrences by day.
90 changes: 90 additions & 0 deletions examples/calendar_views.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! Demonstrates lazy calendar day/week view iteration.

use eventix::{timezone, Calendar, Duration, Event, EventStatus, Recurrence};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut calendar = Calendar::new("Personal Planner");

calendar.add_event(
Event::builder()
.title("Design Review")
.start("2025-11-03 09:00:00", "America/New_York")
.duration_hours(1)
.location("Room A")
.build()?,
);

calendar.add_event(
Event::builder()
.title("Daily Standup")
.start("2025-11-04 10:00:00", "America/New_York")
.duration(Duration::minutes(15))
.recurrence(Recurrence::daily().count(10))
.build()?,
);

calendar.add_event(
Event::builder()
.title("Draft Blog Post")
.start("2025-11-05 13:00:00", "America/New_York")
.duration_minutes(45)
.status(EventStatus::Tentative)
.build()?,
);

let tz = timezone::parse_timezone("America/New_York")?;
let start = timezone::parse_datetime_with_tz("2025-11-03 00:00:00", tz)?;

println!("Next 7 days:");
for day in calendar.days(start).take(7) {
let day = day?;
println!("{} -> {} events", day.date(), day.event_count());
for occurrence in day.events() {
println!(
" - {} at {} ({:?})",
occurrence.title(),
occurrence.occurrence_time.format("%H:%M %Z"),
occurrence.status
);
}
}

println!("\nNext 2 weeks:");
for week in calendar.weeks(start).take(2) {
let week = week?;
println!(
"Week {} to {} -> {} events",
week.start_date(),
week.end_date(),
week.event_count()
);
for day in week.days() {
println!(" {} -> {} events", day.date(), day.event_count());
}
}

// A UI layer can map DayView values directly into components.
let busy_day_titles: Vec<_> = calendar
.days(start)
.take(14)
.collect::<eventix::Result<Vec<_>>>()?
.into_iter()
.filter(|day| !day.is_empty())
.map(|day| {
let titles = day
.events()
.iter()
.map(|event| event.title().to_string())
.collect::<Vec<_>>()
.join(", ");
format!("{} => {}", day.date(), titles)
})
.collect();

println!("\nBusy days:");
for line in busy_day_titles {
println!(" {line}");
}

Ok(())
}
58 changes: 38 additions & 20 deletions src/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
use crate::error::{EventixError, Result};
use crate::event::Event;
use crate::recurrence::Recurrence;
use chrono::{DateTime, TimeZone};
use crate::timezone::local_day_window;
use crate::views::{DayIterator, WeekIterator};
use chrono::DateTime;
use chrono_tz::Tz;
use rrule::Frequency;

Expand Down Expand Up @@ -202,28 +204,44 @@ impl Calendar {

/// Get all events occurring on a specific date
pub fn events_on_date(&self, date: DateTime<Tz>) -> Result<Vec<EventOccurrence<'_>>> {
let start = date
.date_naive()
.and_hms_opt(0, 0, 0)
.ok_or_else(|| EventixError::ValidationError("Invalid start time".to_string()))?;
let end = date
.date_naive()
.and_hms_opt(23, 59, 59)
.ok_or_else(|| EventixError::ValidationError("Invalid end time".to_string()))?;

let tz = date.timezone();
let start_dt = tz
.from_local_datetime(&start)
.earliest()
.ok_or_else(|| EventixError::ValidationError("Ambiguous start time".to_string()))?;
let end_dt = tz
.from_local_datetime(&end)
.latest()
.ok_or_else(|| EventixError::ValidationError("Ambiguous end time".to_string()))?;

let (start_dt, end_dt) = local_day_window(date.date_naive(), date.timezone())?;
self.events_between(start_dt, end_dt)
}

/// Create a lazy iterator over calendar days starting from the given date.
///
/// Each yielded item is a [`crate::Result`] containing a [`crate::DayView`]
/// for the requested local day. Views bucket active occurrences whose time
/// span intersects the day, so overnight events appear on every day they
/// overlap. The iterator advances one day at a time until the supported
/// date range is exhausted.
pub fn days(&self, start: DateTime<Tz>) -> DayIterator<'_> {
DayIterator::new(self, start)
}

/// Create a lazy iterator over calendar days moving backward in time.
///
/// Each yielded item is a [`crate::Result`] containing a [`crate::DayView`].
pub fn days_back(&self, start: DateTime<Tz>) -> DayIterator<'_> {
DayIterator::backward(self, start)
}

/// Create a lazy iterator over ISO weeks (Monday through Sunday).
///
/// The first yielded item is a [`crate::Result`] containing the Monday-Sunday
/// week that contains `start`.
pub fn weeks(&self, start: DateTime<Tz>) -> WeekIterator<'_> {
WeekIterator::new(self, start)
}

/// Create a lazy iterator over ISO weeks moving backward in time.
///
/// Each yielded item is a [`crate::Result`] containing a contiguous
/// Monday-Sunday block.
pub fn weeks_back(&self, start: DateTime<Tz>) -> WeekIterator<'_> {
WeekIterator::backward(self, start)
}

/// Get the number of events in the calendar
pub fn event_count(&self) -> usize {
self.events.len()
Expand Down
23 changes: 6 additions & 17 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

use crate::error::{EventixError, Result};
use crate::recurrence::{Recurrence, RecurrenceFilter};
use crate::timezone::{parse_datetime_with_tz, parse_timezone};
use chrono::{DateTime, Duration, TimeZone};
use crate::timezone::{local_day_window, parse_datetime_with_tz, parse_timezone};
use chrono::{DateTime, Duration};
use chrono_tz::Tz;

use serde::{Deserialize, Serialize};

/// Status of an event in the booking lifecycle
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
)]
pub enum EventStatus {
/// The event is confirmed and occupies time (default)
#[default]
Expand Down Expand Up @@ -151,20 +153,7 @@ impl Event {

/// Check if this event occurs on a specific date
pub fn occurs_on(&self, date: DateTime<Tz>) -> Result<bool> {
let start = date.date_naive().and_hms_opt(0, 0, 0).ok_or_else(|| {
EventixError::ValidationError("Invalid start time for date check".to_string())
})?;
let end = date.date_naive().and_hms_opt(23, 59, 59).ok_or_else(|| {
EventixError::ValidationError("Invalid end time for date check".to_string())
})?;

let start_dt = self.timezone.from_local_datetime(&start).earliest().ok_or_else(|| {
EventixError::ValidationError("Ambiguous start time for date check".to_string())
})?;
let end_dt = self.timezone.from_local_datetime(&end).latest().ok_or_else(|| {
EventixError::ValidationError("Ambiguous end time for date check".to_string())
})?;

let (start_dt, end_dt) = local_day_window(date.date_naive(), self.timezone)?;
let occurrences = self.occurrences_between(start_dt, end_dt, 1)?;
Ok(!occurrences.is_empty())
}
Expand Down
7 changes: 6 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//! - **Exceptions**: Skip specific dates or apply custom filters (e.g., skip weekends)
//! - **ICS support**: Import and export events using the iCalendar format (RFC 5545 compliant with TZID support)
//! - **Builder API**: Ergonomic, fluent interface for creating events and calendars
//! - **Calendar view iterators**: Lazy day/week traversal for UI-friendly calendar rendering
//! - **Gap validation**: Find gaps between events, detect conflicts, and analyze schedule density
//! - **Schedule analysis**: Unique features for occupancy metrics, availability finding, and conflict resolution
//!
Expand Down Expand Up @@ -105,11 +106,13 @@
//! - [`ics`] - ICS (iCalendar) import/export with TZID support
//! - [`recurrence`] - Recurrence patterns (secondly, minutely, hourly, daily, weekly, monthly, yearly)
//! - [`timezone`] - Timezone utilities with DST awareness
//! - [`views`] - Lazy day/week calendar view iterators
//!
//! ## Examples
//!
//! See the `examples/` directory for more comprehensive examples:
//! - `basic.rs` - Simple calendar creation and event management
//! - `calendar_views.rs` - Lazy day and week view iteration for UI rendering
//! - `recurrence.rs` - All seven RFC 5545 recurrence frequencies with lazy iteration and DST handling
//! - `ics_export.rs` - ICS import/export functionality
//! - `timezone_ics_export.rs` - Timezone-aware ICS export demonstration
Expand All @@ -121,14 +124,16 @@ pub mod gap_validation;
pub mod ics;
pub mod recurrence;
pub mod timezone;
pub mod views;

mod error;

pub use calendar::Calendar;
pub use error::{EventixError, Result};
pub use event::{Event, EventBuilder, EventStatus};
pub use recurrence::{OccurrenceIterator, Recurrence};
pub use views::{DayIterator, DayView, OwnedEventOccurrence, WeekIterator, WeekView};

// Re-export commonly used types from chrono
pub use chrono::{DateTime, Duration, NaiveDateTime, Utc};
pub use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, Utc};
pub use chrono_tz::Tz;
Loading
Loading