diff --git a/proto/backend.proto b/proto/backend.proto index e546b6ce7..82b91c8c8 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -84,10 +84,11 @@ message TemplateRequirementAny { } message SchedTimingTodayIn { - int64 created = 1; - int64 now = 2; - sint32 minutes_west = 3; - sint32 rollover_hour = 4; + int64 created_secs = 1; + sint32 created_mins_west = 2; + int64 now_secs = 3; + sint32 now_mins_west = 4; + sint32 rollover_hour = 5; } message SchedTimingTodayOut { diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 435a62735..edab0967a 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -132,7 +132,7 @@ class _Collection: elif ver == 2: self.sched = V2Scheduler(self) if not self.server: - self.conf["localOffset"] = self.sched.timezoneOffset() + self.conf["localOffset"] = self.sched.currentTimezoneOffset() elif self.server.minutes_west is not None: self.conf["localOffset"] = self.server.minutes_west @@ -162,7 +162,7 @@ class _Collection: if isinstance(self.sched, V1Scheduler): return None else: - return self.sched.timezoneOffset() + return self.sched.currentTimezoneOffset() # DB-related ########################################################################## diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 9623f5d27..c0dd8503b 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -85,12 +85,21 @@ class RustBackend: return proto_template_reqs_to_legacy(reqs) def sched_timing_today( - self, start: int, end: int, offset: int, rollover: int + self, + created_secs: int, + created_mins_west: int, + now_secs: int, + now_mins_west: int, + rollover: int, ) -> SchedTimingToday: return self._run_command( pb.BackendInput( sched_timing_today=pb.SchedTimingTodayIn( - created=start, now=end, minutes_west=offset, rollover_hour=rollover, + created_secs=created_secs, + created_mins_west=created_mins_west, + now_secs=now_secs, + now_mins_west=now_mins_west, + rollover_hour=rollover, ) ) ).sched_timing_today diff --git a/pylib/anki/schedv2.py b/pylib/anki/schedv2.py index 506f363d1..a1733498f 100644 --- a/pylib/anki/schedv2.py +++ b/pylib/anki/schedv2.py @@ -1395,22 +1395,44 @@ where id = ? def _rolloverHour(self) -> int: return self.col.conf.get("rollover", 4) + # New timezone handling + ########################################################################## + def _newTimezoneEnabled(self) -> bool: - return self.col.conf.get("newTimezone", False) + return self.col.conf.get("creationOffset") is not None def _timingToday(self) -> SchedTimingToday: return self.col.backend.sched_timing_today( - self.col.crt, intTime(), self.timezoneOffset(), self._rolloverHour(), + self.col.crt, + self.creationTimezoneOffset(), + intTime(), + self.currentTimezoneOffset(), + self._rolloverHour(), ) - def timezoneOffset(self) -> int: + def currentTimezoneOffset(self) -> int: if self.col.server: return self.col.conf.get("localOffset", 0) else: - if time.localtime().tm_isdst: - return time.altzone // 60 - else: - return time.timezone // 60 + return self._localOffsetForDate(datetime.datetime.today()) + + def creationTimezoneOffset(self) -> int: + return self.col.conf.get("creationOffset", 0) + + def setCreationOffset(self): + """Save the UTC west offset at the time of creation into the DB. + + Once stored, this activates the new timezone handling code. + """ + mins_west = self._localOffsetForDate( + datetime.datetime.fromtimestamp(self.col.crt) + ) + self.col.conf["creationOffset"] = mins_west + self.col.setMod() + + def _localOffsetForDate(self, date: datetime.datetime) -> int: + "Minutes west of UTC for a given datetime in the local timezone." + return date.astimezone().utcoffset().seconds // -60 # Deck finished state ########################################################################## diff --git a/rslib/src/backend.rs b/rslib/src/backend.rs index 35d20fd26..8d83f2246 100644 --- a/rslib/src/backend.rs +++ b/rslib/src/backend.rs @@ -145,9 +145,10 @@ impl Backend { fn sched_timing_today(&self, input: pt::SchedTimingTodayIn) -> pt::SchedTimingTodayOut { let today = sched_timing_today( - input.created as i64, - input.now as i64, - input.minutes_west, + input.created_secs as i64, + input.created_mins_west, + input.now_secs as i64, + input.now_mins_west, input.rollover_hour as i8, ); pt::SchedTimingTodayOut { diff --git a/rslib/src/sched.rs b/rslib/src/sched.rs index d3b8d466e..6e0398d83 100644 --- a/rslib/src/sched.rs +++ b/rslib/src/sched.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Datelike, FixedOffset, Local, TimeZone}; +use chrono::{Date, Duration, FixedOffset, Local, TimeZone}; pub struct SchedTimingToday { /// The number of days that have passed since the collection was created. @@ -8,49 +8,54 @@ pub struct SchedTimingToday { } /// Timing information for the current day. -/// - created is the collection creation time -/// - now is the current UTC timestamp -/// - minutes_west is relative to the local timezone (eg UTC+10 hours is -600) +/// - created_secs is a UNIX timestamp of the collection creation time +/// - created_mins_west is the offset west of UTC at the time of creation +/// (eg UTC+10 hours is -600) +/// - now_secs is a timestamp of the current time +/// - now_mins_west is the current offset west of UTC /// - rollover_hour is the hour of the day the rollover happens (eg 4 for 4am) pub fn sched_timing_today( - created: i64, - now: i64, - minutes_west: i32, + created_secs: i64, + created_mins_west: i32, + now_secs: i64, + now_mins_west: i32, rollover_hour: i8, ) -> SchedTimingToday { - let rollover_today = rollover_for_today(now, minutes_west, rollover_hour).timestamp(); + // get date(times) based on timezone offsets + let created_date = fixed_offset_from_minutes(created_mins_west) + .timestamp(created_secs, 0) + .date(); + let now_datetime = fixed_offset_from_minutes(now_mins_west).timestamp(now_secs, 0); + let today = now_datetime.date(); + + // rollover + let rollover_hour = normalized_rollover_hour(rollover_hour); + let rollover_today_datetime = today.and_hms(rollover_hour as u32, 0, 0); + let rollover_passed = rollover_today_datetime <= now_datetime; + let next_day_at = (rollover_today_datetime + Duration::days(1)).timestamp(); + + // day count + let days_elapsed = days_elapsed(created_date, today, rollover_passed); SchedTimingToday { - days_elapsed: days_elapsed(created, now, rollover_today), - next_day_at: rollover_today + 86_400, + days_elapsed, + next_day_at, } } -/// Convert timestamp to the local timezone, with the provided rollover hour. -fn rollover_for_today( - timestamp: i64, - minutes_west: i32, - rollover_hour: i8, -) -> DateTime { - let local_offset = fixed_offset_from_minutes(minutes_west); - let rollover_hour = normalized_rollover_hour(rollover_hour); - let dt = local_offset.timestamp(timestamp, 0); - local_offset - .ymd(dt.year(), dt.month(), dt.day()) - .and_hms(rollover_hour as u32, 0, 0) -} +/// The number of times the day rolled over between two dates. +fn days_elapsed( + start_date: Date, + end_date: Date, + rollover_passed: bool, +) -> u32 { + let days = (end_date - start_date).num_days(); -/// The number of times the day rolled over between two timestamps. -fn days_elapsed(start: i64, end: i64, rollover_today: i64) -> u32 { - let secs = (rollover_today - start).max(0); - let days = (secs / 86_400) as u32; + // current day doesn't count before rollover time + let days = if rollover_passed { days } else { days - 1 }; - // minus one if today's cutoff hasn't passed - if days > 0 && end < rollover_today { - days - 1 - } else { - days - } + // minimum of 0 + days.max(0) as u32 } /// Negative rollover hours are relative to the next day, eg -1 = 23. @@ -81,10 +86,10 @@ fn utc_minus_local_mins() -> i32 { #[cfg(test)] mod test { use crate::sched::{ - fixed_offset_from_minutes, normalized_rollover_hour, rollover_for_today, - sched_timing_today, utc_minus_local_mins, + fixed_offset_from_minutes, normalized_rollover_hour, sched_timing_today, + utc_minus_local_mins, }; - use chrono::{Datelike, FixedOffset, TimeZone, Timelike}; + use chrono::{FixedOffset, TimeZone}; #[test] fn test_rollover() { @@ -95,15 +100,6 @@ mod test { assert_eq!(normalized_rollover_hour(-2), 22); assert_eq!(normalized_rollover_hour(-23), 1); assert_eq!(normalized_rollover_hour(-24), 1); - - let now_dt = FixedOffset::west(-600).ymd(2019, 12, 1).and_hms(2, 3, 4); - let roll_dt = rollover_for_today(now_dt.timestamp(), -600, 4); - assert_eq!(roll_dt.year(), 2019); - assert_eq!(roll_dt.month(), 12); - assert_eq!(roll_dt.day(), 1); - assert_eq!(roll_dt.hour(), 4); - assert_eq!(roll_dt.minute(), 0); - assert_eq!(roll_dt.second(), 0); } #[test] @@ -113,56 +109,97 @@ mod test { } // helper - fn elap(start: i64, end: i64, west: i32, rollhour: i8) -> u32 { - let today = sched_timing_today(start, end, west, rollhour); + fn elap(start: i64, end: i64, start_west: i32, end_west: i32, rollhour: i8) -> u32 { + let today = sched_timing_today(start, start_west, end, end_west, rollhour); today.days_elapsed } #[test] fn test_days_elapsed() { - let offset = utc_minus_local_mins(); + let local_offset = utc_minus_local_mins(); - let created_dt = FixedOffset::west(offset * 60) + let created_dt = FixedOffset::west(local_offset * 60) .ymd(2019, 12, 1) .and_hms(2, 0, 0); let crt = created_dt.timestamp(); // days can't be negative - assert_eq!(elap(crt, crt, offset, 4), 0); - assert_eq!(elap(crt, crt - 86_400, offset, 4), 0); + assert_eq!(elap(crt, crt, local_offset, local_offset, 4), 0); + assert_eq!(elap(crt, crt - 86_400, local_offset, local_offset, 4), 0); // 2am the next day is still the same day - assert_eq!(elap(crt, crt + 24 * 3600, offset, 4), 0); + assert_eq!(elap(crt, crt + 24 * 3600, local_offset, local_offset, 4), 0); // day rolls over at 4am - assert_eq!(elap(crt, crt + 26 * 3600, offset, 4), 1); + assert_eq!(elap(crt, crt + 26 * 3600, local_offset, local_offset, 4), 1); // the longest extra delay is +23, or 19 hours past the 4 hour default - assert_eq!(elap(crt, crt + (26 + 18) * 3600, offset, 23), 0); - assert_eq!(elap(crt, crt + (26 + 19) * 3600, offset, 23), 1); + assert_eq!( + elap(crt, crt + (26 + 18) * 3600, local_offset, local_offset, 23), + 0 + ); + assert_eq!( + elap(crt, crt + (26 + 19) * 3600, local_offset, local_offset, 23), + 1 + ); + + let mdt = FixedOffset::west(6 * 60 * 60); + let mdt_offset = mdt.utc_minus_local() / 60; + let mst = FixedOffset::west(7 * 60 * 60); + let mst_offset = mst.utc_minus_local() / 60; // a collection created @ midnight in MDT in the past - let mdt = FixedOffset::west(6 * 60 * 60); - let mst = FixedOffset::west(7 * 60 * 60); let crt = mdt.ymd(2018, 8, 6).and_hms(0, 0, 0).timestamp(); // with the current time being MST let now = mst.ymd(2019, 12, 26).and_hms(20, 0, 0).timestamp(); - let offset = mst.utc_minus_local() / 60; - assert_eq!(elap(crt, now, offset, 4), 507); + assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 507); // the previous implementation generated a diferent elapsed number of days with a change // to DST, but the number shouldn't change - let offset = mdt.utc_minus_local() / 60; - assert_eq!(elap(crt, now, offset, 4), 507); + assert_eq!(elap(crt, now, mdt_offset, mdt_offset, 4), 507); // collection created at 3am on the 6th, so day 1 starts at 4am on the 7th, and day 3 on the 9th. let crt = mdt.ymd(2018, 8, 6).and_hms(3, 0, 0).timestamp(); - - let mst_offset = mst.utc_minus_local() / 60; let now = mst.ymd(2018, 8, 9).and_hms(1, 59, 59).timestamp(); - assert_eq!(elap(crt, now, mst_offset, 4), 2); + assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 2); let now = mst.ymd(2018, 8, 9).and_hms(3, 59, 59).timestamp(); - assert_eq!(elap(crt, now, mst_offset, 4), 2); + assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 2); let now = mst.ymd(2018, 8, 9).and_hms(4, 0, 0).timestamp(); - assert_eq!(elap(crt, now, mst_offset, 4), 3); + assert_eq!(elap(crt, now, mdt_offset, mst_offset, 4), 3); + + // try a bunch of combinations of creation time, current time, and rollover hour + let hours_of_interest = &[0, 1, 4, 12, 22, 23]; + for creation_hour in hours_of_interest { + let crt_dt = mdt.ymd(2018, 8, 6).and_hms(*creation_hour, 0, 0); + let crt_stamp = crt_dt.timestamp(); + let crt_offset = mdt_offset; + + for current_day in 0..=3 { + for current_hour in hours_of_interest { + for rollover_hour in hours_of_interest { + let end_dt = mdt + .ymd(2018, 8, 6 + current_day) + .and_hms(*current_hour, 0, 0); + let end_stamp = end_dt.timestamp(); + let end_offset = mdt_offset; + let elap_day = if *current_hour < *rollover_hour { + current_day.max(1) - 1 + } else { + current_day + }; + + assert_eq!( + elap( + crt_stamp, + end_stamp, + crt_offset, + end_offset, + *rollover_hour as i8 + ), + elap_day + ); + } + } + } + } } }