Skip to main content

max / goingson

Fix core logic bugs: recurrence drift, urgency, parser, progress cap - Prevent monthly recurrence day drift across short months by detecting end-of-month dates and snapping to target day (Jan 31 -> Feb 28 -> Mar 31) - Export calculate_next_due_with_day for explicit day-of-month control - Use rem_euclid in add_months for correctness with negative months - Clamp age_days to max(0.0) to prevent negative urgency bonus - Reject negative relative dates (e.g. "-3d") in quick-add parser - Cap time_progress() at 100 instead of 255 - Propagate email dedup DB error instead of swallowing with unwrap_or_default - Clamp scheduled task duration to min 1 minute Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 19:38 UTC
Commit: d404fcf2d7059cc880fb510b3225775d26f2bc6b
Parent: 46169e6
7 files changed, +60 insertions, -15 deletions
@@ -62,8 +62,7 @@ pub async fn process_fetched_emails(
62 62
63 63 let existing_ids = email_repo
64 64 .exists_by_message_ids(user_id, &msg_ids)
65 - .await
66 - .unwrap_or_default();
65 + .await?;
67 66
68 67 // Collect new emails for batch insert, track reply-to IDs for waiting-status clearing
69 68 let mut new_emails = Vec::new();
@@ -76,7 +76,7 @@ pub use models::{
76 76 };
77 77 pub use parser::{parse_quick_add, parse_quick_add_with_warnings, ParsedTask, ParseResult};
78 78 pub use day_planning::{Conflict, TimelineItem, detect_conflicts};
79 - pub use recurrence::{calculate_next_due, should_recur};
79 + pub use recurrence::{calculate_next_due, calculate_next_due_with_day, should_recur};
80 80 pub use repository::*;
81 81 pub use urgency::calculate_urgency;
82 82 pub use plugin::{
@@ -389,7 +389,7 @@ impl Task {
389 389 if est <= 0 {
390 390 return 0;
391 391 }
392 - ((self.actual_minutes as f64 / est as f64) * 100.0).round().min(255.0) as u8
392 + ((self.actual_minutes as f64 / est as f64) * 100.0).round().min(100.0) as u8
393 393 })
394 394 }
395 395
@@ -209,6 +209,9 @@ fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<Date
209 209
210 210 let (num_str, unit) = s.split_at(s.len() - 1);
211 211 let num: i64 = num_str.parse().ok()?;
212 + if num < 1 {
213 + return None;
214 + }
212 215
213 216 let target = match unit {
214 217 "d" => from + Duration::days(num),
@@ -3,23 +3,53 @@
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,17 +63,21 @@ pub fn calculate_next_due(
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,11 +206,20 @@ mod tests {
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
@@ -105,7 +105,7 @@ pub fn calculate_urgency_with_config(
105 105
106 106 // Age coefficient - older tasks get higher urgency
107 107 let age_hours = now.signed_duration_since(*created_at).num_hours() as f64;
108 - let age_days = age_hours / HOURS_PER_DAY;
108 + let age_days = (age_hours / HOURS_PER_DAY).max(0.0);
109 109 let age_bonus = (age_days / config.age_days_for_max).min(1.0) * config.age_max;
110 110 urgency += age_bonus;
111 111
@@ -148,7 +148,7 @@ pub async fn schedule_task(
148 148 id: TaskId,
149 149 input: ScheduleTaskInput,
150 150 ) -> Result<TaskResponse, ApiError> {
151 - let duration = input.duration.unwrap_or(30);
151 + let duration = input.duration.unwrap_or(30).max(1);
152 152 let end_time = input.start_time + chrono::Duration::minutes(duration as i64);
153 153
154 154 let task = state.tasks