Rust · 13192 bytes Raw Blame History
1 use std::{array, fmt};
2
3 use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
4 use time::{Date, Month, Weekday};
5
6 pub const DAYS_PER_WEEK: usize = 7;
7 pub const MONTH_GRID_WEEKS: usize = 6;
8 pub const MONTH_GRID_CELLS: usize = DAYS_PER_WEEK * MONTH_GRID_WEEKS;
9
10 pub const SUNDAY_FIRST_WEEKDAYS: [Weekday; DAYS_PER_WEEK] = [
11 Weekday::Sunday,
12 Weekday::Monday,
13 Weekday::Tuesday,
14 Weekday::Wednesday,
15 Weekday::Thursday,
16 Weekday::Friday,
17 Weekday::Saturday,
18 ];
19
20 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
21 pub struct CalendarDate(Date);
22
23 impl CalendarDate {
24 pub const fn new(date: Date) -> Self {
25 Self(date)
26 }
27
28 pub fn from_ymd(year: i32, month: Month, day: u8) -> Result<Self, time::error::ComponentRange> {
29 Date::from_calendar_date(year, month, day).map(Self)
30 }
31
32 pub const fn inner(self) -> Date {
33 self.0
34 }
35
36 pub const fn year(self) -> i32 {
37 self.0.year()
38 }
39
40 pub const fn month(self) -> Month {
41 self.0.month()
42 }
43
44 pub const fn day(self) -> u8 {
45 self.0.day()
46 }
47
48 pub const fn weekday(self) -> Weekday {
49 self.0.weekday()
50 }
51
52 pub fn add_days(self, days: i32) -> Self {
53 let mut date = self.0;
54
55 if days >= 0 {
56 for _ in 0..days {
57 date = date
58 .next_day()
59 .expect("calendar navigation requires a representable next day");
60 }
61 } else {
62 for _ in 0..days.saturating_abs() {
63 date = date
64 .previous_day()
65 .expect("calendar navigation requires a representable previous day");
66 }
67 }
68
69 Self(date)
70 }
71 }
72
73 impl From<Date> for CalendarDate {
74 fn from(value: Date) -> Self {
75 Self(value)
76 }
77 }
78
79 impl From<CalendarDate> for Date {
80 fn from(value: CalendarDate) -> Self {
81 value.0
82 }
83 }
84
85 impl fmt::Display for CalendarDate {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 write!(
88 f,
89 "{:04}-{:02}-{:02}",
90 self.year(),
91 u8::from(self.month()),
92 self.day()
93 )
94 }
95 }
96
97 impl Serialize for CalendarDate {
98 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99 where
100 S: Serializer,
101 {
102 serializer.serialize_str(&self.to_string())
103 }
104 }
105
106 impl<'de> Deserialize<'de> for CalendarDate {
107 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108 where
109 D: Deserializer<'de>,
110 {
111 let value = String::deserialize(deserializer)?;
112 parse_calendar_date(&value)
113 .ok_or_else(|| de::Error::custom(format!("invalid calendar date '{value}'")))
114 }
115 }
116
117 fn parse_calendar_date(value: &str) -> Option<CalendarDate> {
118 let mut parts = value.split('-');
119 let year = parts.next()?.parse().ok()?;
120 let month = Month::try_from(parts.next()?.parse::<u8>().ok()?).ok()?;
121 let day = parts.next()?.parse().ok()?;
122 if parts.next().is_some() {
123 return None;
124 }
125 CalendarDate::from_ymd(year, month, day).ok()
126 }
127
128 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
129 pub struct MonthId {
130 pub year: i32,
131 pub month: Month,
132 }
133
134 impl MonthId {
135 pub const fn new(year: i32, month: Month) -> Self {
136 Self { year, month }
137 }
138
139 pub fn from_date(date: CalendarDate) -> Self {
140 Self {
141 year: date.year(),
142 month: date.month(),
143 }
144 }
145
146 pub fn first_day(self) -> CalendarDate {
147 CalendarDate::from_ymd(self.year, self.month, 1).expect("month metadata is valid")
148 }
149
150 pub const fn length(self) -> u8 {
151 self.month.length(self.year)
152 }
153
154 pub const fn previous(self) -> Self {
155 let month = self.month.previous();
156 let year = if matches!(self.month, Month::January) {
157 self.year - 1
158 } else {
159 self.year
160 };
161
162 Self { year, month }
163 }
164
165 pub const fn next(self) -> Self {
166 let month = self.month.next();
167 let year = if matches!(self.month, Month::December) {
168 self.year + 1
169 } else {
170 self.year
171 };
172
173 Self { year, month }
174 }
175
176 pub fn date(self, day: u8) -> Option<CalendarDate> {
177 CalendarDate::from_ymd(self.year, self.month, day).ok()
178 }
179 }
180
181 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
182 pub struct Selection {
183 pub selected_date: CalendarDate,
184 }
185
186 impl Selection {
187 pub const fn new(selected_date: CalendarDate) -> Self {
188 Self { selected_date }
189 }
190
191 pub const fn from_launch_date(start_date: CalendarDate) -> Self {
192 Self::new(start_date)
193 }
194 }
195
196 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
197 pub struct CalendarCell {
198 pub date: CalendarDate,
199 pub weekday: Weekday,
200 pub week_index: usize,
201 pub weekday_index: usize,
202 pub is_in_visible_month: bool,
203 pub is_today: bool,
204 pub is_selected: bool,
205 }
206
207 impl CalendarCell {
208 pub const fn is_filler(self) -> bool {
209 !self.is_in_visible_month
210 }
211 }
212
213 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
214 pub struct CalendarWeek {
215 pub index: usize,
216 pub cells: [CalendarCell; DAYS_PER_WEEK],
217 }
218
219 #[derive(Debug, Clone, PartialEq, Eq)]
220 pub struct CalendarMonth {
221 pub current: MonthId,
222 pub previous: MonthId,
223 pub next: MonthId,
224 pub month_length: u8,
225 pub week_start: Weekday,
226 pub weekdays: [Weekday; DAYS_PER_WEEK],
227 pub selection: Selection,
228 pub today: CalendarDate,
229 pub weeks: [CalendarWeek; MONTH_GRID_WEEKS],
230 }
231
232 impl CalendarMonth {
233 pub fn for_launch_date(start_date: CalendarDate) -> Self {
234 Self::from_selection(Selection::from_launch_date(start_date), start_date)
235 }
236
237 pub fn from_dates(selected_date: CalendarDate, today: CalendarDate) -> Self {
238 Self::from_selection(Selection::new(selected_date), today)
239 }
240
241 pub fn from_selection(selection: Selection, today: CalendarDate) -> Self {
242 let current = MonthId::from_date(selection.selected_date);
243 let month_length = current.length();
244 let first_visible_date = first_visible_date(current.first_day());
245 let weeks = build_weeks(first_visible_date, current, selection, today);
246
247 Self {
248 current,
249 previous: current.previous(),
250 next: current.next(),
251 month_length,
252 week_start: Weekday::Sunday,
253 weekdays: SUNDAY_FIRST_WEEKDAYS,
254 selection,
255 today,
256 weeks,
257 }
258 }
259
260 pub fn cells(&self) -> impl Iterator<Item = &CalendarCell> {
261 self.weeks.iter().flat_map(|week| week.cells.iter())
262 }
263
264 pub fn selected_cell(&self) -> Option<&CalendarCell> {
265 self.cells().find(|cell| cell.is_selected)
266 }
267
268 pub fn today_cell(&self) -> Option<&CalendarCell> {
269 self.cells().find(|cell| cell.is_today)
270 }
271 }
272
273 fn build_weeks(
274 first_visible_date: CalendarDate,
275 current: MonthId,
276 selection: Selection,
277 today: CalendarDate,
278 ) -> [CalendarWeek; MONTH_GRID_WEEKS] {
279 array::from_fn(|week_index| CalendarWeek {
280 index: week_index,
281 cells: array::from_fn(|weekday_index| {
282 let day_offset = week_index * DAYS_PER_WEEK + weekday_index;
283 let date = add_days(first_visible_date, day_offset);
284
285 CalendarCell {
286 date,
287 weekday: SUNDAY_FIRST_WEEKDAYS[weekday_index],
288 week_index,
289 weekday_index,
290 is_in_visible_month: MonthId::from_date(date) == current,
291 is_today: date == today,
292 is_selected: date == selection.selected_date,
293 }
294 }),
295 })
296 }
297
298 fn first_visible_date(first_of_month: CalendarDate) -> CalendarDate {
299 let mut date = first_of_month.inner();
300
301 for _ in 0..first_of_month.weekday().number_days_from_sunday() {
302 date = date
303 .previous_day()
304 .expect("calendar grid requires a representable previous day");
305 }
306
307 CalendarDate::from(date)
308 }
309
310 fn add_days(start: CalendarDate, days: usize) -> CalendarDate {
311 let mut date = start.inner();
312
313 for _ in 0..days {
314 date = date
315 .next_day()
316 .expect("calendar grid requires a representable next day");
317 }
318
319 CalendarDate::from(date)
320 }
321
322 #[cfg(test)]
323 mod tests {
324 use super::*;
325 use time::Month::{
326 April, August, December, February, January, June, March, May, November, October, September,
327 };
328
329 fn date(year: i32, month: Month, day: u8) -> CalendarDate {
330 CalendarDate::from_ymd(year, month, day).expect("valid test date")
331 }
332
333 #[test]
334 fn month_lengths_include_leap_year_february() {
335 let leap = CalendarMonth::for_launch_date(date(2024, February, 10));
336 let common = CalendarMonth::for_launch_date(date(2026, February, 10));
337
338 assert_eq!(leap.month_length, 29);
339 assert_eq!(common.month_length, 28);
340 }
341
342 #[test]
343 fn grid_has_stable_six_week_sunday_first_shape() {
344 let month = CalendarMonth::for_launch_date(date(2026, April, 23));
345
346 assert_eq!(month.week_start, Weekday::Sunday);
347 assert_eq!(month.weekdays, SUNDAY_FIRST_WEEKDAYS);
348 assert_eq!(month.weeks.len(), MONTH_GRID_WEEKS);
349 assert_eq!(month.cells().count(), MONTH_GRID_CELLS);
350 assert!(
351 month
352 .weeks
353 .iter()
354 .all(|week| week.cells.len() == DAYS_PER_WEEK)
355 );
356 }
357
358 #[test]
359 fn months_beginning_on_each_weekday_have_matching_padding() {
360 let examples = [
361 (date(2024, September, 1), Weekday::Sunday),
362 (date(2024, April, 1), Weekday::Monday),
363 (date(2024, October, 1), Weekday::Tuesday),
364 (date(2024, May, 1), Weekday::Wednesday),
365 (date(2024, August, 1), Weekday::Thursday),
366 (date(2024, November, 1), Weekday::Friday),
367 (date(2024, June, 1), Weekday::Saturday),
368 ];
369
370 for (first_day, weekday) in examples {
371 let month = CalendarMonth::for_launch_date(first_day);
372 let first_in_month = month
373 .cells()
374 .find(|cell| cell.date == first_day)
375 .expect("first day appears in its month grid");
376
377 assert_eq!(first_in_month.weekday, weekday);
378 assert_eq!(
379 first_in_month.weekday_index,
380 usize::from(weekday.number_days_from_sunday())
381 );
382 }
383 }
384
385 #[test]
386 fn filler_cells_include_previous_and_next_month_dates() {
387 let month = CalendarMonth::for_launch_date(date(2026, April, 23));
388
389 let first = month.weeks[0].cells[0];
390 let last = month.weeks[MONTH_GRID_WEEKS - 1].cells[DAYS_PER_WEEK - 1];
391
392 assert_eq!(first.date, date(2026, March, 29));
393 assert!(first.is_filler());
394 assert_eq!(last.date, date(2026, May, 9));
395 assert!(last.is_filler());
396 }
397
398 #[test]
399 fn selection_initializes_from_launch_date() {
400 let start_date = date(2026, April, 23);
401 let month = CalendarMonth::for_launch_date(start_date);
402
403 assert_eq!(
404 month.selection,
405 Selection {
406 selected_date: start_date
407 }
408 );
409 assert_eq!(
410 month.selected_cell().map(|cell| cell.date),
411 Some(start_date)
412 );
413 assert_eq!(month.today_cell().map(|cell| cell.date), Some(start_date));
414 }
415
416 #[test]
417 fn selected_date_and_today_can_be_marked_separately() {
418 let selected = date(2026, April, 18);
419 let today = date(2026, April, 23);
420 let month = CalendarMonth::from_dates(selected, today);
421
422 let selected_cell = month.selected_cell().expect("selected cell exists");
423 let today_cell = month.today_cell().expect("today cell exists");
424
425 assert_eq!(selected_cell.date, selected);
426 assert!(selected_cell.is_selected);
427 assert!(!selected_cell.is_today);
428
429 assert_eq!(today_cell.date, today);
430 assert!(today_cell.is_today);
431 assert!(!today_cell.is_selected);
432 }
433
434 #[test]
435 fn previous_and_next_metadata_cross_year_boundaries() {
436 let january = CalendarMonth::for_launch_date(date(2026, January, 1));
437 let december = CalendarMonth::for_launch_date(date(2026, December, 1));
438
439 assert_eq!(january.previous, MonthId::new(2025, December));
440 assert_eq!(january.next, MonthId::new(2026, February));
441
442 assert_eq!(december.previous, MonthId::new(2026, November));
443 assert_eq!(december.next, MonthId::new(2027, January));
444 }
445
446 #[test]
447 fn visible_month_contains_exactly_its_own_days() {
448 let month = CalendarMonth::for_launch_date(date(2026, February, 10));
449
450 let visible_days = month
451 .cells()
452 .filter(|cell| cell.is_in_visible_month)
453 .map(|cell| cell.date.day())
454 .collect::<Vec<_>>();
455
456 assert_eq!(visible_days.len(), usize::from(month.month_length));
457 assert_eq!(visible_days.first(), Some(&1));
458 assert_eq!(visible_days.last(), Some(&28));
459 }
460 }
461