| 1 |
1 |
|
//! Recurring task and event scheduling logic.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! Recurrence preserves the **local wall-clock time-of-day** in a target time
|
|
4 |
+ |
//! zone, not the UTC instant. A task set to recur "every day at 09:00" must stay
|
|
5 |
+ |
//! at 09:00 local across a daylight-saving transition; doing the arithmetic in
|
|
6 |
+ |
//! UTC would drift it by an hour twice a year (ultra-fuzz Run #28 MINOR). Every
|
|
7 |
+ |
//! computation therefore takes a `chrono_tz::Tz`: field extraction and result
|
|
8 |
+ |
//! construction happen in that zone, and only the boundaries convert to/from UTC.
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! The `*_in_tz` functions are canonical. The legacy non-`tz` wrappers pass
|
|
11 |
+ |
//! `Tz::UTC` (no DST), preserving their original behavior for callers and tests
|
|
12 |
+ |
//! that operate purely in UTC; application code resolves the system zone and
|
|
13 |
+ |
//! calls the `*_in_tz` variants.
|
| 2 |
14 |
|
|
| 3 |
|
- |
use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
|
|
15 |
+ |
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Timelike, Utc};
|
|
16 |
+ |
use chrono_tz::Tz;
|
| 4 |
17 |
|
use uuid::Uuid;
|
| 5 |
18 |
|
use crate::models::{Event, Recurrence, RecurrenceRule, MonthlySpec};
|
| 6 |
19 |
|
|
| 7 |
|
- |
/// Calculate the next due date based on recurrence type.
|
|
20 |
+ |
/// Calculate the next due date based on recurrence type (UTC; no DST handling).
|
| 8 |
21 |
|
///
|
| 9 |
|
- |
/// The next occurrence is calculated from the original due date,
|
| 10 |
|
- |
/// not from when the task was completed. This ensures consistent
|
| 11 |
|
- |
/// scheduling (e.g., "every Monday" stays on Mondays).
|
| 12 |
|
- |
///
|
| 13 |
|
- |
/// For monthly recurrence, `original_day` can specify the intended
|
| 14 |
|
- |
/// day-of-month to prevent drift when months have fewer days
|
| 15 |
|
- |
/// (e.g., Jan 31 -> Feb 28 -> Mar 31 instead of Mar 28).
|
|
22 |
+ |
/// Thin wrapper over [`calculate_next_due_in_tz`] with `Tz::UTC`. Application
|
|
23 |
+ |
/// code should call the `_in_tz` form with the user's zone so a fixed local
|
|
24 |
+ |
/// time-of-day survives DST transitions.
|
| 16 |
25 |
|
pub fn calculate_next_due(
|
| 17 |
26 |
|
current_due: Option<&DateTime<Utc>>,
|
| 18 |
27 |
|
recurrence: &Recurrence,
|
|
28 |
+ |
) -> Option<DateTime<Utc>> {
|
|
29 |
+ |
calculate_next_due_in_tz(current_due, recurrence, Tz::UTC)
|
|
30 |
+ |
}
|
|
31 |
+ |
|
|
32 |
+ |
/// Calculate the next due date based on recurrence type, in `tz`.
|
|
33 |
+ |
///
|
|
34 |
+ |
/// The next occurrence is calculated from the original due date,
|
|
35 |
+ |
/// not from when the task was completed. This ensures consistent
|
|
36 |
+ |
/// scheduling (e.g., "every Monday" stays on Mondays) and keeps the
|
|
37 |
+ |
/// local time-of-day stable across DST.
|
|
38 |
+ |
pub fn calculate_next_due_in_tz(
|
|
39 |
+ |
current_due: Option<&DateTime<Utc>>,
|
|
40 |
+ |
recurrence: &Recurrence,
|
|
41 |
+ |
tz: Tz,
|
| 19 |
42 |
|
) -> Option<DateTime<Utc>> {
|
| 20 |
43 |
|
// For monthly recurrence, detect end-of-month dates and preserve intent.
|
| 21 |
44 |
|
// If the due date falls on the last day of its month AND the day is >= 29,
|
| 22 |
45 |
|
// treat the target as day 31 so it snaps to end-of-month in longer months.
|
| 23 |
46 |
|
// The >= 29 guard prevents false positives: Feb 28 in a non-leap year is
|
| 24 |
47 |
|
// ambiguous (user may have meant "the 28th"), but days 29-30 at month-end
|
| 25 |
|
- |
// clearly indicate end-of-month intent.
|
|
48 |
+ |
// clearly indicate end-of-month intent. Day/month are read in `tz`.
|
| 26 |
49 |
|
let target_day = if matches!(recurrence, Recurrence::Monthly) {
|
| 27 |
50 |
|
current_due.and_then(|dt| {
|
| 28 |
|
- |
let day = dt.day();
|
| 29 |
|
- |
let month_len = days_in_month(dt.year(), dt.month());
|
|
51 |
+ |
let local = dt.with_timezone(&tz);
|
|
52 |
+ |
let day = local.day();
|
|
53 |
+ |
let month_len = days_in_month(local.year(), local.month());
|
| 30 |
54 |
|
if day == month_len && day >= 29 && day < 31 {
|
| 31 |
55 |
|
Some(31)
|
| 32 |
56 |
|
} else {
|
| 36 |
60 |
|
} else {
|
| 37 |
61 |
|
None
|
| 38 |
62 |
|
};
|
| 39 |
|
- |
calculate_next_due_with_day(current_due, recurrence, target_day)
|
|
63 |
+ |
calculate_next_due_with_day_in_tz(current_due, recurrence, target_day, tz)
|
| 40 |
64 |
|
}
|
| 41 |
65 |
|
|
| 42 |
66 |
|
/// Like `calculate_next_due` but accepts an explicit target day-of-month
|
| 43 |
|
- |
/// for monthly recurrence to prevent day drift across short months.
|
|
67 |
+ |
/// for monthly recurrence to prevent day drift across short months (UTC wrapper).
|
| 44 |
68 |
|
pub fn calculate_next_due_with_day(
|
| 45 |
69 |
|
current_due: Option<&DateTime<Utc>>,
|
| 46 |
70 |
|
recurrence: &Recurrence,
|
| 47 |
71 |
|
original_day: Option<u32>,
|
|
72 |
+ |
) -> Option<DateTime<Utc>> {
|
|
73 |
+ |
calculate_next_due_with_day_in_tz(current_due, recurrence, original_day, Tz::UTC)
|
|
74 |
+ |
}
|
|
75 |
+ |
|
|
76 |
+ |
/// `calculate_next_due_with_day` evaluated in `tz`. Daily/weekly advance by whole
|
|
77 |
+ |
/// civil days in the zone (so the local time-of-day is stable across DST), not by
|
|
78 |
+ |
/// fixed 24h/168h UTC spans.
|
|
79 |
+ |
pub fn calculate_next_due_with_day_in_tz(
|
|
80 |
+ |
current_due: Option<&DateTime<Utc>>,
|
|
81 |
+ |
recurrence: &Recurrence,
|
|
82 |
+ |
original_day: Option<u32>,
|
|
83 |
+ |
tz: Tz,
|
| 48 |
84 |
|
) -> Option<DateTime<Utc>> {
|
| 49 |
85 |
|
let base_date = current_due.copied().unwrap_or_else(Utc::now);
|
| 50 |
86 |
|
|
| 51 |
87 |
|
match recurrence {
|
| 52 |
|
- |
Recurrence::Daily => Some(base_date + Duration::days(1)),
|
| 53 |
|
- |
Recurrence::Weekly => Some(base_date + Duration::weeks(1)),
|
|
88 |
+ |
Recurrence::Daily => Some(add_civil_days(base_date, 1, tz)),
|
|
89 |
+ |
Recurrence::Weekly => Some(add_civil_days(base_date, 7, tz)),
|
| 54 |
90 |
|
Recurrence::Monthly => {
|
| 55 |
|
- |
let next = add_months(base_date, 1, original_day);
|
|
91 |
+ |
let next = add_months(base_date, 1, original_day, tz);
|
| 56 |
92 |
|
Some(next)
|
| 57 |
93 |
|
}
|
| 58 |
94 |
|
Recurrence::None => None,
|
| 59 |
95 |
|
}
|
| 60 |
96 |
|
}
|
| 61 |
97 |
|
|
| 62 |
|
- |
/// Add months to a DateTime, handling edge cases like month-end dates.
|
|
98 |
+ |
/// Advance `dt` by `days` whole **civil** days in `tz`, holding the local
|
|
99 |
+ |
/// wall-clock time-of-day fixed, then convert back to UTC. This is what keeps a
|
|
100 |
+ |
/// "every day at 09:00 local" task at 09:00 across a DST boundary (a fixed
|
|
101 |
+ |
/// `Duration::days` would shift it by the offset change). Falls back to absolute
|
|
102 |
+ |
/// addition if the reconstructed local time lands in a DST gap/fold.
|
|
103 |
+ |
fn add_civil_days(dt: DateTime<Utc>, days: i64, tz: Tz) -> DateTime<Utc> {
|
|
104 |
+ |
let local = dt.with_timezone(&tz);
|
|
105 |
+ |
let new_date = local.date_naive() + Duration::days(days);
|
|
106 |
+ |
tz.from_local_datetime(&new_date.and_time(local.time()))
|
|
107 |
+ |
.single()
|
|
108 |
+ |
.map(|t| t.with_timezone(&Utc))
|
|
109 |
+ |
.unwrap_or(dt + Duration::days(days))
|
|
110 |
+ |
}
|
|
111 |
+ |
|
|
112 |
+ |
/// Add months to a DateTime in `tz`, handling edge cases like month-end dates.
|
| 63 |
113 |
|
///
|
| 64 |
114 |
|
/// Uses absolute month counting (year*12 + month) to add/subtract months, then
|
| 65 |
115 |
|
/// clamps the day to the target month's length. Examples:
|
| 67 |
117 |
|
/// Mar 31 + 1 month → Apr 30
|
| 68 |
118 |
|
///
|
| 69 |
119 |
|
/// When `target_day` is provided, uses that as the intended day-of-month
|
| 70 |
|
- |
/// instead of `dt.day()`, preventing drift across short months:
|
|
120 |
+ |
/// instead of the zone-local day, preventing drift across short months:
|
| 71 |
121 |
|
/// Jan 31 (target=31) + 1 → Feb 28, then Feb 28 (target=31) + 1 → Mar 31
|
| 72 |
122 |
|
///
|
| 73 |
|
- |
/// Preserves the original hour/minute/second. Falls back to the input datetime
|
| 74 |
|
- |
/// if the target date is ambiguous (e.g., DST gap via `with_ymd_and_hms`).
|
| 75 |
|
- |
fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>) -> DateTime<Utc> {
|
| 76 |
|
- |
|
| 77 |
|
- |
let year = dt.year();
|
| 78 |
|
- |
let month = dt.month() as i32;
|
| 79 |
|
- |
let day = target_day.unwrap_or(dt.day());
|
|
123 |
+ |
/// Reads the date fields and rebuilds the result in `tz`, preserving the local
|
|
124 |
+ |
/// hour/minute/second. Falls back to the input datetime if the target local time
|
|
125 |
+ |
/// is ambiguous (e.g., a DST gap via `with_ymd_and_hms`).
|
|
126 |
+ |
fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>, tz: Tz) -> DateTime<Utc> {
|
|
127 |
+ |
let local = dt.with_timezone(&tz);
|
|
128 |
+ |
let year = local.year();
|
|
129 |
+ |
let month = local.month() as i32;
|
|
130 |
+ |
let day = target_day.unwrap_or(local.day());
|
| 80 |
131 |
|
|
| 81 |
132 |
|
let total_months = year * 12 + month - 1 + months;
|
| 82 |
133 |
|
let new_year = total_months.div_euclid(12);
|
| 86 |
137 |
|
let days_in_new_month = days_in_month(new_year, new_month);
|
| 87 |
138 |
|
let new_day = day.min(days_in_new_month);
|
| 88 |
139 |
|
|
| 89 |
|
- |
Utc.with_ymd_and_hms(new_year, new_month, new_day,
|
| 90 |
|
- |
dt.hour(), dt.minute(), dt.second())
|
|
140 |
+ |
tz.with_ymd_and_hms(new_year, new_month, new_day,
|
|
141 |
+ |
local.hour(), local.minute(), local.second())
|
| 91 |
142 |
|
.single()
|
|
143 |
+ |
.map(|t| t.with_timezone(&Utc))
|
| 92 |
144 |
|
.unwrap_or(dt)
|
| 93 |
145 |
|
}
|
| 94 |
146 |
|
|
| 121 |
173 |
|
0xb1, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
|
| 122 |
174 |
|
]);
|
| 123 |
175 |
|
|
| 124 |
|
- |
/// Calculate the next due date using a rich recurrence rule.
|
| 125 |
|
- |
///
|
| 126 |
|
- |
/// Handles intervals, weekday selection, and monthly specifications.
|
| 127 |
|
- |
/// Falls back to simple recurrence for rules with interval=1 and no extras.
|
|
176 |
+ |
/// Calculate the next due date using a rich recurrence rule (UTC wrapper).
|
| 128 |
177 |
|
pub fn calculate_next_due_rich(
|
| 129 |
178 |
|
current_due: Option<&DateTime<Utc>>,
|
| 130 |
179 |
|
rule: &RecurrenceRule,
|
|
180 |
+ |
) -> Option<DateTime<Utc>> {
|
|
181 |
+ |
calculate_next_due_rich_in_tz(current_due, rule, Tz::UTC)
|
|
182 |
+ |
}
|
|
183 |
+ |
|
|
184 |
+ |
/// Calculate the next due date using a rich recurrence rule, in `tz`.
|
|
185 |
+ |
///
|
|
186 |
+ |
/// Handles intervals, weekday selection, and monthly specifications. Weekday and
|
|
187 |
+ |
/// date fields are read in `tz`, and daily/weekly advance by whole civil days so
|
|
188 |
+ |
/// the local time-of-day is stable across DST.
|
|
189 |
+ |
pub fn calculate_next_due_rich_in_tz(
|
|
190 |
+ |
current_due: Option<&DateTime<Utc>>,
|
|
191 |
+ |
rule: &RecurrenceRule,
|
|
192 |
+ |
tz: Tz,
|
| 131 |
193 |
|
) -> Option<DateTime<Utc>> {
|
| 132 |
194 |
|
if matches!(rule.pattern, Recurrence::None) {
|
| 133 |
195 |
|
return None;
|
| 134 |
196 |
|
}
|
| 135 |
197 |
|
let base = current_due.copied().unwrap_or_else(Utc::now);
|
|
198 |
+ |
let local = base.with_timezone(&tz);
|
| 136 |
199 |
|
let interval = rule.interval.max(1) as i64;
|
| 137 |
200 |
|
|
| 138 |
201 |
|
match rule.pattern {
|
| 139 |
202 |
|
Recurrence::Daily => {
|
| 140 |
|
- |
Some(base + Duration::days(interval))
|
|
203 |
+ |
Some(add_civil_days(base, interval, tz))
|
| 141 |
204 |
|
}
|
| 142 |
205 |
|
Recurrence::Weekly => {
|
| 143 |
206 |
|
if rule.weekdays.is_empty() {
|
| 144 |
|
- |
return Some(base + Duration::weeks(interval));
|
|
207 |
+ |
return Some(add_civil_days(base, interval * 7, tz));
|
| 145 |
208 |
|
}
|
| 146 |
|
- |
// Find next matching weekday
|
| 147 |
|
- |
let current_wd = base.weekday().num_days_from_monday() as u8;
|
|
209 |
+ |
// Find next matching weekday (weekday read in `tz`).
|
|
210 |
+ |
let current_wd = local.weekday().num_days_from_monday() as u8;
|
| 148 |
211 |
|
let mut sorted_days = rule.weekdays.clone();
|
| 149 |
212 |
|
sorted_days.sort_unstable();
|
| 150 |
213 |
|
sorted_days.dedup();
|
| 152 |
215 |
|
// Look for next day in the current week (after current weekday)
|
| 153 |
216 |
|
if let Some(&next_wd) = sorted_days.iter().find(|&&d| d > current_wd) {
|
| 154 |
217 |
|
let diff = (next_wd - current_wd) as i64;
|
| 155 |
|
- |
return Some(base + Duration::days(diff));
|
|
218 |
+ |
return Some(add_civil_days(base, diff, tz));
|
| 156 |
219 |
|
}
|
| 157 |
220 |
|
// Wrap to first day of next interval-week
|
| 158 |
221 |
|
let first_wd = sorted_days[0];
|
| 159 |
222 |
|
let days_to_end = 7 - current_wd as i64;
|
| 160 |
223 |
|
let skip_weeks = (interval - 1) * 7;
|
| 161 |
224 |
|
let days = days_to_end + skip_weeks + first_wd as i64;
|
| 162 |
|
- |
Some(base + Duration::days(days))
|
|
225 |
+ |
Some(add_civil_days(base, days, tz))
|
| 163 |
226 |
|
}
|
| 164 |
227 |
|
Recurrence::Monthly => {
|
| 165 |
228 |
|
match &rule.monthly_spec {
|
| 166 |
229 |
|
Some(MonthlySpec::DayOfMonth { day }) => {
|
| 167 |
|
- |
let next = add_months(base, interval as i32, Some(*day));
|
|
230 |
+ |
let next = add_months(base, interval as i32, Some(*day), tz);
|
| 168 |
231 |
|
Some(next)
|
| 169 |
232 |
|
}
|
| 170 |
233 |
|
Some(MonthlySpec::NthWeekday { week, weekday }) => {
|
| 171 |
|
- |
let next_base = add_months(base, interval as i32, None);
|
|
234 |
+ |
let next_base = add_months(base, interval as i32, None, tz);
|
|
235 |
+ |
let next_local = next_base.with_timezone(&tz);
|
| 172 |
236 |
|
let target = nth_weekday_in_month(
|
| 173 |
|
- |
next_base.year(), next_base.month(),
|
|
237 |
+ |
next_local.year(), next_local.month(),
|
| 174 |
238 |
|
*week, *weekday,
|
| 175 |
|
- |
next_base.hour(), next_base.minute(), next_base.second(),
|
|
239 |
+ |
next_local.hour(), next_local.minute(), next_local.second(),
|
|
240 |
+ |
tz,
|
| 176 |
241 |
|
);
|
| 177 |
242 |
|
Some(target.unwrap_or(next_base))
|
| 178 |
243 |
|
}
|
| 179 |
244 |
|
None => {
|
| 180 |
|
- |
// Same as legacy monthly
|
|
245 |
+ |
// Same as legacy monthly (end-of-month intent read in `tz`)
|
| 181 |
246 |
|
let target_day = {
|
| 182 |
|
- |
let day = base.day();
|
| 183 |
|
- |
let month_len = days_in_month(base.year(), base.month());
|
|
247 |
+ |
let day = local.day();
|
|
248 |
+ |
let month_len = days_in_month(local.year(), local.month());
|
| 184 |
249 |
|
if day == month_len && day >= 29 && day < 31 { Some(31) } else { None }
|
| 185 |
250 |
|
};
|
| 186 |
|
- |
Some(add_months(base, interval as i32, target_day))
|
|
251 |
+ |
Some(add_months(base, interval as i32, target_day, tz))
|
| 187 |
252 |
|
}
|
| 188 |
253 |
|
}
|
| 189 |
254 |
|
}
|
| 191 |
256 |
|
}
|
| 192 |
257 |
|
}
|
| 193 |
258 |
|
|
| 194 |
|
- |
/// Find the Nth weekday in a given month.
|
|
259 |
+ |
/// Find the Nth weekday in a given month, constructing the result in `tz`.
|
| 195 |
260 |
|
/// `week`: 1-4 for ordinal, -1 for last.
|
| 196 |
261 |
|
/// `weekday`: 0=Mon..6=Sun.
|
|
262 |
+ |
/// `year`/`month`/`hour`/`minute`/`second` are civil fields in `tz`.
|
| 197 |
263 |
|
fn nth_weekday_in_month(
|
| 198 |
264 |
|
year: i32, month: u32,
|
| 199 |
265 |
|
week: i8, weekday: u8,
|
| 200 |
266 |
|
hour: u32, minute: u32, second: u32,
|
|
267 |
+ |
tz: Tz,
|
| 201 |
268 |
|
) -> Option<DateTime<Utc>> {
|
| 202 |
|
- |
use chrono::NaiveDate;
|
| 203 |
|
- |
|
| 204 |
269 |
|
let weekday_chrono = match weekday {
|
| 205 |
270 |
|
0 => chrono::Weekday::Mon,
|
| 206 |
271 |
|
1 => chrono::Weekday::Tue,
|
| 220 |
285 |
|
while d.weekday() != weekday_chrono {
|
| 221 |
286 |
|
d = d.pred_opt()?;
|
| 222 |
287 |
|
}
|
| 223 |
|
- |
Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single()
|
|
288 |
+ |
tz.with_ymd_and_hms(year, month, d.day(), hour, minute, second)
|
|
289 |
+ |
.single()
|
|
290 |
+ |
.map(|t| t.with_timezone(&Utc))
|
| 224 |
291 |
|
} else if (1..=5).contains(&week) {
|
| 225 |
292 |
|
// Nth occurrence: start from day 1, find first matching weekday, skip N-1
|
| 226 |
293 |
|
let first = NaiveDate::from_ymd_opt(year, month, 1)?;
|
| 233 |
300 |
|
if d.month() != month {
|
| 234 |
301 |
|
return None; // e.g., 5th Monday doesn't exist
|
| 235 |
302 |
|
}
|
| 236 |
|
- |
Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single()
|
|
303 |
+ |
tz.with_ymd_and_hms(year, month, d.day(), hour, minute, second)
|
|
304 |
+ |
.single()
|
|
305 |
+ |
.map(|t| t.with_timezone(&Utc))
|
| 237 |
306 |
|
} else {
|
| 238 |
307 |
|
None
|
| 239 |
308 |
|
}
|
| 248 |
317 |
|
event: &Event,
|
| 249 |
318 |
|
range_start: DateTime<Utc>,
|
| 250 |
319 |
|
range_end: DateTime<Utc>,
|
|
320 |
+ |
) -> Vec<Event> {
|
|
321 |
+ |
expand_recurrence_in_tz(event, range_start, range_end, Tz::UTC)
|
|
322 |
+ |
}
|
|
323 |
+ |
|
|
324 |
+ |
/// Expand a recurring event into virtual instances within a date range, advancing
|
|
325 |
+ |
/// occurrences in `tz` so the local time-of-day is stable across DST.
|
|
326 |
+ |
pub fn expand_recurrence_in_tz(
|
|
327 |
+ |
event: &Event,
|
|
328 |
+ |
range_start: DateTime<Utc>,
|
|
329 |
+ |
range_end: DateTime<Utc>,
|
|
330 |
+ |
tz: Tz,
|
| 251 |
331 |
|
) -> Vec<Event> {
|
| 252 |
332 |
|
let rule = match event.effective_recurrence_rule() {
|
| 253 |
333 |
|
Some(r) => r,
|
| 285 |
365 |
|
}
|
| 286 |
366 |
|
|
| 287 |
367 |
|
// Advance to next occurrence
|
| 288 |
|
- |
match calculate_next_due_rich(Some(&cursor), &rule) {
|
|
368 |
+ |
match calculate_next_due_rich_in_tz(Some(&cursor), &rule, tz) {
|
| 289 |
369 |
|
Some(next) if next > cursor => cursor = next,
|
| 290 |
370 |
|
_ => break, // prevent infinite loop
|
| 291 |
371 |
|
}
|
| 710 |
790 |
|
assert_eq!(a.id, b.id);
|
| 711 |
791 |
|
}
|
| 712 |
792 |
|
}
|
|
793 |
+ |
|
|
794 |
+ |
// ============ DST / time-zone-aware recurrence (Run #28) ============
|
|
795 |
+ |
|
|
796 |
+ |
#[test]
|
|
797 |
+ |
fn test_daily_recurrence_holds_local_time_across_spring_forward() {
|
|
798 |
+ |
use chrono_tz::America::New_York;
|
|
799 |
+ |
// 2026-03-08 is US spring-forward (02:00 -> 03:00). A task at 09:00 local on
|
|
800 |
+ |
// Mar 7 must land at 09:00 local on Mar 8 — not 10:00 as fixed-24h-UTC would give.
|
|
801 |
+ |
let start = New_York.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).single().unwrap()
|
|
802 |
+ |
.with_timezone(&Utc);
|
|
803 |
+ |
let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap();
|
|
804 |
+ |
let next_local = next.with_timezone(&New_York);
|
|
805 |
+ |
assert_eq!(next_local.day(), 8);
|
|
806 |
+ |
assert_eq!(next_local.hour(), 9, "local hour must stay 09:00 across DST");
|
|
807 |
+ |
// The UTC instant shifts by 23h (a short civil day), proving DST was honored.
|
|
808 |
+ |
assert_eq!((next - start).num_hours(), 23);
|
|
809 |
+ |
}
|
|
810 |
+ |
|
|
811 |
+ |
#[test]
|
|
812 |
+ |
fn test_daily_recurrence_holds_local_time_across_fall_back() {
|
|
813 |
+ |
use chrono_tz::America::New_York;
|
|
814 |
+ |
// 2026-11-01 is US fall-back (02:00 -> 01:00). 09:00 local Oct 31 -> 09:00 local Nov 1.
|
|
815 |
+ |
let start = New_York.with_ymd_and_hms(2026, 10, 31, 9, 0, 0).single().unwrap()
|
|
816 |
+ |
.with_timezone(&Utc);
|
|
817 |
+ |
let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap();
|
|
818 |
+ |
let next_local = next.with_timezone(&New_York);
|
|
819 |
+ |
assert_eq!(next_local.day(), 1);
|
|
820 |
+ |
assert_eq!(next_local.hour(), 9);
|
|
821 |
+ |
assert_eq!((next - start).num_hours(), 25, "a long civil day spans the fall-back");
|
|
822 |
+ |
}
|
|
823 |
+ |
|
|
824 |
+ |
#[test]
|
|
825 |
+ |
fn test_weekly_recurrence_holds_local_time_across_dst() {
|
|
826 |
+ |
use chrono_tz::America::New_York;
|
|
827 |
+ |
// Mar 5 (Thu) 08:00 local -> Mar 12, still 08:00 local, despite the Mar 8 transition.
|
|
828 |
+ |
let start = New_York.with_ymd_and_hms(2026, 3, 5, 8, 0, 0).single().unwrap()
|
|
829 |
+ |
.with_timezone(&Utc);
|
|
830 |
+ |
let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Weekly, New_York).unwrap();
|
|
831 |
+ |
let next_local = next.with_timezone(&New_York);
|
|
832 |
+ |
assert_eq!(next_local.day(), 12);
|
|
833 |
+ |
assert_eq!(next_local.hour(), 8);
|
|
834 |
+ |
}
|
|
835 |
+ |
|
|
836 |
+ |
#[test]
|
|
837 |
+ |
fn test_monthly_recurrence_holds_local_time_across_dst() {
|
|
838 |
+ |
use chrono_tz::America::New_York;
|
|
839 |
+ |
// Feb 20 09:00 local -> Mar 20 09:00 local, crossing the Mar 8 spring-forward.
|
|
840 |
+ |
let start = New_York.with_ymd_and_hms(2026, 2, 20, 9, 0, 0).single().unwrap()
|
|
841 |
+ |
.with_timezone(&Utc);
|
|
842 |
+ |
let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Monthly, New_York).unwrap();
|
|
843 |
+ |
let next_local = next.with_timezone(&New_York);
|
|
844 |
+ |
assert_eq!(next_local.month(), 3);
|
|
845 |
+ |
assert_eq!(next_local.day(), 20);
|
|
846 |
+ |
assert_eq!(next_local.hour(), 9);
|
|
847 |
+ |
}
|
|
848 |
+ |
|
|
849 |
+ |
#[test]
|
|
850 |
+ |
fn test_utc_wrapper_unaffected_by_dst_logic() {
|
|
851 |
+ |
// The legacy UTC entry points must still add a fixed 24h (no zone involved),
|
|
852 |
+ |
// so existing callers and instants are unchanged.
|
|
853 |
+ |
let start = Utc.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).unwrap();
|
|
854 |
+ |
let next = calculate_next_due(Some(&start), &Recurrence::Daily).unwrap();
|
|
855 |
+ |
assert_eq!((next - start).num_hours(), 24);
|
|
856 |
+ |
assert_eq!(next.hour(), 9);
|
|
857 |
+ |
}
|
| 713 |
858 |
|
}
|