Skip to main content

herogpui_components/
calendar_view.rs

1//! The v3 calendar view model: `visibleDuration`, `pageBehavior` and
2//! `selectionAlignment`.
3//!
4//! v3 lets a calendar show a month grid, several month grids side by side, a
5//! run of week rows, or a rolling window of days. All three views share one
6//! anchor date. Date calculations are pure; the shared year-grid render state
7//! also lives here.
8
9use herogpui_core::element_id;
10
11use crate::calendar::{add_days, bump_month, days_from_civil, days_in_month, Date};
12use crate::date_constraints::Weekday;
13
14/// `visibleDuration` — how much time one calendar shows at once.
15///
16/// React spells this `{months?: n, weeks?: n, days?: n}`; an enum makes the
17/// three mutually exclusive forms unrepresentable in combination.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum VisibleDuration {
20    /// `{months: n}` — n month grids side by side.
21    Months(usize),
22    /// `{weeks: n}` — one grid of n week rows.
23    Weeks(usize),
24    /// `{days: n}` — a rolling window of n consecutive days.
25    /// Its displayed week rows may also contain disabled leading dates.
26    Days(usize),
27}
28
29impl Default for VisibleDuration {
30    fn default() -> Self {
31        VisibleDuration::Months(1)
32    }
33}
34
35impl VisibleDuration {
36    /// The unit count, never zero — a calendar always shows something.
37    pub fn count(self) -> usize {
38        match self {
39            VisibleDuration::Months(n) | VisibleDuration::Weeks(n) | VisibleDuration::Days(n) => {
40                n.max(1)
41            }
42        }
43    }
44
45    /// Month view keeps the classic 7-column grid with lead/trail cells; the
46    /// week and day views are a flat run of dates instead.
47    pub fn is_month_view(self) -> bool {
48        matches!(self, VisibleDuration::Months(_))
49    }
50}
51
52/// `pageBehavior` — whether navigation advances the whole visible range or a
53/// single unit.
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum PageBehavior {
56    /// `'visible'` — step by the visible duration.
57    #[default]
58    Visible,
59    /// `'single'` — step one month/week/day at a time.
60    Single,
61}
62
63/// `selectionAlignment` — where the selection sits inside the visible range on
64/// first render.
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66pub enum SelectionAlignment {
67    /// The selection sits at the start of the visible range.
68    Start,
69    #[default]
70    /// The selection sits at the centre of the visible range.
71    Center,
72    /// The selection sits at the end of the visible range.
73    End,
74}
75
76impl SelectionAlignment {
77    /// How many units before the selection the range starts, for a range of
78    /// `count` units.
79    pub fn lead_units(self, count: usize) -> usize {
80        let count = count.max(1);
81        match self {
82            SelectionAlignment::Start => 0,
83            // A 2-up range centres by leading with one unit, matching v3.
84            SelectionAlignment::Center => (count - 1) / 2,
85            SelectionAlignment::End => count - 1,
86        }
87    }
88}
89
90/// The first day of the week containing `date`, honouring `firstDayOfWeek`.
91pub fn week_start(date: Date, first_day: Weekday) -> Date {
92    // `first_weekday_pub` is Monday-indexed, so shift into the caller's frame
93    // before taking the remainder.
94    let dow = crate::calendar::weekday_index(date);
95    let offset = (dow + 7 - first_day.monday_index()) % 7;
96    add_days(&date, -(offset as i64))
97}
98
99/// Clamps `day` into `(year, month)` — paging from the 31st into a short month
100/// must not produce an invalid date.
101fn clamp_day(year: i32, month: u32, day: u32) -> Date {
102    Date::new(year, month, day.min(days_in_month(year, month)))
103}
104
105/// The anchor after paging by `dir` (-1 back, +1 forward).
106pub fn page(duration: VisibleDuration, behavior: PageBehavior, anchor: Date, dir: i32) -> Date {
107    page_in(
108        crate::calendar_system::system(),
109        duration,
110        behavior,
111        anchor,
112        dir,
113    )
114}
115
116/// The anchor one page away, stepping months in `system`'s own calendar.
117///
118/// A month view pages by a month the *reader* would recognise, so an Indian
119/// grid moves Pausha to Magha rather than by a Gregorian month that would drift
120/// against it. The anchor stays Gregorian either way.
121pub fn page_in(
122    system: &crate::calendar_system::CalendarSystem,
123    duration: VisibleDuration,
124    behavior: PageBehavior,
125    anchor: Date,
126    dir: i32,
127) -> Date {
128    let step = match behavior {
129        PageBehavior::Visible => duration.count(),
130        PageBehavior::Single => 1,
131    };
132    match duration {
133        VisibleDuration::Months(_) => {
134            if system.is_gregorian() {
135                let (mut y, mut m) = (anchor.year, anchor.month);
136                for _ in 0..step {
137                    let (ny, nm) = bump_month(y, m, dir);
138                    y = ny;
139                    m = nm;
140                }
141                return clamp_day(y, m, anchor.day);
142            }
143            let (year, month, day) = system.from_gregorian(anchor);
144            let (year, month) = system.add_months(year, month, dir.signum() * step as i32);
145            // The same day of the month where that month has one, else its
146            // last -- the non-Gregorian half of `clamp_day`.
147            let day = day.min(system.days_in_month(year, month)).max(1);
148            system.to_gregorian(year, month, day).unwrap_or(anchor)
149        }
150        VisibleDuration::Weeks(_) => add_days(&anchor, dir as i64 * step as i64 * 7),
151        VisibleDuration::Days(_) => add_days(&anchor, dir as i64 * step as i64),
152    }
153}
154
155/// The focused section reached by PageUp/PageDown in pinned React Aria.
156///
157/// Month and week views move one displayed unit regardless of `pageBehavior`;
158/// Shift moves their next larger unit. Day views delegate to page navigation,
159/// so they honor `pageBehavior` and ignore Shift.
160pub fn focus_section(
161    duration: VisibleDuration,
162    behavior: PageBehavior,
163    anchor: Date,
164    dir: i32,
165    larger: bool,
166) -> Date {
167    focus_section_in(
168        crate::calendar_system::system(),
169        duration,
170        behavior,
171        anchor,
172        dir,
173        larger,
174    )
175}
176
177/// Uses the explicitly selected calendar system.
178pub(crate) fn focus_section_in(
179    system: &crate::calendar_system::CalendarSystem,
180    duration: VisibleDuration,
181    behavior: PageBehavior,
182    anchor: Date,
183    dir: i32,
184    larger: bool,
185) -> Date {
186    match (duration, larger) {
187        (VisibleDuration::Days(_), _) => page_in(system, duration, behavior, anchor, dir),
188        (VisibleDuration::Weeks(_), false) => page_in(
189            system,
190            VisibleDuration::Weeks(1),
191            PageBehavior::Single,
192            anchor,
193            dir,
194        ),
195        (VisibleDuration::Weeks(_), true) => page_in(
196            system,
197            VisibleDuration::Months(1),
198            PageBehavior::Single,
199            anchor,
200            dir,
201        ),
202        (VisibleDuration::Months(_), false) => page_in(
203            system,
204            VisibleDuration::Months(1),
205            PageBehavior::Single,
206            anchor,
207            dir,
208        ),
209        (VisibleDuration::Months(_), true) => system.add_years(anchor, dir),
210    }
211}
212
213/// The date reached by Home in pinned React Stately's calendar grid.
214pub fn section_start(duration: VisibleDuration, visible_start: Date, focused: Date) -> Date {
215    section_start_in(
216        crate::calendar_system::system(),
217        duration,
218        visible_start,
219        focused,
220    )
221}
222
223/// Uses the explicitly selected calendar system.
224pub(crate) fn section_start_in(
225    system: &crate::calendar_system::CalendarSystem,
226    duration: VisibleDuration,
227    visible_start: Date,
228    focused: Date,
229) -> Date {
230    match duration {
231        VisibleDuration::Days(_) => visible_start,
232        // React Stately deliberately uses the locale week here rather than
233        // the grid's firstDayOfWeek override.
234        VisibleDuration::Weeks(_) => week_start(focused, Weekday::default()),
235        VisibleDuration::Months(_) => {
236            let (year, month, _) = system.from_gregorian(focused);
237            system.to_gregorian(year, month, 1).unwrap_or(focused)
238        }
239    }
240}
241
242/// The date reached by End in pinned React Stately's calendar grid.
243pub fn section_end(duration: VisibleDuration, visible_end: Date, focused: Date) -> Date {
244    section_end_in(
245        crate::calendar_system::system(),
246        duration,
247        visible_end,
248        focused,
249    )
250}
251
252/// Uses the explicitly selected calendar system.
253pub(crate) fn section_end_in(
254    system: &crate::calendar_system::CalendarSystem,
255    duration: VisibleDuration,
256    visible_end: Date,
257    focused: Date,
258) -> Date {
259    match duration {
260        VisibleDuration::Days(_) => visible_end,
261        VisibleDuration::Weeks(_) => add_days(&week_start(focused, Weekday::default()), 6),
262        VisibleDuration::Months(_) => {
263            let (year, month, _) = system.from_gregorian(focused);
264            system
265                .to_gregorian(year, month, system.days_in_month(year, month))
266                .unwrap_or(focused)
267        }
268    }
269}
270
271/// Realign a visible window after keyboard focus crosses either edge.
272pub fn anchor_following_focus(
273    duration: VisibleDuration,
274    first_day: Weekday,
275    anchor: Date,
276    visible_start: Date,
277    visible_end: Date,
278    focused: Date,
279) -> Date {
280    anchor_following_focus_in(
281        crate::calendar_system::system(),
282        duration,
283        first_day,
284        anchor,
285        visible_start,
286        visible_end,
287        focused,
288    )
289}
290
291/// Uses the explicitly selected calendar system.
292pub(crate) fn anchor_following_focus_in(
293    system: &crate::calendar_system::CalendarSystem,
294    duration: VisibleDuration,
295    first_day: Weekday,
296    anchor: Date,
297    visible_start: Date,
298    visible_end: Date,
299    focused: Date,
300) -> Date {
301    if days_from_civil(&focused) < days_from_civil(&visible_start) {
302        aligned_anchor_in(
303            system,
304            duration,
305            SelectionAlignment::End,
306            first_day,
307            focused,
308        )
309    } else if days_from_civil(&focused) > days_from_civil(&visible_end) {
310        aligned_anchor_in(
311            system,
312            duration,
313            SelectionAlignment::Start,
314            first_day,
315            focused,
316        )
317    } else {
318        anchor
319    }
320}
321
322/// The anchor that puts `selection` at the requested position in the range.
323///
324/// v3 applies this on initial render only, so callers seed state with it
325/// rather than recomputing every frame.
326pub fn aligned_anchor(
327    duration: VisibleDuration,
328    alignment: SelectionAlignment,
329    first_day: Weekday,
330    selection: Date,
331) -> Date {
332    aligned_anchor_in(
333        crate::calendar_system::system(),
334        duration,
335        alignment,
336        first_day,
337        selection,
338    )
339}
340
341/// Uses the explicitly selected calendar system.
342pub(crate) fn aligned_anchor_in(
343    system: &crate::calendar_system::CalendarSystem,
344    duration: VisibleDuration,
345    alignment: SelectionAlignment,
346    first_day: Weekday,
347    selection: Date,
348) -> Date {
349    let lead = alignment.lead_units(duration.count());
350    match duration {
351        VisibleDuration::Months(_) => {
352            let (year, month, day) = system.from_gregorian(selection);
353            let (year, month) = system.add_months(year, month, -(lead as i32));
354            system
355                .to_gregorian(year, month, day.min(system.days_in_month(year, month)))
356                .unwrap_or(selection)
357        }
358        VisibleDuration::Weeks(_) => {
359            add_days(&week_start(selection, first_day), -(lead as i64) * 7)
360        }
361        VisibleDuration::Days(_) => add_days(&selection, -(lead as i64)),
362    }
363}
364
365/// The `(year, month)` heading of each month grid in a month view.
366pub fn month_headings(duration: VisibleDuration, anchor: Date) -> Vec<(i32, u32)> {
367    month_headings_in(crate::calendar_system::system(), duration, anchor)
368}
369
370/// The visible months, in `system`'s own year and month numbering.
371///
372/// The anchor stays Gregorian -- it is the caller's state -- and is converted
373/// here, so a grid drawn in another calendar never leaks that system into
374/// `CalendarState`.
375pub fn month_headings_in(
376    system: &crate::calendar_system::CalendarSystem,
377    duration: VisibleDuration,
378    anchor: Date,
379) -> Vec<(i32, u32)> {
380    if !duration.is_month_view() {
381        return Vec::new();
382    }
383    let mut out = Vec::with_capacity(duration.count());
384    let (mut y, mut m, _) = system.from_gregorian(anchor);
385    for _ in 0..duration.count() {
386        out.push((y, m));
387        let (ny, nm) = system.add_months(y, m, 1);
388        y = ny;
389        m = nm;
390    }
391    out
392}
393
394/// The consecutive dates in a week or day view's logical range; empty for a month view,
395/// which builds its cells from lead offsets instead.
396pub fn linear_cells(duration: VisibleDuration, first_day: Weekday, anchor: Date) -> Vec<Date> {
397    let n = duration.count();
398    match duration {
399        VisibleDuration::Months(_) => Vec::new(),
400        VisibleDuration::Weeks(_) => {
401            let start = week_start(anchor, first_day);
402            (0..n as i64 * 7).map(|i| add_days(&start, i)).collect()
403        }
404        VisibleDuration::Days(_) => (0..n as i64).map(|i| add_days(&anchor, i)).collect(),
405    }
406}
407
408/// Day views retain the whole weekday grid. Leading dates are rendered disabled
409/// by the caller; trailing slots after the visible end stay empty.
410pub(crate) fn week_aligned_rows(
411    start: Date,
412    end: Date,
413    first_day: Weekday,
414) -> Vec<[Option<Date>; 7]> {
415    let first = week_start(start, first_day);
416    let rows = (days_from_civil(&end) - days_from_civil(&first)) / 7 + 1;
417    (0..rows)
418        .map(|row| {
419            std::array::from_fn(|column| {
420                let date = add_days(&first, row * 7 + column as i64);
421                (days_from_civil(&date) <= days_from_civil(&end)).then_some(date)
422            })
423        })
424        .collect()
425}
426
427/// The first and last dates in the visible range, excluding month-grid spill
428/// cells. React Stately tests the immediately adjacent days to decide whether
429/// the previous and next controls are disabled.
430pub(crate) fn visible_range(
431    system: &crate::calendar_system::CalendarSystem,
432    duration: VisibleDuration,
433    first_day: Weekday,
434    anchor: Date,
435) -> (Date, Date) {
436    match duration {
437        VisibleDuration::Months(_) => {
438            let months = month_headings_in(system, duration, anchor);
439            let (start_year, start_month) = months[0];
440            let (end_year, end_month) = months[months.len() - 1];
441            (
442                system
443                    .to_gregorian(start_year, start_month, 1)
444                    .unwrap_or(anchor),
445                system
446                    .to_gregorian(
447                        end_year,
448                        end_month,
449                        system.days_in_month(end_year, end_month),
450                    )
451                    .unwrap_or(anchor),
452            )
453        }
454        VisibleDuration::Weeks(_) | VisibleDuration::Days(_) => {
455            let cells = linear_cells(duration, first_day, anchor);
456            (cells[0], cells[cells.len() - 1])
457        }
458    }
459}
460
461/// The heading for a week or day view, e.g. `Aug 3 – Aug 9, 2026`.
462pub fn range_heading(cells: &[Date]) -> String {
463    match (cells.first(), cells.last()) {
464        (Some(a), Some(b)) if a == b => {
465            format!(
466                "{} {}, {}",
467                crate::calendar::month_abbr(a.month),
468                a.day,
469                a.year
470            )
471        }
472        (Some(a), Some(b)) if a.year == b.year => format!(
473            "{} {} \u{2013} {} {}, {}",
474            crate::calendar::month_abbr(a.month),
475            a.day,
476            crate::calendar::month_abbr(b.month),
477            b.day,
478            b.year
479        ),
480        (Some(a), Some(b)) => format!(
481            "{} {}, {} \u{2013} {} {}, {}",
482            crate::calendar::month_abbr(a.month),
483            a.day,
484            a.year,
485            crate::calendar::month_abbr(b.month),
486            b.day,
487            b.year
488        ),
489        _ => String::new(),
490    }
491}
492
493/// The sliding year-picker window around `view_year`.
494///
495/// HeroUI defaults to 20 visible years, except when both date bounds are set:
496/// then it shows their full inclusive year span. An explicit `visibleYears`
497/// wins, and the window stays inside either bound.
498pub(crate) fn year_window(
499    system: &crate::calendar_system::CalendarSystem,
500    view_year: i32,
501    visible_years: Option<usize>,
502    min_value: Option<Date>,
503    max_value: Option<Date>,
504) -> Vec<i32> {
505    let min_year = min_value.map(|date| system.from_gregorian(date).0);
506    let max_year = max_value.map(|date| system.from_gregorian(date).0);
507    let available = min_year.zip(max_year).map(|(min, max)| {
508        let span = i64::from(max) - i64::from(min) + 1;
509        usize::try_from(span.max(1)).unwrap_or(usize::MAX)
510    });
511    let requested = visible_years.or(available).unwrap_or(20).max(1);
512    let count = requested.min(available.unwrap_or(requested));
513    let count_i64 = i64::try_from(count).unwrap_or(i64::MAX);
514    let mut start = i64::from(view_year) - count_i64 / 2;
515
516    if let Some(min) = min_year {
517        start = start.max(i64::from(min));
518    }
519    if let Some(max) = max_year {
520        start = start.min(i64::from(max) - count_i64 + 1);
521    }
522    if let Some(min) = min_year {
523        start = start.max(i64::from(min));
524    }
525
526    (0..count)
527        .filter_map(|offset| {
528            let year = start + i64::try_from(offset).ok()?;
529            i32::try_from(year).ok()
530        })
531        .collect()
532}
533
534#[derive(Clone, Copy, PartialEq, Eq)]
535pub(crate) enum MonthScrollFocus {
536    Day(Date),
537    Year(i32),
538    Heading(usize),
539}
540
541#[derive(Default)]
542struct MonthGridScroll {
543    handle: gpui::ScrollHandle,
544    last_target:
545        std::cell::Cell<Option<(MonthScrollFocus, gpui::Pixels, gpui::Pixels, gpui::Pixels)>>,
546}
547
548pub(crate) fn scrolling_months(
549    content: gpui::AnyElement,
550    base: &gpui::ElementId,
551    reveal: Option<(MonthScrollFocus, gpui::Pixels, gpui::Pixels)>,
552    window: &mut gpui::Window,
553    cx: &mut gpui::App,
554) -> gpui::AnyElement {
555    use gpui::{prelude::*, InteractiveElement, StatefulInteractiveElement};
556    let state = window.use_keyed_state(
557        element_id::scoped(base, "months-scroll-state"),
558        cx,
559        |_, _| std::rc::Rc::new(MonthGridScroll::default()),
560    );
561    let scroll = state.read(cx).clone();
562    let handle = scroll.handle.clone();
563    gpui::div()
564        .on_children_prepainted(move |_, window, cx| {
565            let width = scroll.handle.bounds().size.width;
566            let target = reveal.map(|(focus, left, right)| (focus, left, right, width));
567            if scroll.last_target.replace(target) == target {
568                return;
569            }
570            if let Some((_, left, right, _)) = target {
571                let offset = scroll.handle.offset();
572                let x = if left < -offset.x {
573                    -left
574                } else if right > width - offset.x {
575                    width - right
576                } else {
577                    offset.x
578                };
579                if x != offset.x {
580                    scroll.handle.set_offset(gpui::point(x, offset.y));
581                    window.defer(cx, |window, _| window.refresh());
582                }
583            }
584        })
585        .id(element_id::scoped(base, "months-scroll"))
586        .w_full()
587        .min_w_0()
588        .overflow_x_scroll()
589        .track_scroll(&handle)
590        .flex()
591        .child(content)
592        .into_any_element()
593}
594
595#[derive(Default)]
596pub(crate) struct YearGridScroll {
597    pub(crate) handle: gpui::ScrollHandle,
598    pub(crate) last_target: std::cell::Cell<Option<(i32, usize)>>,
599}
600
601/// The shared render inputs for Calendar and RangeCalendar's year grid.
602pub(crate) struct YearGridView<'a> {
603    pub(crate) years: &'a [i32],
604    pub(crate) active_year: i32,
605    pub(crate) base: &'a gpui::ElementId,
606    pub(crate) scroll: &'a std::rc::Rc<YearGridScroll>,
607    pub(crate) reveal_row: Option<usize>,
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn day_rows_keep_leading_dates_and_pad_the_visible_end() {
616        let rows = week_aligned_rows(d(2026, 9, 2), d(2026, 9, 4), Weekday::Mon);
617        assert_eq!(
618            rows,
619            vec![[
620                Some(d(2026, 8, 31)),
621                Some(d(2026, 9, 1)),
622                Some(d(2026, 9, 2)),
623                Some(d(2026, 9, 3)),
624                Some(d(2026, 9, 4)),
625                None,
626                None
627            ]]
628        );
629        let rows = week_aligned_rows(d(2026, 9, 2), d(2026, 9, 9), Weekday::Sun);
630        assert_eq!(rows.len(), 2);
631        assert_eq!(rows[0][0], Some(d(2026, 8, 30)));
632        assert_eq!(
633            rows[1],
634            [
635                Some(d(2026, 9, 6)),
636                Some(d(2026, 9, 7)),
637                Some(d(2026, 9, 8)),
638                Some(d(2026, 9, 9)),
639                None,
640                None,
641                None
642            ]
643        );
644    }
645
646    #[test]
647    fn explicit_calendar_bounds_and_year_pages_keep_gregorian_values() {
648        let indian =
649            crate::calendar_system::CalendarSystem::for_locale("hi-IN-u-ca-indian").unwrap();
650        assert_eq!(
651            visible_range(
652                &indian,
653                VisibleDuration::Months(1),
654                Weekday::Mon,
655                d(2026, 1, 15)
656            ),
657            (d(2025, 12, 22), d(2026, 1, 20))
658        );
659        assert_eq!(
660            year_window(
661                &indian,
662                1947,
663                None,
664                Some(d(2025, 3, 22)),
665                Some(d(2027, 3, 21))
666            ),
667            vec![1947, 1948]
668        );
669        let hebrew =
670            crate::calendar_system::CalendarSystem::for_locale("en-US-u-ca-hebrew").unwrap();
671        assert_eq!(
672            focus_section_in(
673                &hebrew,
674                VisibleDuration::Months(1),
675                PageBehavior::Single,
676                d(2024, 3, 25),
677                1,
678                true
679            ),
680            d(2025, 3, 15)
681        );
682    }
683
684    fn d(y: i32, m: u32, day: u32) -> Date {
685        Date::new(y, m, day)
686    }
687
688    #[test]
689    fn visible_duration_never_zero() {
690        assert_eq!(VisibleDuration::Months(0).count(), 1);
691        assert_eq!(VisibleDuration::Weeks(0).count(), 1);
692    }
693
694    #[test]
695    fn month_view_pages_by_visible_range() {
696        let two = VisibleDuration::Months(2);
697        // 'visible' advances the whole range; 'single' moves one month.
698        assert_eq!(
699            page(two, PageBehavior::Visible, d(2026, 8, 1), 1),
700            d(2026, 10, 1)
701        );
702        assert_eq!(
703            page(two, PageBehavior::Single, d(2026, 8, 1), 1),
704            d(2026, 9, 1)
705        );
706    }
707
708    #[test]
709    fn month_paging_clamps_into_short_months() {
710        // Jan 31 -> Feb must not yield Feb 31.
711        let one = VisibleDuration::Months(1);
712        assert_eq!(
713            page(one, PageBehavior::Visible, d(2026, 1, 31), 1),
714            d(2026, 2, 28)
715        );
716        assert_eq!(
717            page(one, PageBehavior::Visible, d(2024, 1, 31), 1),
718            d(2024, 2, 29)
719        );
720    }
721
722    #[test]
723    fn month_paging_crosses_the_year() {
724        let one = VisibleDuration::Months(1);
725        assert_eq!(
726            page(one, PageBehavior::Visible, d(2026, 12, 5), 1),
727            d(2027, 1, 5)
728        );
729        assert_eq!(
730            page(one, PageBehavior::Visible, d(2026, 1, 5), -1),
731            d(2025, 12, 5)
732        );
733    }
734
735    #[test]
736    fn week_and_day_views_page_in_days() {
737        let w = VisibleDuration::Weeks(2);
738        assert_eq!(
739            page(w, PageBehavior::Visible, d(2026, 8, 3), 1),
740            d(2026, 8, 17)
741        );
742        assert_eq!(
743            page(w, PageBehavior::Single, d(2026, 8, 3), 1),
744            d(2026, 8, 10)
745        );
746        let day = VisibleDuration::Days(3);
747        assert_eq!(
748            page(day, PageBehavior::Visible, d(2026, 8, 3), 1),
749            d(2026, 8, 6)
750        );
751        assert_eq!(
752            page(day, PageBehavior::Single, d(2026, 8, 3), -1),
753            d(2026, 8, 2)
754        );
755    }
756
757    #[test]
758    fn focused_sections_follow_the_displayed_unit() {
759        let at = d(2026, 8, 15);
760        assert_eq!(
761            focus_section(
762                VisibleDuration::Weeks(2),
763                PageBehavior::Visible,
764                at,
765                1,
766                false
767            ),
768            d(2026, 8, 22)
769        );
770        assert_eq!(
771            focus_section(
772                VisibleDuration::Weeks(2),
773                PageBehavior::Visible,
774                at,
775                1,
776                true
777            ),
778            d(2026, 9, 15)
779        );
780        assert_eq!(
781            focus_section(VisibleDuration::Days(3), PageBehavior::Visible, at, 1, true),
782            d(2026, 8, 18)
783        );
784        assert_eq!(
785            focus_section(
786                VisibleDuration::Days(3),
787                PageBehavior::Single,
788                at,
789                -1,
790                false
791            ),
792            d(2026, 8, 14)
793        );
794    }
795
796    #[test]
797    fn week_start_honours_first_day_of_week() {
798        // 2026-08-22 is a Saturday.
799        assert_eq!(week_start(d(2026, 8, 22), Weekday::Mon), d(2026, 8, 17));
800        assert_eq!(week_start(d(2026, 8, 22), Weekday::Sun), d(2026, 8, 16));
801        assert_eq!(week_start(d(2026, 8, 22), Weekday::Sat), d(2026, 8, 22));
802    }
803
804    #[test]
805    fn alignment_places_the_selection() {
806        let two = VisibleDuration::Months(2);
807        // React Aria's alignCenter halves the duration then backs off again on
808        // an even count, so a 2-up range leads by nothing and Center == Start.
809        assert_eq!(
810            aligned_anchor(
811                two,
812                SelectionAlignment::Center,
813                Weekday::Mon,
814                d(2026, 8, 10)
815            ),
816            d(2026, 8, 10)
817        );
818        assert_eq!(
819            aligned_anchor(two, SelectionAlignment::Start, Weekday::Mon, d(2026, 8, 10)),
820            d(2026, 8, 10)
821        );
822        // A 3-up range does centre exactly.
823        assert_eq!(
824            aligned_anchor(
825                VisibleDuration::Months(3),
826                SelectionAlignment::Center,
827                Weekday::Mon,
828                d(2026, 8, 10)
829            ),
830            d(2026, 7, 10)
831        );
832        assert_eq!(
833            aligned_anchor(two, SelectionAlignment::End, Weekday::Mon, d(2026, 8, 10)),
834            d(2026, 7, 10)
835        );
836        let three = VisibleDuration::Months(3);
837        assert_eq!(
838            aligned_anchor(three, SelectionAlignment::End, Weekday::Mon, d(2026, 8, 10)),
839            d(2026, 6, 10)
840        );
841    }
842
843    #[test]
844    fn paging_a_non_gregorian_grid_moves_one_of_its_own_months() {
845        let system =
846            crate::calendar_system::CalendarSystem::for_locale("hi-IN-u-ca-indian").unwrap();
847        // 15 January 2026 is 25 Pausha 1947. One page forward must land in the
848        // *next Indian* month, not one Gregorian month later.
849        let anchor = d(2026, 1, 15);
850        let next = page_in(
851            &system,
852            VisibleDuration::Months(1),
853            PageBehavior::Single,
854            anchor,
855            1,
856        );
857        assert_eq!(system.from_gregorian(next).0, 1947);
858        assert_eq!(
859            system.from_gregorian(next).1,
860            11,
861            "paging forward from Pausha must reach Magha"
862        );
863        // ...and back again returns to the month it came from.
864        let back = page_in(
865            &system,
866            VisibleDuration::Months(1),
867            PageBehavior::Single,
868            next,
869            -1,
870        );
871        assert_eq!(system.from_gregorian(back).1, 10);
872    }
873
874    #[test]
875    fn month_headings_walk_forward_from_the_anchor() {
876        assert_eq!(
877            month_headings(VisibleDuration::Months(3), d(2026, 11, 1)),
878            vec![(2026, 11), (2026, 12), (2027, 1)]
879        );
880        assert!(month_headings(VisibleDuration::Weeks(2), d(2026, 8, 1)).is_empty());
881    }
882
883    #[test]
884    fn linear_cells_are_contiguous() {
885        let cells = linear_cells(VisibleDuration::Weeks(2), Weekday::Mon, d(2026, 8, 22));
886        assert_eq!(cells.len(), 14);
887        assert_eq!(cells[0], d(2026, 8, 17));
888        assert_eq!(cells[13], d(2026, 8, 30));
889        let days = linear_cells(VisibleDuration::Days(3), Weekday::Mon, d(2026, 8, 30));
890        assert_eq!(days, vec![d(2026, 8, 30), d(2026, 8, 31), d(2026, 9, 1)]);
891    }
892
893    #[test]
894    fn visible_range_excludes_month_spill_and_spans_linear_views() {
895        assert_eq!(
896            visible_range(
897                crate::calendar_system::system(),
898                VisibleDuration::Months(2),
899                Weekday::Mon,
900                d(2026, 8, 15)
901            ),
902            (d(2026, 8, 1), d(2026, 9, 30))
903        );
904        assert_eq!(
905            visible_range(
906                crate::calendar_system::system(),
907                VisibleDuration::Weeks(1),
908                Weekday::Mon,
909                d(2026, 8, 22)
910            ),
911            (d(2026, 8, 17), d(2026, 8, 23))
912        );
913        assert_eq!(
914            visible_range(
915                crate::calendar_system::system(),
916                VisibleDuration::Days(3),
917                Weekday::Mon,
918                d(2026, 8, 30)
919            ),
920            (d(2026, 8, 30), d(2026, 9, 1))
921        );
922    }
923
924    #[test]
925    fn range_heading_collapses_and_spans() {
926        let one = linear_cells(VisibleDuration::Days(1), Weekday::Mon, d(2026, 8, 3));
927        assert_eq!(range_heading(&one), "Aug 3, 2026");
928        let week = linear_cells(VisibleDuration::Weeks(1), Weekday::Mon, d(2026, 8, 5));
929        assert_eq!(range_heading(&week), "Aug 3 \u{2013} Aug 9, 2026");
930        let span = linear_cells(VisibleDuration::Days(3), Weekday::Mon, d(2026, 12, 31));
931        assert_eq!(span, vec![d(2026, 12, 31), d(2027, 1, 1), d(2027, 1, 2)]);
932        assert_eq!(range_heading(&span), "Dec 31, 2026 \u{2013} Jan 2, 2027");
933    }
934
935    #[test]
936    fn year_window_centers_and_clamps_to_bounds() {
937        let years = year_window(crate::calendar_system::system(), 2026, None, None, None);
938        assert_eq!(years.len(), 20);
939        assert_eq!(years[10], 2026);
940
941        assert_eq!(
942            year_window(
943                crate::calendar_system::system(),
944                2026,
945                None,
946                Some(d(2024, 6, 1)),
947                Some(d(2028, 6, 1))
948            ),
949            vec![2024, 2025, 2026, 2027, 2028]
950        );
951        assert_eq!(
952            year_window(
953                crate::calendar_system::system(),
954                2026,
955                Some(3),
956                Some(d(2024, 6, 1)),
957                Some(d(2028, 6, 1))
958            ),
959            vec![2025, 2026, 2027]
960        );
961        assert_eq!(
962            year_window(
963                crate::calendar_system::system(),
964                2024,
965                Some(3),
966                Some(d(2024, 6, 1)),
967                None
968            ),
969            vec![2024, 2025, 2026]
970        );
971        assert_eq!(
972            year_window(
973                crate::calendar_system::system(),
974                2028,
975                Some(3),
976                None,
977                Some(d(2028, 6, 1))
978            ),
979            vec![2026, 2027, 2028]
980        );
981    }
982
983    /// React Aria's `offset={months: n}` adds the duration before formatting.
984    /// Each calendar now applies that offset in its own view system, so this
985    /// pins the pair the components compose: step, then name the month.
986    #[test]
987    fn month_heading_offset_crosses_the_year() {
988        let system = crate::calendar_system::CalendarSystem::for_locale("en-US").unwrap();
989        let heading = |year: i32, month: u32, offset: i32| {
990            let (year, month) = system.add_months(year, month, offset);
991            crate::calendar::month_heading_for_locale(system.locale(), year, month).unwrap()
992        };
993        assert_eq!(heading(2026, 8, 0), "August 2026");
994        assert_eq!(heading(2026, 12, 2), "February 2027");
995        assert_eq!(heading(2026, 1, -2), "November 2025");
996    }
997}