Skip to main content

max / goingson

Data & Sync remediation: blob integrity, DST recurrence, HLC conflict ordering Ultra-fuzz Run #28 Phase 1. GO-local fixes plus the GoingsOn side of the SyncKit HLC protocol. - Verify downloaded blobs against their content hash before committing them to the content-addressed store (was trusted on the read path). - Recurrence advances in the user's local time zone, so a fixed local time-of-day survives DST transitions (UTC wrappers retained; new tz-aware variants). - Restore weekly-review vacation_days verbatim (was silently dropping values >6). - HLC integration: migration 054 (hlc_state + per-changelog HLC stamps), a clock module that stamps local changes and advances on pull, and push/pull wiring so resolve_lww compares real clocks. hlc_state excluded from backup.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 19:28 UTC
Signed with PGP, not checked
Commit: 5d39af14c1d087c87a05723366c7db450332e5b8
Parent: ab7f844
19 files changed, +414 insertions, -75 deletions
M Cargo.lock +2
@@ -1880,6 +1880,7 @@
1880 1880 dependencies = [
1881 1881 "async-trait",
1882 1882 "chrono",
1883 + "chrono-tz",
1883 1884 "serde",
1884 1885 "serde_json",
1885 1886 "sha2",
@@ -1922,6 +1923,7 @@
1922 1923 "goingson-core",
1923 1924 "goingson-db-sqlite",
1924 1925 "goingson-plugin-runtime",
1926 + "iana-time-zone",
1925 1927 "ical",
1926 1928 "icalendar",
1927 1929 "keyring",
@@ -38,6 +38,7 @@
38 38 # Utilities
39 39 chrono = { workspace = true }
40 40 chrono-tz = { workspace = true }
41 + iana-time-zone = "0.1"
41 42 uuid = { workspace = true }
42 43
43 44 # Email
@@ -8,6 +8,7 @@
8 8
9 9 [dependencies]
10 10 chrono = { workspace = true }
11 + chrono-tz = { workspace = true }
11 12 uuid = { workspace = true }
12 13 serde = { workspace = true }
13 14 serde_json = { workspace = true }
@@ -16,6 +16,7 @@
16 16 pub mod state;
17 17 pub mod sync_scheduler;
18 18 pub mod sync_service;
19 + pub mod tz;
19 20
20 21 // Desktop-only: file watching for external DB changes
21 22 #[cfg(not(any(target_os = "ios", target_os = "android")))]
@@ -80,7 +80,7 @@
80 80 pub use parser::{parse_quick_add, parse_quick_add_with_warnings, ParsedTask, ParseResult};
81 81 pub use date_parser::parse_natural_date;
82 82 pub use day_planning::{Conflict, TimelineItem, detect_conflicts};
83 - pub use recurrence::{calculate_next_due, calculate_next_due_with_day, calculate_next_due_rich, expand_recurrence, should_recur};
83 + pub use recurrence::{calculate_next_due, calculate_next_due_in_tz, calculate_next_due_with_day, calculate_next_due_with_day_in_tz, calculate_next_due_rich, calculate_next_due_rich_in_tz, expand_recurrence, expand_recurrence_in_tz, should_recur};
84 84 pub use repository::*;
85 85 pub use urgency::calculate_urgency;
86 86 pub use plugin::{
@@ -1,32 +1,56 @@
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,30 +60,56 @@
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,16 +117,17 @@
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,9 +137,10 @@
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,30 +173,41 @@
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,38 +215,40 @@
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,16 +256,16 @@
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,7 +285,9 @@
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,7 +300,9 @@
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,6 +317,17 @@
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,7 +365,7 @@
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,4 +790,69 @@
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 }
@@ -9,7 +9,7 @@
9 9 use tauri::State;
10 10 use tracing::instrument;
11 11
12 - use goingson_core::{Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts, expand_recurrence};
12 + use goingson_core::{Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts, expand_recurrence_in_tz};
13 13 use chrono::Datelike;
14 14
15 15 use crate::state::{AppState, DESKTOP_USER_ID};
@@ -116,7 +116,7 @@
116 116 let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect();
117 117 for r in recurring {
118 118 if !existing_ids.contains(&r.id) {
119 - let expanded = expand_recurrence(&r, day_start, day_end);
119 + let expanded = expand_recurrence_in_tz(&r, day_start, day_end, crate::tz::system_tz());
120 120 events.extend(expanded);
121 121 // Check if the original also falls on this day
122 122 let effective_end = r.end_time.unwrap_or(r.start_time + Duration::hours(1));
@@ -9,7 +9,7 @@
9 9 use tauri::State;
10 10 use tracing::instrument;
11 11
12 - use goingson_core::{BlockType, ContactId, DbValue, Event, EventId, NewEvent, ParseableEnum, ProjectId, Recurrence, RecurrenceRule, TaskId, UpdateEvent, Validate, expand_recurrence};
12 + use goingson_core::{BlockType, ContactId, DbValue, Event, EventId, NewEvent, ParseableEnum, ProjectId, Recurrence, RecurrenceRule, TaskId, UpdateEvent, Validate, expand_recurrence_in_tz};
13 13
14 14 use crate::state::{AppState, DESKTOP_USER_ID};
15 15 use super::{ApiError, OptionNotFound};
@@ -218,7 +218,7 @@
218 218 for event in events {
219 219 if event.has_recurrence() && !event.is_recurring_instance {
220 220 // Add virtual instances within the range
221 - let expanded = expand_recurrence(&event, range_start, range_end);
221 + let expanded = expand_recurrence_in_tz(&event, range_start, range_end, crate::tz::system_tz());
222 222 result.extend(expanded);
223 223 // Include the original if it falls within range
224 224 let effective_end = event.end_time.unwrap_or(event.start_time + Duration::hours(1));