Skip to main content

orbital_date_pickers/shared/
range_calendar_logic.rs

1use chrono::{NaiveDate, Utc};
2use orbital_base_components::{DatetimeTimezone, OrbitalDateTime};
3
4/// In-progress or complete range selection on the calendar grid.
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
6pub struct RangeSelection {
7    pub start: Option<NaiveDate>,
8    pub end: Option<NaiveDate>,
9}
10
11impl RangeSelection {
12    pub fn is_complete(self) -> bool {
13        self.start.is_some() && self.end.is_some()
14    }
15}
16
17/// Visual role for a day cell in a range calendar.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum DayRangeRole {
20    None,
21    RangeStart,
22    RangeEnd,
23    InRange,
24    PreviewRange,
25}
26
27/// Advance range selection after a day click.
28pub fn apply_range_click(selection: RangeSelection, day: NaiveDate) -> RangeSelection {
29    if selection.end.is_some() {
30        return RangeSelection {
31            start: Some(day),
32            end: None,
33        };
34    }
35    if let Some(start) = selection.start {
36        let (start, end) = if day < start {
37            (day, start)
38        } else {
39            (start, day)
40        };
41        RangeSelection {
42            start: Some(start),
43            end: Some(end),
44        }
45    } else {
46        RangeSelection {
47            start: Some(day),
48            end: None,
49        }
50    }
51}
52
53/// Classify a day for range highlighting.
54pub fn day_range_role(
55    day: NaiveDate,
56    selection: RangeSelection,
57    hover: Option<NaiveDate>,
58) -> DayRangeRole {
59    if let (Some(start), Some(end)) = (selection.start, selection.end) {
60        if day == start {
61            return DayRangeRole::RangeStart;
62        }
63        if day == end {
64            return DayRangeRole::RangeEnd;
65        }
66        if day > start && day < end {
67            return DayRangeRole::InRange;
68        }
69        return DayRangeRole::None;
70    }
71
72    if let Some(start) = selection.start {
73        if day == start {
74            return DayRangeRole::RangeStart;
75        }
76        if let Some(hover_day) = hover {
77            let (preview_start, preview_end) = if hover_day < start {
78                (hover_day, start)
79            } else {
80                (start, hover_day)
81            };
82            if day == preview_start && day != start {
83                return DayRangeRole::PreviewRange;
84            }
85            if day == preview_end && day != start {
86                return DayRangeRole::PreviewRange;
87            }
88            if day > preview_start && day < preview_end {
89                return DayRangeRole::PreviewRange;
90            }
91        }
92    }
93
94    DayRangeRole::None
95}
96
97pub fn is_day_disabled(
98    day: NaiveDate,
99    min_date: Option<OrbitalDateTime>,
100    max_date: Option<OrbitalDateTime>,
101) -> bool {
102    if let Some(min) = min_date.and_then(|dt| dt.wall_date()) {
103        if day < min {
104            return true;
105        }
106    }
107    if let Some(max) = max_date.and_then(|dt| dt.wall_date()) {
108        if day > max {
109            return true;
110        }
111    }
112    false
113}
114
115pub fn orbital_from_date(date: NaiveDate, timezone: DatetimeTimezone) -> Option<OrbitalDateTime> {
116    OrbitalDateTime::from_naive_date(date, timezone)
117}
118
119pub fn today_for_timezone(timezone: DatetimeTimezone) -> NaiveDate {
120    use chrono::{FixedOffset, Local};
121    match timezone {
122        DatetimeTimezone::Local => Local::now().date_naive(),
123        DatetimeTimezone::Utc => Utc::now().date_naive(),
124        DatetimeTimezone::FixedOffset(offset_secs) => FixedOffset::east_opt(offset_secs)
125            .map(|offset| Utc::now().with_timezone(&offset).date_naive())
126            .unwrap_or_else(|| Utc::now().date_naive()),
127    }
128}
129
130pub fn selection_to_range(
131    selection: RangeSelection,
132    timezone: DatetimeTimezone,
133) -> Option<crate::DateTimeRange> {
134    let (start, end) = (selection.start?, selection.end?);
135    let start_dt = orbital_from_date(start, timezone)?;
136    let end_dt = orbital_from_date(end, timezone)?;
137    Some(crate::DateTimeRange::new(start_dt, end_dt).normalized())
138}
139
140pub fn range_to_selection(range: Option<crate::DateTimeRange>) -> RangeSelection {
141    match range {
142        Some(range) => RangeSelection {
143            start: range.start.wall_date(),
144            end: range.end.wall_date(),
145        },
146        None => RangeSelection::default(),
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use chrono::NaiveDate;
154
155    #[test]
156    fn first_click_sets_start_only() {
157        let day = NaiveDate::from_ymd_opt(2025, 6, 10).expect("date");
158        let next = apply_range_click(RangeSelection::default(), day);
159        assert_eq!(next.start, Some(day));
160        assert_eq!(next.end, None);
161    }
162
163    #[test]
164    fn second_click_completes_range() {
165        let start = NaiveDate::from_ymd_opt(2025, 6, 10).expect("date");
166        let end = NaiveDate::from_ymd_opt(2025, 6, 15).expect("date");
167        let partial = RangeSelection {
168            start: Some(start),
169            end: None,
170        };
171        let complete = apply_range_click(partial, end);
172        assert_eq!(complete.start, Some(start));
173        assert_eq!(complete.end, Some(end));
174    }
175
176    #[test]
177    fn click_while_complete_restarts() {
178        let old_start = NaiveDate::from_ymd_opt(2025, 6, 1).expect("date");
179        let old_end = NaiveDate::from_ymd_opt(2025, 6, 20).expect("date");
180        let complete = RangeSelection {
181            start: Some(old_start),
182            end: Some(old_end),
183        };
184        let new_day = NaiveDate::from_ymd_opt(2025, 7, 4).expect("date");
185        let restarted = apply_range_click(complete, new_day);
186        assert_eq!(restarted.start, Some(new_day));
187        assert_eq!(restarted.end, None);
188    }
189}