Skip to main content

max / goingson

Performance: make event/search date predicates sargable De-wrap datetime()/date() around indexed date columns so SQLite can use the date indexes instead of a full scan: - event_repo: get_upcoming, list_for_date, list_between, list_snoozed compare e.start_time / e.end_time / e.snoozed_until as bare columns (already bound same-format strings). - search_repo: task is:overdue/thisweek/snoozed and the task/project/event date-range filters. For the range filters the bound param also moves from to_rfc3339() to format_datetime(), since bare-column text comparison needs the "%Y-%m-%d %H:%M:%S" format the columns are stored in (the datetime() wrap had been reconciling rfc3339 vs stored format). - The Today/Tomorrow filters keep date(col,'localtime') — they must convert the UTC-stored value to the user's local calendar day — with an explaining comment. Suite: 785 pass; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 22:07 UTC
Commit: df15075eefceabf4d44d02b07446a8ae4cbeeb29
Parent: 3d70ab9
2 files changed, +32 insertions, -24 deletions
@@ -310,7 +310,7 @@ impl EventRepository for SqliteEventRepository {
310 310 #[tracing::instrument(skip_all)]
311 311 async fn get_upcoming(&self, user_id: UserId, days: i64) -> Result<Vec<Event>> {
312 312 let query = format!(
313 - "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('now') AND datetime(e.start_time) <= datetime('now', ? || ' days') ORDER BY e.start_time ASC",
313 + "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 e.start_time >= datetime('now') AND e.start_time <= datetime('now', ? || ' days') ORDER BY e.start_time ASC",
314 314 EVENT_SELECT_COLUMNS
315 315 );
316 316 let rows = sqlx::query_as::<_, EventRow>(&query)
@@ -356,7 +356,7 @@ impl EventRepository for SqliteEventRepository {
356 356 let date_start = format!("{} 00:00:00", date);
357 357 let date_end = format!("{} 23:59:59", date);
358 358 let query = format!(
359 - "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",
359 + "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 e.start_time <= ? AND (e.end_time IS NULL OR e.end_time >= ?) ORDER BY e.start_time ASC",
360 360 EVENT_SELECT_COLUMNS
361 361 );
362 362 let rows = sqlx::query_as::<_, EventRow>(&query)
@@ -375,7 +375,7 @@ impl EventRepository for SqliteEventRepository {
375 375 let start_str = format_datetime(&start);
376 376 let end_str = format_datetime(&end);
377 377 let query = format!(
378 - "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",
378 + "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 e.start_time <= ? AND (e.end_time IS NULL OR e.end_time >= ?) ORDER BY e.start_time ASC",
379 379 EVENT_SELECT_COLUMNS
380 380 );
381 381 let rows = sqlx::query_as::<_, EventRow>(&query)
@@ -460,7 +460,7 @@ impl EventRepository for SqliteEventRepository {
460 460 #[tracing::instrument(skip_all)]
461 461 async fn list_snoozed(&self, user_id: UserId) -> Result<Vec<Event>> {
462 462 let query = format!(
463 - "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 e.snoozed_until IS NOT NULL AND datetime(e.snoozed_until) > datetime('now') ORDER BY e.snoozed_until ASC",
463 + "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 e.snoozed_until IS NOT NULL AND e.snoozed_until > datetime('now') ORDER BY e.snoozed_until ASC",
464 464 EVENT_SELECT_COLUMNS
465 465 );
466 466 let rows = sqlx::query_as::<_, EventRow>(&query)
@@ -221,15 +221,19 @@ fn build_is_filter_clauses(is_filters: &[IsFilter]) -> Vec<String> {
221 221 .iter()
222 222 .map(|f| {
223 223 match f {
224 - IsFilter::Overdue => "(t.due IS NOT NULL AND datetime(t.due) < datetime('now'))".to_string(),
224 + IsFilter::Overdue => "(t.due IS NOT NULL AND t.due < datetime('now'))".to_string(),
225 + // Today/Tomorrow must convert the UTC-stored due to the local
226 + // calendar day, so date(..., 'localtime') is required and these
227 + // two predicates are intentionally non-sargable (a bare-column
228 + // compare would test the UTC date, not the user's local date).
225 229 IsFilter::Today => "(t.due IS NOT NULL AND date(t.due, 'localtime') = date('now', 'localtime'))".to_string(),
226 230 IsFilter::Tomorrow => "(t.due IS NOT NULL AND date(t.due, 'localtime') = date('now', '+1 day', 'localtime'))".to_string(),
227 231 IsFilter::ThisWeek => {
228 232 // Due on or before end of this week (Sunday).
229 233 // weekday 1 = next Monday; < Monday midnight = through Sunday 23:59:59.
230 - "(t.due IS NOT NULL AND datetime(t.due) < datetime('now', 'weekday 1'))".to_string()
234 + "(t.due IS NOT NULL AND t.due < datetime('now', 'weekday 1'))".to_string()
231 235 }
232 - IsFilter::Snoozed => "(t.snoozed_until IS NOT NULL AND datetime(t.snoozed_until) > datetime('now'))".to_string(),
236 + IsFilter::Snoozed => "(t.snoozed_until IS NOT NULL AND t.snoozed_until > datetime('now'))".to_string(),
233 237 IsFilter::Pending => "t.status = 'Pending'".to_string(),
234 238 IsFilter::Started => "t.status = 'Started'".to_string(),
235 239 IsFilter::Completed => "t.status = 'Completed'".to_string(),
@@ -350,21 +354,24 @@ async fn search_tasks_fts(
350 354 param_idx += 1;
351 355 }
352 356
353 - // Date filters
357 + // Date filters. Compare the stored `due` text directly (sortable
358 + // "%Y-%m-%d %H:%M:%S" format) and bind the bound in the same format, so the
359 + // predicate is sargable against the due index instead of wrapping the column
360 + // in datetime().
354 361 if let Some(df) = &query.date_from {
355 362 sql.push_str(&format!(
356 - " AND (t.due IS NULL OR datetime(t.due) >= datetime(${})) ",
363 + " AND (t.due IS NULL OR t.due >= ${}) ",
357 364 param_idx
358 365 ));
359 - params.push(df.to_rfc3339());
366 + params.push(format_datetime(df));
360 367 param_idx += 1;
361 368 }
362 369 if let Some(dt) = &query.date_to {
363 370 sql.push_str(&format!(
364 - " AND (t.due IS NULL OR datetime(t.due) <= datetime(${})) ",
371 + " AND (t.due IS NULL OR t.due <= ${}) ",
365 372 param_idx
366 373 ));
367 - params.push(dt.to_rfc3339());
374 + params.push(format_datetime(dt));
368 375 }
369 376
370 377 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));
@@ -559,21 +566,22 @@ async fn search_projects_fts(
559 566 let mut params: Vec<String> = Vec::new();
560 567 let mut param_idx = 3;
561 568
562 - // Date filters (on created_at)
569 + // Date filters (on created_at). Sargable bare-column comparison against a
570 + // same-format bound (see the task/email date filters).
563 571 if let Some(df) = &query.date_from {
564 572 sql.push_str(&format!(
565 - " AND datetime(p.created_at) >= datetime(${})",
573 + " AND p.created_at >= ${}",
566 574 param_idx
567 575 ));
568 - params.push(df.to_rfc3339());
576 + params.push(format_datetime(df));
569 577 param_idx += 1;
570 578 }
571 579 if let Some(dt) = &query.date_to {
572 580 sql.push_str(&format!(
573 - " AND datetime(p.created_at) <= datetime(${})",
581 + " AND p.created_at <= ${}",
574 582 param_idx
575 583 ));
576 - params.push(dt.to_rfc3339());
584 + params.push(format_datetime(dt));
577 585 }
578 586
579 587 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));
@@ -685,10 +693,10 @@ async fn search_events_fts(
685 693 );
686 694 }
687 695 IsFilter::ThisWeek => {
688 - sql.push_str(" AND datetime(ev.start_time) < datetime('now', 'weekday 1')");
696 + sql.push_str(" AND ev.start_time < datetime('now', 'weekday 1')");
689 697 }
690 698 IsFilter::Overdue => {
691 - sql.push_str(" AND datetime(ev.start_time) < datetime('now')");
699 + sql.push_str(" AND ev.start_time < datetime('now')");
692 700 }
693 701 _ => {} // Other is: filters don't apply to events
694 702 }
@@ -711,21 +719,21 @@ async fn search_events_fts(
711 719 param_idx += 1;
712 720 }
713 721
714 - // Date filters
722 + // Date filters. Sargable bare-column comparison against a same-format bound.
715 723 if let Some(df) = &query.date_from {
716 724 sql.push_str(&format!(
717 - " AND datetime(ev.start_time) >= datetime(${})",
725 + " AND ev.start_time >= ${}",
718 726 param_idx
719 727 ));
720 - params.push(df.to_rfc3339());
728 + params.push(format_datetime(df));
721 729 param_idx += 1;
722 730 }
723 731 if let Some(dt) = &query.date_to {
724 732 sql.push_str(&format!(
725 - " AND datetime(ev.start_time) <= datetime(${})",
733 + " AND ev.start_time <= ${}",
726 734 param_idx
727 735 ));
728 - params.push(dt.to_rfc3339());
736 + params.push(format_datetime(dt));
729 737 }
730 738
731 739 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));