Skip to main content

max / goingson

Backend improvements: sync tests, JMAP fixes, code fuzz fixes - Attachment sync tests (blob sync, UPSERT/DELETE ordering) - JMAP email/mailbox error handling improvements - OAuth reconnect flow hardening - Search repo and time session repo improvements - Sync service pull conflict handling - Plugin runtime API cleanup - Various code fuzz fixes across commands layer - Weekly review and monthly review refinements - Completed items archive (todo_done.md) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-04 02:59 UTC
Commit: 2326f8b11fc5af0d122d3bf7ff8cfdd1080f1db7
Parent: 0215064
45 files changed, +787 insertions, -190 deletions
M Cargo.lock +1
@@ -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