From 85a6a1dd808d921156e24bcb88708da167ae327f Mon Sep 17 00:00:00 2001 From: "seungju24.choi" Date: Tue, 18 Aug 2026 18:24:28 +0900 Subject: [PATCH 1/2] resolve recurrence series refs from search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recurring row shows its series ref (RCR-NP3S) in the task list preview, and resolve_recurrence_ref accepts that ref everywhere else, but search returned nothing for it: the ref lane only ever matched a task id, and the prefix check demanded a project prefix, which RCR never is. Give the ref lane a second identity to match. When the query names no prefix or names RCR, load the occurrence tasks whose series id carries the suffix — under the same visibility rules the task ref lane uses, so paused and archived projections stay out — and score them against the series ref. Recurrence grouping then collapses the occurrences into the single row the caller expects. The scoring math moves into score_ref_identity so both identities weigh a ref the same way, and SERIES_REF_PREFIX moves next to the display-ref formatter that mints it. --- crates/aven-core/src/operations/recurrence.rs | 5 +- .../aven-core/src/query/recurrence_tests.rs | 50 ++++++- crates/aven-core/src/query/search.rs | 125 +++++++++++++++++- crates/aven-core/src/recurrence.rs | 6 +- 4 files changed, 178 insertions(+), 8 deletions(-) diff --git a/crates/aven-core/src/operations/recurrence.rs b/crates/aven-core/src/operations/recurrence.rs index b8eabec4..03585a7f 100644 --- a/crates/aven-core/src/operations/recurrence.rs +++ b/crates/aven-core/src/operations/recurrence.rs @@ -19,8 +19,8 @@ use crate::mutation::apply_field_value_in_workspace; use crate::projects::resolve_or_create_project_in_workspace; use crate::recurrence::{ RecurrenceDuePolicy, RecurrenceOutcome, RecurrenceProjectionState, RecurrenceSchedule, - RecurrenceSeriesId, RecurrenceSeriesState, derive_occurrence_identity, next_slot_after, - projection_slot_at, recurrence_series_display_ref, slot_cutoff, slot_values, + RecurrenceSeriesId, RecurrenceSeriesState, SERIES_REF_PREFIX, derive_occurrence_identity, + next_slot_after, projection_slot_at, recurrence_series_display_ref, slot_cutoff, slot_values, }; use crate::refs::get_task_in_workspace; use crate::task_fields::TaskField; @@ -33,7 +33,6 @@ use crate::workspaces::Workspace; mod tests; const RECONCILE_ATTEMPTS: usize = 3; -const SERIES_REF_PREFIX: &str = "RCR"; const SERIES_TEMPLATE_FIELDS: &[&str] = &[ "title", "description", diff --git a/crates/aven-core/src/query/recurrence_tests.rs b/crates/aven-core/src/query/recurrence_tests.rs index 81d69600..b9e503da 100644 --- a/crates/aven-core/src/query/recurrence_tests.rs +++ b/crates/aven-core/src/query/recurrence_tests.rs @@ -3,7 +3,9 @@ use sqlx::Connection; use super::*; use crate::operations::{CreateRecurrenceSeriesParams, RecurrenceSeriesDraft}; -use crate::query::{SortDirection, TaskFilters, TaskQueryMode, TaskSearchQuery, TaskSort}; +use crate::query::{ + SearchMatchedField, SortDirection, TaskFilters, TaskQueryMode, TaskSearchQuery, TaskSort, +}; use crate::recurrence::{ RecurrenceDuePolicy, RecurrenceOutcome, RecurrenceRule, RecurrenceSchedule, TimeZoneId, }; @@ -752,3 +754,49 @@ async fn recurrence_hydration_statement_shapes_stay_bounded() { assert!(items.iter().all(|item| item.recurrence.is_some())); assert!(conn.cached_statements_size() <= 16); } + +#[tokio::test] +async fn search_resolves_recurrence_series_refs_to_their_occurrences() { + let (_temp, database, workspace) = setup().await; + let created = create(&database, &workspace, "series ref fixture", 6).await; + let series_ref = created.series_ref.clone(); + let suffix = series_ref.split_once('-').unwrap().1.to_string(); + + let search = |text: String| { + let database = database.clone(); + let workspace_id = workspace.id.clone(); + async move { + let mut conn = database.acquire_writer().await.unwrap(); + super::super::search::search_task_items_in_workspace( + &mut conn, + &workspace_id, + TaskSearchQuery { + metadata: Vec::new(), + has_metadata: Vec::new(), + missing_metadata: Vec::new(), + text, + project: None, + include_deleted: false, + limit: 20, + }, + ) + .await + .unwrap() + } + }; + + // The series ref is what the UI shows for a recurring row, so searching it + // has to land on the occurrence the series currently projects. + let qualified = search(series_ref).await; + assert_eq!(qualified.len(), 1); + assert_eq!(qualified[0].item.task.id, created.task.id); + assert_eq!(qualified[0].matched_field, SearchMatchedField::Ref); + + let bare = search(suffix.clone()).await; + assert_eq!(bare.len(), 1); + assert_eq!(bare[0].item.task.id, created.task.id); + assert_eq!(bare[0].matched_field, SearchMatchedField::Ref); + + let wrong_prefix = search(format!("/APP-{suffix}")).await; + assert!(wrong_prefix.is_empty()); +} diff --git a/crates/aven-core/src/query/search.rs b/crates/aven-core/src/query/search.rs index fd57450c..a5d09842 100644 --- a/crates/aven-core/src/query/search.rs +++ b/crates/aven-core/src/query/search.rs @@ -125,6 +125,15 @@ struct SearchDocument { labels_text: String, notes_text: String, attachments_text: String, + series: Option, +} + +/// Recurrence identity of an occurrence task. The ref lane reads it so a series +/// ref like `RCR-NP3S` — the ref the UI shows for a recurring row — resolves to +/// the occurrences that series projects. +struct DocumentSeries { + id: String, + display_ref: String, } struct ScoredDocument { @@ -551,6 +560,16 @@ async fn load_candidate_search_documents( ) .await?; merge_search_documents(&mut documents, ref_documents); + let series_documents = load_series_ref_search_documents( + conn, + workspace_id, + project_id, + include_deleted, + ref_query, + display_refs, + ) + .await?; + merge_search_documents(&mut documents, series_documents); } let attachment_documents = load_attachment_text_search_documents( conn, @@ -563,6 +582,9 @@ async fn load_candidate_search_documents( .await?; merge_search_documents(&mut documents, attachment_documents); attach_attachment_search_text(conn, workspace_id.as_str(), &mut documents).await?; + if parsed.ref_query.is_some() { + attach_recurrence_series_refs(conn, workspace_id, &mut documents).await?; + } Ok(documents) } @@ -600,6 +622,75 @@ async fn load_ref_search_documents( search_documents_from_rows(rows, display_refs) } +/// Suffix a series ref query addresses, or `None` when the query names a project +/// prefix and so cannot be a series ref. +fn series_ref_suffix(ref_query: &parser::ParsedRefSearchQuery) -> Option<&str> { + match ref_query.normalized_prefix.as_deref() { + Some(prefix) if prefix != crate::recurrence::SERIES_REF_PREFIX => None, + _ => Some(&ref_query.normalized_suffix), + } +} + +async fn load_series_ref_search_documents( + conn: &mut SqliteConnection, + workspace_id: &WorkspaceId, + project_id: Option<&ProjectId>, + include_deleted: bool, + ref_query: &parser::ParsedRefSearchQuery, + display_refs: &DisplayRefContext, +) -> Result> { + let Some(suffix) = series_ref_suffix(ref_query) else { + return Ok(Vec::new()); + }; + let mut query = QueryBuilder::::new( + "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id, + p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix, + t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic, + '' AS fts_labels, '' AS fts_notes + FROM recurrence_occurrences ro + JOIN tasks t ON t.workspace_id = ro.workspace_id AND t.id = ro.task_id + JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id + WHERE ro.workspace_id = ", + ); + query.push_bind(workspace_id); + query.push(" AND ("); + query.push_bind(include_deleted); + query.push(" OR t.deleted = 0) AND ("); + query.push_bind(project_id.is_none()); + query.push(" OR t.project_id = "); + query.push_bind(project_id); + query.push(") AND "); + query.push(super::fragments::ordinary_task_clause("t")); + query.push(" AND ro.series_id LIKE "); + query.push_bind(suffix); + query.push(" || '%' ORDER BY t.updated_at DESC, t.id"); + + let rows = query.build().fetch_all(&mut *conn).await?; + search_documents_from_rows(rows, display_refs) +} + +async fn attach_recurrence_series_refs( + conn: &mut SqliteConnection, + workspace_id: &WorkspaceId, + documents: &mut [SearchDocument], +) -> Result<()> { + let task_ids = documents + .iter() + .map(|document| document.task.id.clone()) + .collect::>(); + let mut summaries = + super::recurrence::task_recurrence_summaries(conn, workspace_id, &task_ids).await?; + for document in documents { + document.series = summaries + .remove(&document.task.id) + .map(|summary| DocumentSeries { + id: summary.series_id.to_string(), + display_ref: summary.series_ref, + }); + } + Ok(()) +} + fn merge_search_documents(documents: &mut Vec, incoming: Vec) { for document in incoming { if !documents @@ -678,6 +769,7 @@ fn search_documents_from_rows( task, display_ref, project_name, + series: None, } }) .collect()) @@ -927,18 +1019,45 @@ fn search_terms(query: &parser::ParsedTaskSearchQuery) -> Vec<&str> { fn score_ref_lane( document: &SearchDocument, ref_query: &parser::ParsedRefSearchQuery, +) -> Option { + // Option ordering keeps whichever identity the query addresses more closely. + score_task_ref_lane(document, ref_query).max(score_series_ref_lane(document, ref_query)) +} + +fn score_task_ref_lane( + document: &SearchDocument, + ref_query: &parser::ParsedRefSearchQuery, ) -> Option { if let Some(prefix) = ref_query.normalized_prefix.as_deref() && normalize_ref_query(&document.task.project_prefix) != prefix { return None; } - let normalized_id = normalize_ref_query(&document.task.id); + score_ref_identity(&document.task.id, &document.display_ref, ref_query) +} + +fn score_series_ref_lane( + document: &SearchDocument, + ref_query: &parser::ParsedRefSearchQuery, +) -> Option { + let suffix = series_ref_suffix(ref_query)?; + let series = document.series.as_ref()?; + normalize_ref_query(&series.id) + .starts_with(suffix) + .then(|| score_ref_identity(&series.id, &series.display_ref, ref_query)) + .flatten() +} + +fn score_ref_identity( + id: &str, + display_ref: &str, + ref_query: &parser::ParsedRefSearchQuery, +) -> Option { + let normalized_id = normalize_ref_query(id); if !normalized_id.starts_with(&ref_query.normalized_suffix) { return None; } - let display_suffix_len = document - .display_ref + let display_suffix_len = display_ref .rsplit_once('-') .map(|(_, suffix)| normalize_ref_query(suffix).len()) .unwrap_or(0); diff --git a/crates/aven-core/src/recurrence.rs b/crates/aven-core/src/recurrence.rs index 45f25b85..c1cff6f1 100644 --- a/crates/aven-core/src/recurrence.rs +++ b/crates/aven-core/src/recurrence.rs @@ -49,6 +49,10 @@ impl RecurrenceSeriesId { } } +/// Prefix every recurrence series display ref carries, in place of the project +/// prefix a task ref uses. +pub(crate) const SERIES_REF_PREFIX: &str = "RCR"; + pub(crate) fn recurrence_series_display_ref( series_id: &RecurrenceSeriesId, ids: &[RecurrenceSeriesId], @@ -66,7 +70,7 @@ pub(crate) fn recurrence_series_display_ref( .max() .unwrap_or(0); let length = 4.max(shared.saturating_add(1)).min(id.len()); - format!("RCR-{}", &id[..length]) + format!("{SERIES_REF_PREFIX}-{}", &id[..length]) } impl Default for RecurrenceSeriesId { From af5c220b2a16e75635bc6e16b3f332a6725748b8 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Wed, 19 Aug 2026 23:28:37 +0300 Subject: [PATCH 2/2] optimize recurrence series ref search Bind the complete GLOB prefix pattern so SQLite can use the existing (workspace_id, series_id, slot_on) primary key as a bounded series ID range. The previous LIKE expression constrained the index only by workspace and scanned every recurrence occurrence while filtering the prefix afterward. This keeps the normalized Crockford Base32 matching behavior unchanged while reducing the work performed by CLI and TUI live searches. There are no breaking behavior changes. --- crates/aven-core/src/query/search.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/aven-core/src/query/search.rs b/crates/aven-core/src/query/search.rs index a5d09842..7d567584 100644 --- a/crates/aven-core/src/query/search.rs +++ b/crates/aven-core/src/query/search.rs @@ -661,9 +661,9 @@ async fn load_series_ref_search_documents( query.push_bind(project_id); query.push(") AND "); query.push(super::fragments::ordinary_task_clause("t")); - query.push(" AND ro.series_id LIKE "); - query.push_bind(suffix); - query.push(" || '%' ORDER BY t.updated_at DESC, t.id"); + query.push(" AND ro.series_id GLOB "); + query.push_bind(format!("{suffix}*")); + query.push(" ORDER BY t.updated_at DESC, t.id"); let rows = query.build().fetch_all(&mut *conn).await?; search_documents_from_rows(rows, display_refs)