| 3 |
3 |
|
use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
|
| 4 |
4 |
|
use crate::models::Recurrence;
|
| 5 |
5 |
|
|
| 6 |
|
- |
/// Calculate the next due date based on recurrence type
|
|
6 |
+ |
/// Calculate the next due date based on recurrence type.
|
| 7 |
7 |
|
///
|
| 8 |
8 |
|
/// The next occurrence is calculated from the original due date,
|
| 9 |
9 |
|
/// not from when the task was completed. This ensures consistent
|
| 10 |
10 |
|
/// scheduling (e.g., "every Monday" stays on Mondays).
|
|
11 |
+ |
///
|
|
12 |
+ |
/// For monthly recurrence, `original_day` can specify the intended
|
|
13 |
+ |
/// day-of-month to prevent drift when months have fewer days
|
|
14 |
+ |
/// (e.g., Jan 31 -> Feb 28 -> Mar 31 instead of Mar 28).
|
| 11 |
15 |
|
pub fn calculate_next_due(
|
| 12 |
16 |
|
current_due: Option<&DateTime<Utc>>,
|
| 13 |
17 |
|
recurrence: &Recurrence,
|
| 14 |
18 |
|
) -> Option<DateTime<Utc>> {
|
|
19 |
+ |
// For monthly recurrence, detect end-of-month dates and preserve intent.
|
|
20 |
+ |
// If the due date falls on the last day of its month (e.g., Feb 28 in a
|
|
21 |
+ |
// non-leap year), treat the target as day 31 so it snaps to end-of-month
|
|
22 |
+ |
// in longer months rather than drifting to the 28th permanently.
|
|
23 |
+ |
let target_day = if matches!(recurrence, Recurrence::Monthly) {
|
|
24 |
+ |
current_due.and_then(|dt| {
|
|
25 |
+ |
let day = dt.day();
|
|
26 |
+ |
let month_len = days_in_month(dt.year(), dt.month());
|
|
27 |
+ |
if day == month_len && day < 31 {
|
|
28 |
+ |
Some(31)
|
|
29 |
+ |
} else {
|
|
30 |
+ |
None
|
|
31 |
+ |
}
|
|
32 |
+ |
})
|
|
33 |
+ |
} else {
|
|
34 |
+ |
None
|
|
35 |
+ |
};
|
|
36 |
+ |
calculate_next_due_with_day(current_due, recurrence, target_day)
|
|
37 |
+ |
}
|
|
38 |
+ |
|
|
39 |
+ |
/// Like `calculate_next_due` but accepts an explicit target day-of-month
|
|
40 |
+ |
/// for monthly recurrence to prevent day drift across short months.
|
|
41 |
+ |
pub fn calculate_next_due_with_day(
|
|
42 |
+ |
current_due: Option<&DateTime<Utc>>,
|
|
43 |
+ |
recurrence: &Recurrence,
|
|
44 |
+ |
original_day: Option<u32>,
|
|
45 |
+ |
) -> Option<DateTime<Utc>> {
|
| 15 |
46 |
|
let base_date = current_due.copied().unwrap_or_else(Utc::now);
|
| 16 |
47 |
|
|
| 17 |
48 |
|
match recurrence {
|
| 18 |
49 |
|
Recurrence::Daily => Some(base_date + Duration::days(1)),
|
| 19 |
50 |
|
Recurrence::Weekly => Some(base_date + Duration::weeks(1)),
|
| 20 |
51 |
|
Recurrence::Monthly => {
|
| 21 |
|
- |
// Add one month - use chrono's month arithmetic
|
| 22 |
|
- |
let next = add_months(base_date, 1);
|
|
52 |
+ |
let next = add_months(base_date, 1, original_day);
|
| 23 |
53 |
|
Some(next)
|
| 24 |
54 |
|
}
|
| 25 |
55 |
|
Recurrence::None => None,
|
| 33 |
63 |
|
/// Jan 31 + 1 month → Feb 28 (or 29 in a leap year)
|
| 34 |
64 |
|
/// Mar 31 + 1 month → Apr 30
|
| 35 |
65 |
|
///
|
|
66 |
+ |
/// When `target_day` is provided, uses that as the intended day-of-month
|
|
67 |
+ |
/// instead of `dt.day()`, preventing drift across short months:
|
|
68 |
+ |
/// Jan 31 (target=31) + 1 → Feb 28, then Feb 28 (target=31) + 1 → Mar 31
|
|
69 |
+ |
///
|
| 36 |
70 |
|
/// Preserves the original hour/minute/second. Falls back to the input datetime
|
| 37 |
71 |
|
/// if the target date is ambiguous (e.g., DST gap via `with_ymd_and_hms`).
|
| 38 |
|
- |
fn add_months(dt: DateTime<Utc>, months: i32) -> DateTime<Utc> {
|
|
72 |
+ |
fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>) -> DateTime<Utc> {
|
| 39 |
73 |
|
|
| 40 |
74 |
|
let year = dt.year();
|
| 41 |
75 |
|
let month = dt.month() as i32;
|
| 42 |
|
- |
let day = dt.day();
|
|
76 |
+ |
let day = target_day.unwrap_or(dt.day());
|
| 43 |
77 |
|
|
| 44 |
78 |
|
let total_months = year * 12 + month - 1 + months;
|
| 45 |
|
- |
let new_year = total_months / 12;
|
| 46 |
|
- |
let new_month = (total_months % 12 + 1) as u32;
|
|
79 |
+ |
let new_year = total_months.div_euclid(12);
|
|
80 |
+ |
let new_month = (total_months.rem_euclid(12) + 1) as u32;
|
| 47 |
81 |
|
|
| 48 |
82 |
|
// Handle end-of-month edge cases (e.g., Jan 31 -> Feb 28)
|
| 49 |
83 |
|
let days_in_new_month = days_in_month(new_year, new_month);
|
| 172 |
206 |
|
}
|
| 173 |
207 |
|
|
| 174 |
208 |
|
#[test]
|
| 175 |
|
- |
fn test_monthly_feb_28_to_march() {
|
| 176 |
|
- |
// Feb 28, 2026 -> Mar 28, 2026
|
|
209 |
+ |
fn test_monthly_feb_28_to_march_end_of_month() {
|
|
210 |
+ |
// Feb 28 is end-of-month in non-leap year, so target snaps to 31 -> Mar 31
|
| 177 |
211 |
|
let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
|
| 178 |
212 |
|
let next = calculate_next_due(Some(&feb_28), &Recurrence::Monthly).unwrap();
|
| 179 |
213 |
|
assert_eq!(next.month(), 3);
|
|
214 |
+ |
assert_eq!(next.day(), 31);
|
|
215 |
+ |
}
|
|
216 |
+ |
|
|
217 |
+ |
#[test]
|
|
218 |
+ |
fn test_monthly_feb_28_explicit_day_28() {
|
|
219 |
+ |
// With explicit target day 28, Feb 28 -> Mar 28 (no end-of-month heuristic)
|
|
220 |
+ |
let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
|
|
221 |
+ |
let next = calculate_next_due_with_day(Some(&feb_28), &Recurrence::Monthly, Some(28)).unwrap();
|
|
222 |
+ |
assert_eq!(next.month(), 3);
|
| 180 |
223 |
|
assert_eq!(next.day(), 28);
|
| 181 |
224 |
|
}
|
| 182 |
225 |
|
|