max / goingson
45 files changed,
+787 insertions,
-190 deletions
| @@ -1882,6 +1882,7 @@ dependencies = [ | |||
| 1882 | 1882 | "chrono", | |
| 1883 | 1883 | "serde", | |
| 1884 | 1884 | "serde_json", | |
| 1885 | + | "sha2", | |
| 1885 | 1886 | "sqlx", | |
| 1886 | 1887 | "strum", | |
| 1887 | 1888 | "strum_macros", |
| @@ -17,3 +17,4 @@ strum = { workspace = true } | |||
| 17 | 17 | strum_macros = { workspace = true } | |
| 18 | 18 | sqlx = { workspace = true, features = ["sqlite", "uuid"], optional = true } | |
| 19 | 19 | tagtree = { workspace = true } | |
| 20 | + | sha2 = { workspace = true } |
| @@ -43,12 +43,12 @@ pub fn detect_conflicts(items: &[TimelineItem]) -> Vec<Conflict> { | |||
| 43 | 43 | ||
| 44 | 44 | for (i, item1) in items.iter().enumerate() { | |
| 45 | 45 | let end1 = item1.end_time.unwrap_or_else(|| { | |
| 46 | - | item1.start_time + chrono::Duration::minutes(item1.duration.unwrap_or(30) as i64) | |
| 46 | + | item1.start_time + chrono::Duration::minutes(item1.duration.unwrap_or(30).max(0) as i64) | |
| 47 | 47 | }); | |
| 48 | 48 | ||
| 49 | 49 | for item2 in items.iter().skip(i + 1) { | |
| 50 | 50 | let end2 = item2.end_time.unwrap_or_else(|| { | |
| 51 | - | item2.start_time + chrono::Duration::minutes(item2.duration.unwrap_or(30) as i64) | |
| 51 | + | item2.start_time + chrono::Duration::minutes(item2.duration.unwrap_or(30).max(0) as i64) | |
| 52 | 52 | }); | |
| 53 | 53 | ||
| 54 | 54 | // Standard interval overlap test: two intervals [s1,e1) and [s2,e2) |
| @@ -61,14 +61,19 @@ pub async fn process_fetched_emails( | |||
| 61 | 61 | let mut emails = emails; | |
| 62 | 62 | for e in &mut emails { | |
| 63 | 63 | if e.message_id.is_none() { | |
| 64 | - | use std::collections::hash_map::DefaultHasher; | |
| 65 | - | use std::hash::{Hash, Hasher}; | |
| 66 | - | let mut h = DefaultHasher::new(); | |
| 67 | - | e.from.hash(&mut h); | |
| 68 | - | e.to.hash(&mut h); | |
| 69 | - | e.subject.hash(&mut h); | |
| 70 | - | e.date.hash(&mut h); | |
| 71 | - | e.message_id = Some(format!("synth-{:x}", h.finish())); | |
| 64 | + | // Use SHA-256 for a stable hash across Rust versions (DefaultHasher is not stable). | |
| 65 | + | use sha2::{Sha256, Digest}; | |
| 66 | + | let mut h = Sha256::new(); | |
| 67 | + | h.update(e.from.as_bytes()); | |
| 68 | + | h.update(b"\x00"); | |
| 69 | + | h.update(e.to.as_bytes()); | |
| 70 | + | h.update(b"\x00"); | |
| 71 | + | h.update(e.subject.as_bytes()); | |
| 72 | + | h.update(b"\x00"); | |
| 73 | + | h.update(e.date.to_rfc3339().as_bytes()); | |
| 74 | + | let hash = h.finalize(); | |
| 75 | + | e.message_id = Some(format!("synth-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", | |
| 76 | + | hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7])); | |
| 72 | 77 | } | |
| 73 | 78 | } | |
| 74 | 79 |
| @@ -72,8 +72,7 @@ impl Event { | |||
| 72 | 72 | /// Returns a relative date string: "Past", "Today", "Tomorrow", day name, or "Mon DD". | |
| 73 | 73 | pub fn date_formatted(&self) -> String { | |
| 74 | 74 | let now = Utc::now(); | |
| 75 | - | let diff = self.start_time.signed_duration_since(now); | |
| 76 | - | let days = diff.num_days(); | |
| 75 | + | let days = (self.start_time.date_naive() - now.date_naive()).num_days(); | |
| 77 | 76 | ||
| 78 | 77 | if days < 0 { | |
| 79 | 78 | "Past".to_string() |
| @@ -253,8 +253,7 @@ impl Task { | |||
| 253 | 253 | match &self.due { | |
| 254 | 254 | Some(dt) => { | |
| 255 | 255 | let now = Utc::now(); | |
| 256 | - | let diff = dt.signed_duration_since(now); | |
| 257 | - | let days = diff.num_days(); | |
| 256 | + | let days = (dt.date_naive() - now.date_naive()).num_days(); | |
| 258 | 257 | ||
| 259 | 258 | if days < 0 { | |
| 260 | 259 | format!("{}d ago", -days) |
| @@ -167,20 +167,20 @@ pub fn compute_monthly_review(input: MonthlyReviewInput) -> MonthlyReviewData { | |||
| 167 | 167 | let day_start = date.and_hms_opt(0, 0, 0) | |
| 168 | 168 | .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) | |
| 169 | 169 | .unwrap_or_else(Utc::now); | |
| 170 | - | let day_end = date.and_hms_opt(23, 59, 59) | |
| 170 | + | let next_day_start = (date + Duration::days(1)).and_hms_opt(0, 0, 0) | |
| 171 | 171 | .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) | |
| 172 | 172 | .unwrap_or_else(Utc::now); | |
| 173 | 173 | ||
| 174 | 174 | let completed_count = input.tasks_completed.iter() | |
| 175 | 175 | .filter(|t| { | |
| 176 | 176 | t.completed_at | |
| 177 | - | .map(|ca| ca >= day_start && ca <= day_end) | |
| 177 | + | .map(|ca| ca >= day_start && ca < next_day_start) | |
| 178 | 178 | .unwrap_or(false) | |
| 179 | 179 | }) | |
| 180 | 180 | .count() as i32; | |
| 181 | 181 | ||
| 182 | 182 | let event_count = input.events.iter() | |
| 183 | - | .filter(|e| e.start_time >= day_start && e.start_time <= day_end) | |
| 183 | + | .filter(|e| e.start_time >= day_start && e.start_time < next_day_start) | |
| 184 | 184 | .count() as i32; | |
| 185 | 185 | ||
| 186 | 186 | let activity = completed_count + event_count; |
| @@ -195,7 +195,7 @@ fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<Date | |||
| 195 | 195 | ||
| 196 | 196 | let (num_str, unit) = s.split_at(s.len() - 1); | |
| 197 | 197 | let num: i64 = num_str.parse().ok()?; | |
| 198 | - | if num < 1 || num > MAX_RELATIVE_DATE_DAYS { | |
| 198 | + | if num < 0 || num > MAX_RELATIVE_DATE_DAYS { | |
| 199 | 199 | return None; | |
| 200 | 200 | } | |
| 201 | 201 |
| @@ -17,14 +17,16 @@ pub fn calculate_next_due( | |||
| 17 | 17 | recurrence: &Recurrence, | |
| 18 | 18 | ) -> Option<DateTime<Utc>> { | |
| 19 | 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. | |
| 20 | + | // If the due date falls on the last day of its month AND the day is >= 29, | |
| 21 | + | // treat the target as day 31 so it snaps to end-of-month in longer months. | |
| 22 | + | // The >= 29 guard prevents false positives: Feb 28 in a non-leap year is | |
| 23 | + | // ambiguous (user may have meant "the 28th"), but days 29-30 at month-end | |
| 24 | + | // clearly indicate end-of-month intent. | |
| 23 | 25 | let target_day = if matches!(recurrence, Recurrence::Monthly) { | |
| 24 | 26 | current_due.and_then(|dt| { | |
| 25 | 27 | let day = dt.day(); | |
| 26 | 28 | let month_len = days_in_month(dt.year(), dt.month()); | |
| 27 | - | if day == month_len && day < 31 { | |
| 29 | + | if day == month_len && day >= 29 && day < 31 { | |
| 28 | 30 | Some(31) | |
| 29 | 31 | } else { | |
| 30 | 32 | None | |
| @@ -206,12 +208,13 @@ mod tests { | |||
| 206 | 208 | } | |
| 207 | 209 | ||
| 208 | 210 | #[test] | |
| 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 | |
| 211 | + | fn test_monthly_feb_28_no_snap() { | |
| 212 | + | // Feb 28 in a non-leap year: day < 29, so no end-of-month snap. | |
| 213 | + | // User who chose the 28th gets Mar 28, not Mar 31. | |
| 211 | 214 | let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap(); | |
| 212 | 215 | let next = calculate_next_due(Some(&feb_28), &Recurrence::Monthly).unwrap(); | |
| 213 | 216 | assert_eq!(next.month(), 3); | |
| 214 | - | assert_eq!(next.day(), 31); | |
| 217 | + | assert_eq!(next.day(), 28); | |
| 215 | 218 | } | |
| 216 | 219 | ||
| 217 | 220 | #[test] |
| @@ -298,20 +298,21 @@ pub fn build_timeline_days( | |||
| 298 | 298 | let day_start = date.and_hms_opt(0, 0, 0) | |
| 299 | 299 | .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) | |
| 300 | 300 | .unwrap_or_else(Utc::now); | |
| 301 | - | let day_end = date.and_hms_opt(23, 59, 59) | |
| 301 | + | let next_day_start = (date + Duration::days(1)).and_hms_opt(0, 0, 0) | |
| 302 | 302 | .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) | |
| 303 | 303 | .unwrap_or_else(Utc::now); | |
| 304 | 304 | ||
| 305 | 305 | let completed_count = tasks_completed.iter() | |
| 306 | 306 | .filter(|t| { | |
| 307 | 307 | t.completed_at | |
| 308 | - | .map(|ca| ca >= day_start && ca <= day_end) | |
| 308 | + | .map(|ca| ca >= day_start && ca < next_day_start) | |
| 309 | 309 | .unwrap_or(false) | |
| 310 | 310 | }) | |
| 311 | 311 | .count() as i32; | |
| 312 | 312 | ||
| 313 | 313 | let event_count = events_occurred.iter() | |
| 314 | - | .filter(|e| e.start_time >= day_start && e.start_time <= day_end) | |
| 314 | + | .chain(upcoming_events.iter()) | |
| 315 | + | .filter(|e| e.start_time >= day_start && e.start_time < next_day_start) | |
| 315 | 316 | .count() as i32; | |
| 316 | 317 | ||
| 317 | 318 | let overdue_count = tasks_overdue.iter() | |
| @@ -329,7 +330,7 @@ pub fn build_timeline_days( | |||
| 329 | 330 | // Collect events for this day from both past and upcoming, capped at 5 | |
| 330 | 331 | let day_events: Vec<EventSummary> = events_occurred.iter() | |
| 331 | 332 | .chain(upcoming_events.iter()) | |
| 332 | - | .filter(|e| e.start_time >= day_start && e.start_time <= day_end) | |
| 333 | + | .filter(|e| e.start_time >= day_start && e.start_time < next_day_start) | |
| 333 | 334 | .take(5) | |
| 334 | 335 | .map(event_to_summary) | |
| 335 | 336 | .collect(); |
| @@ -71,13 +71,14 @@ pub async fn migrate_deterministic_email_ids(pool: &SqlitePool) -> Result<(), St | |||
| 71 | 71 | match result { | |
| 72 | 72 | Ok(_) => { | |
| 73 | 73 | // Update FK references in tasks | |
| 74 | - | let _ = sqlx::query( | |
| 74 | + | sqlx::query( | |
| 75 | 75 | "UPDATE tasks SET source_email_id = ? WHERE source_email_id = ?", | |
| 76 | 76 | ) | |
| 77 | 77 | .bind(&new_id_str) | |
| 78 | 78 | .bind(old_id) | |
| 79 | 79 | .execute(&mut *tx) | |
| 80 | - | .await; | |
| 80 | + | .await | |
| 81 | + | .map_err(|e| format!("Failed to update task FK for email {old_id}: {e}"))?; | |
| 81 | 82 | ||
| 82 | 83 | updated += 1; | |
| 83 | 84 | } |
| @@ -472,10 +472,10 @@ impl ContactRepository for SqliteContactRepository { | |||
| 472 | 472 | } | |
| 473 | 473 | ||
| 474 | 474 | if let Some(s) = search.filter(|s| !s.is_empty()) { | |
| 475 | - | let search_pattern = format!("%{}%", s.to_lowercase()); | |
| 475 | + | let search_pattern = format!("%{}%", escape_like(&s.to_lowercase())); | |
| 476 | 476 | // Search across contact fields and email addresses using a subquery | |
| 477 | 477 | conditions.push( | |
| 478 | - | "(LOWER(c.display_name) LIKE ? OR LOWER(COALESCE(c.nickname, '')) LIKE ? OR LOWER(COALESCE(c.company, '')) LIKE ? OR LOWER(COALESCE(c.title, '')) LIKE ? OR LOWER(c.notes) LIKE ? OR EXISTS (SELECT 1 FROM contact_emails ce WHERE ce.contact_id = c.id AND LOWER(ce.address) LIKE ?))".to_string() | |
| 478 | + | "(LOWER(c.display_name) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.nickname, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.company, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.title, '')) LIKE ? ESCAPE '\\' OR LOWER(c.notes) LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM contact_emails ce WHERE ce.contact_id = c.id AND LOWER(ce.address) LIKE ? ESCAPE '\\'))".to_string() | |
| 479 | 479 | ); | |
| 480 | 480 | // 6 binds for the search pattern | |
| 481 | 481 | for _ in 0..6 { |
| @@ -253,14 +253,14 @@ impl EventRepository for SqliteEventRepository { | |||
| 253 | 253 | let date_start = format!("{} 00:00:00", date); | |
| 254 | 254 | let date_end = format!("{} 23:59:59", date); | |
| 255 | 255 | let query = format!( | |
| 256 | - | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND datetime(e.start_time) >= datetime(?) AND datetime(e.start_time) <= datetime(?) ORDER BY e.start_time ASC", | |
| 256 | + | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND datetime(e.start_time) <= datetime(?) AND (e.end_time IS NULL OR datetime(e.end_time) >= datetime(?)) ORDER BY e.start_time ASC", | |
| 257 | 257 | EVENT_SELECT_COLUMNS | |
| 258 | 258 | ); | |
| 259 | 259 | let rows = sqlx::query_as::<_, EventRow>(&query) | |
| 260 | 260 | .bind(user_id.to_string()) | |
| 261 | 261 | .bind(user_id.to_string()) | |
| 262 | - | .bind(&date_start) | |
| 263 | 262 | .bind(&date_end) | |
| 263 | + | .bind(&date_start) | |
| 264 | 264 | .fetch_all(&self.pool) | |
| 265 | 265 | .await | |
| 266 | 266 | .map_err(CoreError::database)?; | |
| @@ -272,14 +272,14 @@ impl EventRepository for SqliteEventRepository { | |||
| 272 | 272 | let start_str = format_datetime(&start); | |
| 273 | 273 | let end_str = format_datetime(&end); | |
| 274 | 274 | let query = format!( | |
| 275 | - | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND datetime(e.start_time) >= datetime(?) AND datetime(e.start_time) <= datetime(?) ORDER BY e.start_time ASC", | |
| 275 | + | "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND datetime(e.start_time) <= datetime(?) AND (e.end_time IS NULL OR datetime(e.end_time) >= datetime(?)) ORDER BY e.start_time ASC", | |
| 276 | 276 | EVENT_SELECT_COLUMNS | |
| 277 | 277 | ); | |
| 278 | 278 | let rows = sqlx::query_as::<_, EventRow>(&query) | |
| 279 | 279 | .bind(user_id.to_string()) | |
| 280 | 280 | .bind(user_id.to_string()) | |
| 281 | - | .bind(&start_str) | |
| 282 | 281 | .bind(&end_str) | |
| 282 | + | .bind(&start_str) | |
| 283 | 283 | .fetch_all(&self.pool) | |
| 284 | 284 | .await | |
| 285 | 285 | .map_err(CoreError::database)?; |
| @@ -185,6 +185,7 @@ impl MilestoneRepository for SqliteMilestoneRepository { | |||
| 185 | 185 | ||
| 186 | 186 | #[tracing::instrument(skip_all)] | |
| 187 | 187 | async fn reorder(&self, project_id: ProjectId, user_id: UserId, milestone_ids: &[MilestoneId]) -> Result<()> { | |
| 188 | + | let mut tx = self.pool.begin().await.map_err(CoreError::database)?; | |
| 188 | 189 | for (i, id) in milestone_ids.iter().enumerate() { | |
| 189 | 190 | sqlx::query( | |
| 190 | 191 | "UPDATE milestones SET position = ? WHERE id = ? AND user_id = ? AND project_id = ?" | |
| @@ -193,10 +194,11 @@ impl MilestoneRepository for SqliteMilestoneRepository { | |||
| 193 | 194 | .bind(id.to_string()) | |
| 194 | 195 | .bind(user_id.to_string()) | |
| 195 | 196 | .bind(project_id.to_string()) | |
| 196 | - | .execute(&self.pool) | |
| 197 | + | .execute(&mut *tx) | |
| 197 | 198 | .await | |
| 198 | 199 | .map_err(CoreError::database)?; | |
| 199 | 200 | } | |
| 201 | + | tx.commit().await.map_err(CoreError::database)?; | |
| 200 | 202 | Ok(()) | |
| 201 | 203 | } | |
| 202 | 204 | } |
| @@ -193,12 +193,16 @@ fn prepare_fts5_query(query: &str) -> String { | |||
| 193 | 193 | // Split into words and add prefix matching | |
| 194 | 194 | query | |
| 195 | 195 | .split_whitespace() | |
| 196 | - | .map(|word| { | |
| 196 | + | .filter_map(|word| { | |
| 197 | 197 | // Escape special FTS5 characters | |
| 198 | 198 | let escaped = word | |
| 199 | 199 | .replace('"', "\"\"") | |
| 200 | 200 | .replace(['*', '(', ')', ':'], ""); | |
| 201 | - | format!("\"{}\"*", escaped) | |
| 201 | + | if escaped.is_empty() { | |
| 202 | + | None | |
| 203 | + | } else { | |
| 204 | + | Some(format!("\"{}\"*", escaped)) | |
| 205 | + | } | |
| 202 | 206 | }) | |
| 203 | 207 | .collect::<Vec<_>>() | |
| 204 | 208 | .join(" ") | |
| @@ -306,10 +310,10 @@ async fn search_tasks_fts( | |||
| 306 | 310 | // Project filter by name (partial match) | |
| 307 | 311 | if let Some(pname) = &query.project_name { | |
| 308 | 312 | sql.push_str(&format!( | |
| 309 | - | " AND t.project_id IN (SELECT id FROM projects WHERE name LIKE ${} COLLATE NOCASE)", | |
| 313 | + | " AND t.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)", | |
| 310 | 314 | param_idx | |
| 311 | 315 | )); | |
| 312 | - | params.push(format!("%{}%", pname)); | |
| 316 | + | params.push(format!("%{}%", escape_like(pname))); | |
| 313 | 317 | param_idx += 1; | |
| 314 | 318 | } | |
| 315 | 319 | ||
| @@ -450,10 +454,10 @@ async fn search_emails_fts( | |||
| 450 | 454 | // Project filter by name | |
| 451 | 455 | if let Some(pname) = &query.project_name { | |
| 452 | 456 | sql.push_str(&format!( | |
| 453 | - | " AND e.project_id IN (SELECT id FROM projects WHERE name LIKE ${} COLLATE NOCASE)", | |
| 457 | + | " AND e.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)", | |
| 454 | 458 | param_idx | |
| 455 | 459 | )); | |
| 456 | - | params.push(format!("%{}%", pname)); | |
| 460 | + | params.push(format!("%{}%", escape_like(pname))); | |
| 457 | 461 | param_idx += 1; | |
| 458 | 462 | } | |
| 459 | 463 | ||
| @@ -694,10 +698,10 @@ async fn search_events_fts( | |||
| 694 | 698 | // Project filter by name | |
| 695 | 699 | if let Some(pname) = &query.project_name { | |
| 696 | 700 | sql.push_str(&format!( | |
| 697 | - | " AND ev.project_id IN (SELECT id FROM projects WHERE name LIKE ${} COLLATE NOCASE)", | |
| 701 | + | " AND ev.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)", | |
| 698 | 702 | param_idx | |
| 699 | 703 | )); | |
| 700 | - | params.push(format!("%{}%", pname)); | |
| 704 | + | params.push(format!("%{}%", escape_like(pname))); | |
| 701 | 705 | param_idx += 1; | |
| 702 | 706 | } | |
| 703 | 707 |
| @@ -73,17 +73,20 @@ pub(crate) async fn get_active_sessions_for_tasks( | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | /// Start a timer on a task. Fails if any session is already active for the user. | |
| 76 | + | /// Uses a transaction to prevent double-start from concurrent requests. | |
| 76 | 77 | pub(crate) async fn start_timer( | |
| 77 | 78 | pool: &SqlitePool, | |
| 78 | 79 | task_id: TaskId, | |
| 79 | 80 | user_id: UserId, | |
| 80 | 81 | ) -> Result<TimeSession> { | |
| 81 | - | // Check for existing active session | |
| 82 | + | let mut tx = pool.begin().await.map_err(CoreError::database)?; | |
| 83 | + | ||
| 84 | + | // Check for existing active session (inside transaction to prevent race) | |
| 82 | 85 | let existing: Option<(String,)> = sqlx::query_as( | |
| 83 | 86 | "SELECT id FROM time_sessions WHERE user_id = ? AND ended_at IS NULL LIMIT 1" | |
| 84 | 87 | ) | |
| 85 | 88 | .bind(user_id.to_string()) | |
| 86 | - | .fetch_optional(pool) | |
| 89 | + | .fetch_optional(&mut *tx) | |
| 87 | 90 | .await | |
| 88 | 91 | .map_err(CoreError::database)?; | |
| 89 | 92 | ||
| @@ -105,7 +108,7 @@ pub(crate) async fn start_timer( | |||
| 105 | 108 | .bind(user_id.to_string()) | |
| 106 | 109 | .bind(&now) | |
| 107 | 110 | .bind(&now) | |
| 108 | - | .execute(pool) | |
| 111 | + | .execute(&mut *tx) | |
| 109 | 112 | .await | |
| 110 | 113 | .map_err(CoreError::database)?; | |
| 111 | 114 | ||
| @@ -113,14 +116,16 @@ pub(crate) async fn start_timer( | |||
| 113 | 116 | "SELECT id, task_id, user_id, started_at, ended_at, duration_minutes, created_at FROM time_sessions WHERE id = ?" | |
| 114 | 117 | ) | |
| 115 | 118 | .bind(id.to_string()) | |
| 116 | - | .fetch_one(pool) | |
| 119 | + | .fetch_one(&mut *tx) | |
| 117 | 120 | .await | |
| 118 | 121 | .map_err(CoreError::database)?; | |
| 119 | 122 | ||
| 123 | + | tx.commit().await.map_err(CoreError::database)?; | |
| 120 | 124 | row.into_session() | |
| 121 | 125 | } | |
| 122 | 126 | ||
| 123 | 127 | /// Stop the active timer on a task. Updates duration_minutes and the task's actual_minutes cache. | |
| 128 | + | /// Uses a transaction so session end and task actual_minutes are updated atomically. | |
| 124 | 129 | pub(crate) async fn stop_timer( | |
| 125 | 130 | pool: &SqlitePool, | |
| 126 | 131 | task_id: TaskId, | |
| @@ -147,6 +152,8 @@ pub(crate) async fn stop_timer( | |||
| 147 | 152 | let duration = (now - started_at).num_minutes().max(0) as i32; | |
| 148 | 153 | let now_str = format_datetime(&now); | |
| 149 | 154 | ||
| 155 | + | let mut tx = pool.begin().await.map_err(CoreError::database)?; | |
| 156 | + | ||
| 150 | 157 | // Update the session | |
| 151 | 158 | sqlx::query( | |
| 152 | 159 | "UPDATE time_sessions SET ended_at = ?, duration_minutes = ? WHERE id = ?" | |
| @@ -154,7 +161,7 @@ pub(crate) async fn stop_timer( | |||
| 154 | 161 | .bind(&now_str) | |
| 155 | 162 | .bind(duration) | |
| 156 | 163 | .bind(&row.id) | |
| 157 | - | .execute(pool) | |
| 164 | + | .execute(&mut *tx) | |
| 158 | 165 | .await | |
| 159 | 166 | .map_err(CoreError::database)?; | |
| 160 | 167 | ||
| @@ -164,10 +171,12 @@ pub(crate) async fn stop_timer( | |||
| 164 | 171 | ) | |
| 165 | 172 | .bind(duration) | |
| 166 | 173 | .bind(task_id.to_string()) | |
| 167 | - | .execute(pool) | |
| 174 | + | .execute(&mut *tx) | |
| 168 | 175 | .await | |
| 169 | 176 | .map_err(CoreError::database)?; | |
| 170 | 177 | ||
| 178 | + | tx.commit().await.map_err(CoreError::database)?; | |
| 179 | + | ||
| 171 | 180 | // Fetch updated session | |
| 172 | 181 | let updated = sqlx::query_as::<_, TimeSessionRow>( | |
| 173 | 182 | "SELECT id, task_id, user_id, started_at, ended_at, duration_minutes, created_at FROM time_sessions WHERE id = ?" |
| @@ -129,14 +129,15 @@ mod goingson_api { | |||
| 129 | 129 | return Err("Permission denied: file_read capability not granted".into()); | |
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | - | // Check if path matches the import file (sandboxing) | |
| 132 | + | // Check if path matches the import file (sandboxing via canonical path comparison) | |
| 133 | 133 | if let Some(ref allowed) = ctx.import_file_path { | |
| 134 | - | // Normalize paths for comparison | |
| 135 | - | let allowed_path = std::path::Path::new(allowed); | |
| 136 | - | let request_path = std::path::Path::new(path); | |
| 134 | + | let allowed_canonical = std::fs::canonicalize(allowed) | |
| 135 | + | .map_err(|e| format!("Cannot resolve import file path: {}", e))?; | |
| 136 | + | let request_canonical = std::fs::canonicalize(path) | |
| 137 | + | .map_err(|e| format!("Cannot resolve requested path: {}", e))?; | |
| 137 | 138 | ||
| 138 | - | // Allow reading the exact import file | |
| 139 | - | if allowed_path != request_path { | |
| 139 | + | // Allow reading the exact import file (canonical comparison prevents traversal) | |
| 140 | + | if allowed_canonical != request_canonical { | |
| 140 | 141 | return Err(format!( | |
| 141 | 142 | "Permission denied: can only read import file '{}'", | |
| 142 | 143 | allowed |
| @@ -0,0 +1,73 @@ | |||
| 1 | + | # GoingsOn — Completed Items | |
| 2 | + | ||
| 3 | + | Moved from todo.md to keep the active list focused. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Email Compose | |
| 8 | + | ||
| 9 | + | - [x] HTML email body conversion to readable markdown via pter (replaces hand-rolled strip_html) | |
| 10 | + | - [x] Reply / Reply-All — two distinct buttons, In-Reply-To/References headers, quoted body, thread joining | |
| 11 | + | - [x] Forward — Fwd: prefix, forwarded message header block, From account auto-select | |
| 12 | + | - [x] CC / BCC fields — togglable CC/BCC rows in compose, SMTP CC/BCC headers | |
| 13 | + | - [x] Multiple recipients — comma-separated To/CC/BCC, per-address SMTP validation | |
| 14 | + | ||
| 15 | + | --- | |
| 16 | + | ||
| 17 | + | ## Usability Audit Remediations (2026-05-02) | |
| 18 | + | ||
| 19 | + | Audit run: `/use-fuzz GoingsOn`. Overall grade: B+. | |
| 20 | + | ||
| 21 | + | ### Bugs | |
| 22 | + | - [x] Fix `g v` keyboard shortcut — maps to Weekly Review but overlay says "Events" (keyboard.js:42 vs :156) | |
| 23 | + | - [x] Fix mobile email reply prefill — openComposeModal now accepts prefill data for reply/forward | |
| 24 | + | ||
| 25 | + | ### Discoverability (B-) | |
| 26 | + | - [x] Add Events pill to Time tab navigation — full view currently keyboard/URL-only | |
| 27 | + | - [x] Add overflow/kebab icon on hover for item rows — surfaces context menu actions without right-click | |
| 28 | + | - [x] Add visible Quick Add button or input in Tasks header — `q` shortcut is invisible | |
| 29 | + | - [x] Add "Create Event" to email right-click context menu — parity with existing "Create Task" | |
| 30 | + | - [x] Add one-time onboarding hints: "Press ? for shortcuts" on first launch, "Shift-click to select range" on first bulk selection | |
| 31 | + | - [x] Add play/timer icon to task rows for started tasks — time tracking entry point is buried | |
| 32 | + | ||
| 33 | + | ### Learnability (B) | |
| 34 | + | - [x] Add hint text for day planner paint-to-create: "Drag across time slots to block time" | |
| 35 | + | - [x] Add introductory paragraph for first weekly review explaining the workflow | |
| 36 | + | - [x] Add introductory content for first monthly review (title hidden by CSS, no explanation) | |
| 37 | + | - [x] Add brief descriptions to differentiate Start Task / Schedule Time / Track Time / Focus Mode | |
| 38 | + | - [x] Add 2-3 sentence explanation in sync setup panel (what SyncKit is, what syncs, E2E encryption) | |
| 39 | + | - [x] IMAP auto-detect server settings from email domain (Gmail, Fastmail, Outlook, Yahoo, iCloud) | |
| 40 | + | - [x] Rename "Pri" column header to "Priority" (only abbreviated header in task table) | |
| 41 | + | ||
| 42 | + | ### Complexity (A-) | |
| 43 | + | - [x] Collapse task creation form: show 4 fields (Description, Project, Priority, Due Date) + "More options" toggle for Tags, Recurrence, Estimated Time, Contact, Milestone | |
| 44 | + | - [x] Group time-related context menu items into "Time" submenu or consolidate Track/Focus | |
| 45 | + | - [x] Add undo toast for keyboard task completion (`c` key) — matches delete undo pattern | |
| 46 | + | ||
| 47 | + | ### Feature Completeness (B-) | |
| 48 | + | - [x] Manual time entry — log time retroactively, not just live timer | |
| 49 | + | - [x] Bulk "Set Project" and "Set Priority" in task bulk actions bar | |
| 50 | + | - [x] Monthly review: add explicit "Complete Review" action (weekly has it, monthly does not) | |
| 51 | + | ||
| 52 | + | --- | |
| 53 | + | ||
| 54 | + | ## Code Fuzz Fixes (2026-05-03) | |
| 55 | + | ||
| 56 | + | Audit run: `/code-fuzz goingson`. 8 serious, 10 minor. 7/8 serious fixed, 7/10 minor fixed. | |
| 57 | + | ||
| 58 | + | ### Serious (fixed) | |
| 59 | + | - [x] JMAP token refresh writes empty string to DB instead of actual token (email_sync.rs:289) | |
| 60 | + | - [x] Sync `applying_remote` flag is global — fixed via WAL-isolated transaction on dedicated connection (pull.rs) | |
| 61 | + | - [x] `stop_timer` non-atomic across session + task tables — wrapped in transaction (time_session_repo.rs) | |
| 62 | + | - [x] `milestone reorder` non-atomic — wrapped in transaction (milestone_repo.rs) | |
| 63 | + | - [x] `start_timer` race condition — check+insert wrapped in transaction (time_session_repo.rs) | |
| 64 | + | - [x] Blob sync writes not atomic — tmp+rename pattern (blob_sync.rs) | |
| 65 | + | - [x] Synthetic email message-ID uses non-zero-padded hex — {:02x} (email_sync.rs) | |
| 66 | + | ||
| 67 | + | ### Minor (fixed) | |
| 68 | + | - [x] Plugin sandbox uses lexical path comparison — canonicalize() (plugin-runtime/api.rs) | |
| 69 | + | - [x] CSV formula injection: added `;`/`|` prefixes, tags sanitized individually (export/csv.rs) | |
| 70 | + | - [x] Day boundary at 23:59:59.000 misses sub-second events — use `< next_day_start` (weekly_review.rs, monthly_review.rs) | |
| 71 | + | - [x] Weekly review `event_count` only counts past events — now counts both sources (weekly_review.rs) | |
| 72 | + | - [x] Three unescaped `taskId` in inline handlers — added escAttr() (tasks-render.js) | |
| 73 | + | - [x] vCard unfold strips extra tabs from continuation lines — removed trim_start_matches (vcard.rs) |
| @@ -61,17 +61,23 @@ function showContextMenu(x, y, items) { | |||
| 61 | 61 | if (item === 'separator') { | |
| 62 | 62 | return '<div class="context-menu-separator"></div>'; | |
| 63 | 63 | } | |
| 64 | + | if (item.type === 'header') { | |
| 65 | + | return `<div class="context-menu-header">${GoingsOn.utils.escapeHtml(item.label)}</div>`; | |
| 66 | + | } | |
| 64 | 67 | const dangerClass = item.danger ? ' context-menu-item--danger' : ''; | |
| 65 | 68 | const shortcutHtml = item.shortcut | |
| 66 | 69 | ? `<span class="context-menu-item-shortcut">${GoingsOn.utils.escapeHtml(item.shortcut)}</span>` | |
| 67 | 70 | : ''; | |
| 71 | + | const subtitleHtml = item.subtitle | |
| 72 | + | ? `<span class="context-menu-item-subtitle">${GoingsOn.utils.escapeHtml(item.subtitle)}</span>` | |
| 73 | + | : ''; | |
| 68 | 74 | return ` | |
| 69 | 75 | <button class="context-menu-item${dangerClass}" | |
| 70 | 76 | data-index="${index}" | |
| 71 | 77 | role="menuitem" | |
| 72 | 78 | tabindex="-1"> | |
| 73 | 79 | <span class="context-menu-item-icon">${item.icon || ''}</span> | |
| 74 | - | <span class="context-menu-item-label">${GoingsOn.utils.escapeHtml(item.label)}</span> | |
| 80 | + | <span class="context-menu-item-label">${GoingsOn.utils.escapeHtml(item.label)}${subtitleHtml}</span> | |
| 75 | 81 | ${shortcutHtml} | |
| 76 | 82 | </button> | |
| 77 | 83 | `; | |
| @@ -210,7 +216,7 @@ document.addEventListener('scroll', () => { | |||
| 210 | 216 | function getTaskContextMenuItems(taskId, task = null) { | |
| 211 | 217 | const items = [ | |
| 212 | 218 | { label: 'Edit Task', shortcut: 'e', action: () => GoingsOn.tasks.openEdit(taskId) }, | |
| 213 | - | { label: 'Start Task', action: () => GoingsOn.tasks.start(taskId) }, | |
| 219 | + | { label: 'Start Task', subtitle: 'Mark as in-progress', action: () => GoingsOn.tasks.start(taskId) }, | |
| 214 | 220 | { label: 'Complete Task', shortcut: 'c', action: () => GoingsOn.tasks.complete(taskId) }, | |
| 215 | 221 | 'separator', | |
| 216 | 222 | { label: 'Manage Subtasks', action: () => GoingsOn.tasks.openSubtasks(taskId) }, | |
| @@ -218,9 +224,10 @@ function getTaskContextMenuItems(taskId, task = null) { | |||
| 218 | 224 | { label: 'Set Milestone...', action: () => GoingsOn.tasks.openSetMilestone(taskId) }, | |
| 219 | 225 | 'separator', | |
| 220 | 226 | { label: 'Snooze...', action: () => GoingsOn.snooze.openModal('task', taskId) }, | |
| 221 | - | { label: 'Schedule Time', action: () => GoingsOn.dayPlan.openScheduleTaskModal(taskId) }, | |
| 222 | - | { label: 'Track Time', action: () => GoingsOn.timeTracking.startTimer(taskId) }, | |
| 223 | - | { label: 'Focus Mode', action: () => GoingsOn.focusTimer.start(taskId) }, | |
| 227 | + | { type: 'header', label: 'Time' }, | |
| 228 | + | { label: 'Schedule Time', subtitle: 'Block time on day planner', action: () => GoingsOn.dayPlan.openScheduleTaskModal(taskId) }, | |
| 229 | + | { label: 'Track Time', subtitle: 'Start live timer', action: () => GoingsOn.timeTracking.startTimer(taskId) }, | |
| 230 | + | { label: 'Focus Mode', subtitle: 'Pomodoro-style timer', action: () => GoingsOn.focusTimer.start(taskId) }, | |
| 224 | 231 | 'separator', | |
| 225 | 232 | { label: 'Delete Task', danger: true, action: () => GoingsOn.tasks.delete(taskId) }, | |
| 226 | 233 | ]; | |
| @@ -249,6 +256,7 @@ function getEmailContextMenuItems(emailId, email = null) { | |||
| 249 | 256 | : { label: 'Archive', shortcut: 'a', action: () => GoingsOn.emails.archive(emailId) }, | |
| 250 | 257 | 'separator', | |
| 251 | 258 | { label: 'Create Task', shortcut: 't', action: () => GoingsOn.emails.createTaskFromEmail(emailId) }, | |
| 259 | + | { label: 'Create Event', shortcut: 'e', action: () => GoingsOn.emails.createEventFromEmail(emailId) }, | |
| 252 | 260 | 'separator', | |
| 253 | 261 | isSnoozed | |
| 254 | 262 | ? { label: 'Unsnooze', action: () => GoingsOn.snooze.unsnooze('email', emailId) } |
| @@ -575,10 +575,27 @@ | |||
| 575 | 575 | */ | |
| 576 | 576 | async function complete(id) { | |
| 577 | 577 | GoingsOn.cache.invalidate('tasks'); | |
| 578 | - | await GoingsOn.ui.apiCall(GoingsOn.api.tasks.complete(id), { | |
| 579 | - | successMessage: 'Task completed!', | |
| 580 | - | errorMessage: 'Failed to complete task', | |
| 581 | - | reload: load, | |
| 578 | + | ||
| 579 | + | // Optimistically hide from UI | |
| 580 | + | const cachedTasks = GoingsOn.state.tasks; | |
| 581 | + | const removedTask = cachedTasks.find(t => t.id === id); | |
| 582 | + | GoingsOn.state.set('tasks', cachedTasks.filter(t => t.id !== id)); | |
| 583 | + | ||
| 584 | + | GoingsOn.ui.showUndoToast('Task completed', { | |
| 585 | + | onConfirm: async () => { | |
| 586 | + | try { | |
| 587 | + | await GoingsOn.api.tasks.complete(id); | |
| 588 | + | load(); | |
| 589 | + | } catch (err) { | |
| 590 | + | GoingsOn.ui.showToast('Failed to complete task', 'error'); | |
| 591 | + | load(); | |
| 592 | + | } | |
| 593 | + | }, | |
| 594 | + | onUndo: () => { | |
| 595 | + | if (removedTask) { | |
| 596 | + | GoingsOn.state.set('tasks', [...GoingsOn.state.tasks, removedTask]); | |
| 597 | + | } | |
| 598 | + | }, | |
| 582 | 599 | }); | |
| 583 | 600 | } | |
| 584 | 601 |