Skip to main content

aven_core/operations/
recurrence.rs

1use std::collections::BTreeMap;
2
3use anyhow::{Context, Result, bail, ensure};
4use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
5use serde_json::Value;
6use sqlx::{Row, SqliteConnection};
7
8use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
9use crate::choices::{TaskPriority, TaskStatus};
10use crate::db::{
11    Database, IdentifiedChange, begin_immediate, entity_conflict_exists, entity_field_version,
12    insert_change, insert_change_with_identity, recurrence_occurrence_from_row,
13    recurrence_series_from_row, set_entity_field_version, set_field_version,
14};
15use crate::ids::{TaskId, WorkspaceId, new_id, now};
16use crate::labels::{resolve_labels_in_workspace, resolve_or_create_labels_in_workspace};
17use crate::mutation::apply_field_value_in_workspace;
18use crate::projects::resolve_or_create_project_in_workspace;
19use crate::recurrence::{
20    RecurrenceDuePolicy, RecurrenceOutcome, RecurrenceProjectionState, RecurrenceSchedule,
21    RecurrenceSeriesId, RecurrenceSeriesState, derive_occurrence_identity, next_slot_after,
22    projection_slot_at, recurrence_series_display_ref, slot_cutoff, slot_values,
23};
24use crate::refs::get_task_in_workspace;
25use crate::task_fields::TaskField;
26use crate::types::{MutableEntityType, RecurrenceOccurrence, RecurrenceSeries, Task};
27use crate::undo::{UndoCommand, UndoContext, UndoPayload, record_tui_undo};
28use crate::workspaces::Workspace;
29
30#[cfg(test)]
31#[path = "recurrence_tests.rs"]
32mod tests;
33
34const RECONCILE_ATTEMPTS: usize = 3;
35const SERIES_REF_PREFIX: &str = "RCR";
36const SERIES_TEMPLATE_FIELDS: &[&str] = &[
37    "title",
38    "description",
39    "project",
40    "priority",
41    "initial_status",
42    "labels",
43    "available_local_time",
44    "due_policy",
45    "state",
46    "stopped_at",
47    "deleted",
48];
49
50#[derive(Debug, Clone, Copy, Default)]
51pub struct RecurrenceCreationOptions {
52    create_missing_labels: bool,
53}
54
55impl RecurrenceCreationOptions {
56    pub fn with_create_missing_labels(mut self) -> Self {
57        self.create_missing_labels = true;
58        self
59    }
60}
61
62#[derive(Debug, Clone, Copy, Default)]
63pub struct RecurrenceTemplateUpdateOptions {
64    create_missing_labels: bool,
65}
66
67impl RecurrenceTemplateUpdateOptions {
68    pub fn with_create_missing_labels(mut self) -> Self {
69        self.create_missing_labels = true;
70        self
71    }
72}
73
74#[derive(Debug, Clone)]
75pub struct RecurrenceSeriesDraft {
76    pub title: String,
77    pub description: String,
78    pub project: String,
79    pub priority: String,
80    pub initial_status: String,
81    pub labels: Vec<String>,
82    pub schedule: RecurrenceSchedule,
83}
84
85#[derive(Debug, Clone, Default)]
86pub struct RecurrenceTemplateUpdate {
87    pub title: Option<String>,
88    pub description: Option<String>,
89    pub project: Option<String>,
90    pub priority: Option<String>,
91    pub initial_status: Option<String>,
92    pub labels: Option<Vec<String>>,
93    pub available_local_time: Option<Option<NaiveTime>>,
94    pub due_policy: Option<RecurrenceDuePolicy>,
95}
96
97#[derive(Debug, Clone)]
98pub struct RecurrenceCreateOutcome {
99    pub series: RecurrenceSeries,
100    pub series_ref: String,
101    pub occurrence: RecurrenceOccurrence,
102    pub task: Task,
103}
104
105#[derive(Debug, Clone)]
106pub struct RecurrenceTemplateUpdateOutcome {
107    pub series: RecurrenceSeries,
108    pub changed: bool,
109}
110
111#[derive(Debug, Clone)]
112pub struct RecurrenceReconcileOutcome {
113    pub series: RecurrenceSeries,
114    pub occurrence: Option<RecurrenceOccurrence>,
115    pub changed: bool,
116    pub lifecycle_blocked: bool,
117}
118
119#[derive(Debug, Clone)]
120pub struct RecurrenceResolveOutcome {
121    pub series: RecurrenceSeries,
122    pub resolved: RecurrenceOccurrence,
123    pub task: Task,
124    pub successor: Option<Task>,
125}
126
127#[derive(Debug, Clone)]
128pub struct RecurrenceStateOutcome {
129    pub series: RecurrenceSeries,
130    pub occurrence: Option<RecurrenceOccurrence>,
131}
132
133impl Database {
134    pub async fn create_recurrence_series(
135        &self,
136        workspace: &Workspace,
137        draft: RecurrenceSeriesDraft,
138    ) -> Result<RecurrenceCreateOutcome> {
139        self.create_recurrence_series_at(workspace, draft, utc_now()?)
140            .await
141    }
142
143    pub async fn create_recurrence_series_with_options(
144        &self,
145        workspace: &Workspace,
146        draft: RecurrenceSeriesDraft,
147        options: RecurrenceCreationOptions,
148    ) -> Result<RecurrenceCreateOutcome> {
149        self.create_recurrence_series_at_with_options(workspace, draft, utc_now()?, options)
150            .await
151    }
152
153    pub async fn create_recurrence_series_at(
154        &self,
155        workspace: &Workspace,
156        draft: RecurrenceSeriesDraft,
157        at: DateTime<Utc>,
158    ) -> Result<RecurrenceCreateOutcome> {
159        let mut conn = self.acquire().await?;
160        create_recurrence_series(&mut conn, workspace, draft, at).await
161    }
162
163    pub async fn create_recurrence_series_at_with_options(
164        &self,
165        workspace: &Workspace,
166        draft: RecurrenceSeriesDraft,
167        at: DateTime<Utc>,
168        options: RecurrenceCreationOptions,
169    ) -> Result<RecurrenceCreateOutcome> {
170        let mut conn = self.acquire().await?;
171        create_recurrence_series_with_options(&mut conn, workspace, draft, at, options).await
172    }
173
174    pub async fn update_recurrence_template(
175        &self,
176        workspace: &Workspace,
177        series_id: &RecurrenceSeriesId,
178        update: RecurrenceTemplateUpdate,
179    ) -> Result<RecurrenceTemplateUpdateOutcome> {
180        let mut conn = self.acquire().await?;
181        update_recurrence_template(&mut conn, workspace, series_id, update).await
182    }
183
184    pub async fn update_recurrence_template_with_options(
185        &self,
186        workspace: &Workspace,
187        series_id: &RecurrenceSeriesId,
188        update: RecurrenceTemplateUpdate,
189        options: RecurrenceTemplateUpdateOptions,
190    ) -> Result<RecurrenceTemplateUpdateOutcome> {
191        let mut conn = self.acquire().await?;
192        update_recurrence_template_with_options(&mut conn, workspace, series_id, update, options)
193            .await
194    }
195
196    pub async fn reconcile_recurrence_series(
197        &self,
198        workspace: &Workspace,
199        series_id: &RecurrenceSeriesId,
200        at: DateTime<Utc>,
201    ) -> Result<RecurrenceReconcileOutcome> {
202        let mut conn = self.acquire().await?;
203        let mut last_error = None;
204        for _ in 0..RECONCILE_ATTEMPTS {
205            match reconcile_recurrence_series_once(&mut conn, workspace, series_id, at).await {
206                Ok(outcome) => return Ok(outcome),
207                Err(error) if retryable_reconcile_error(&error) => last_error = Some(error),
208                Err(error) => return Err(error),
209            }
210        }
211        Err(last_error.expect("bounded reconciliation records retry errors"))
212    }
213
214    pub async fn resolve_recurrence_occurrence(
215        &self,
216        workspace: &Workspace,
217        task_id: &TaskId,
218        outcome: RecurrenceOutcome,
219    ) -> Result<RecurrenceResolveOutcome> {
220        self.resolve_recurrence_occurrence_with_undo(workspace, task_id, outcome, UndoContext::None)
221            .await
222    }
223
224    pub async fn resolve_recurrence_occurrence_with_undo(
225        &self,
226        workspace: &Workspace,
227        task_id: &TaskId,
228        outcome: RecurrenceOutcome,
229        undo: UndoContext,
230    ) -> Result<RecurrenceResolveOutcome> {
231        let mut conn = self.acquire().await?;
232        let mut tx = begin_immediate(&mut conn).await?;
233        let before = get_task_in_workspace(&mut tx, workspace, task_id).await?;
234        let result = resolve_recurrence_occurrence_in_transaction(
235            &mut tx,
236            workspace,
237            task_id,
238            outcome,
239            &now(),
240        )
241        .await?;
242        if before.status != result.task.status
243            && let UndoContext::Tui { summary } = undo
244        {
245            record_tui_undo(
246                &mut tx,
247                &workspace.id,
248                &summary,
249                UndoPayload {
250                    commands: vec![UndoCommand::SetTaskField {
251                        task_id: task_id.clone(),
252                        field: "status".to_string(),
253                        before: before.status.as_str().to_string(),
254                        after: result.task.status.as_str().to_string(),
255                    }],
256                },
257            )
258            .await?;
259        }
260        tx.commit().await?;
261        Ok(result)
262    }
263
264    pub async fn pause_recurrence_series(
265        &self,
266        workspace: &Workspace,
267        series_id: &RecurrenceSeriesId,
268    ) -> Result<RecurrenceStateOutcome> {
269        let mut conn = self.acquire().await?;
270        pause_recurrence_series(&mut conn, workspace, series_id, &now()).await
271    }
272
273    pub async fn resume_recurrence_series(
274        &self,
275        workspace: &Workspace,
276        series_id: &RecurrenceSeriesId,
277        at: DateTime<Utc>,
278    ) -> Result<RecurrenceStateOutcome> {
279        let mut conn = self.acquire().await?;
280        resume_recurrence_series(&mut conn, workspace, series_id, at).await
281    }
282
283    pub async fn stop_recurrence_series(
284        &self,
285        workspace: &Workspace,
286        series_id: &RecurrenceSeriesId,
287        skip_current: bool,
288    ) -> Result<RecurrenceStateOutcome> {
289        let mut conn = self.acquire().await?;
290        stop_recurrence_series(&mut conn, workspace, series_id, skip_current, &now()).await
291    }
292
293    pub async fn resolve_recurrence_ref(
294        &self,
295        workspace: &Workspace,
296        input: &str,
297    ) -> Result<RecurrenceSeries> {
298        let mut conn = self.acquire().await?;
299        resolve_recurrence_ref(&mut conn, workspace, input).await
300    }
301
302    pub async fn recurrence_series_ref(
303        &self,
304        workspace_id: &WorkspaceId,
305        series_id: &RecurrenceSeriesId,
306    ) -> Result<String> {
307        let mut conn = self.acquire().await?;
308        recurrence_series_ref(&mut conn, workspace_id, series_id).await
309    }
310}
311
312pub async fn create_recurrence_series(
313    conn: &mut SqliteConnection,
314    workspace: &Workspace,
315    draft: RecurrenceSeriesDraft,
316    at: DateTime<Utc>,
317) -> Result<RecurrenceCreateOutcome> {
318    create_recurrence_series_with_options(
319        conn,
320        workspace,
321        draft,
322        at,
323        RecurrenceCreationOptions::default(),
324    )
325    .await
326}
327
328pub async fn create_recurrence_series_with_options(
329    conn: &mut SqliteConnection,
330    workspace: &Workspace,
331    draft: RecurrenceSeriesDraft,
332    at: DateTime<Utc>,
333    options: RecurrenceCreationOptions,
334) -> Result<RecurrenceCreateOutcome> {
335    let priority = TaskPriority::parse(&draft.priority)?;
336    let initial_status = TaskStatus::parse(&draft.initial_status)?;
337    ensure!(
338        initial_status.is_open(),
339        "error recurrence-initial-status-terminal status={}",
340        initial_status.as_str()
341    );
342    let creation_date = at
343        .with_timezone(&draft.schedule.timezone.timezone())
344        .date_naive();
345    let first_slot = draft
346        .schedule
347        .slots_on_or_after(draft.schedule.start_on.max(creation_date))
348        .next()
349        .context("recurrence schedule has no representable first slot")?;
350    let series_id = RecurrenceSeriesId::new();
351    let created_at = format_utc(at);
352
353    let mut tx = begin_immediate(conn).await?;
354    let project =
355        resolve_or_create_project_in_workspace(&mut tx, &workspace.id, draft.project.as_str())
356            .await?;
357    let labels = if options.create_missing_labels {
358        resolve_or_create_labels_in_workspace(&mut tx, workspace, &draft.labels)
359            .await?
360            .names
361    } else {
362        resolve_labels_in_workspace(&mut tx, &workspace.id, &draft.labels).await?
363    };
364    sqlx::query(
365        "INSERT INTO recurrence_series(
366            workspace_id, id, title, description, project_id, priority, initial_status,
367            frequency, interval, weekdays, timezone, start_on, available_local_time,
368            due_policy, state, stopped_at, created_at, updated_at, deleted
369         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', '', ?, ?, 0)",
370    )
371    .bind(&workspace.id)
372    .bind(&series_id)
373    .bind(&draft.title)
374    .bind(&draft.description)
375    .bind(&project.id)
376    .bind(priority.as_str())
377    .bind(initial_status.as_str())
378    .bind(draft.schedule.rule.frequency().as_str())
379    .bind(i64::from(draft.schedule.rule.interval()))
380    .bind(draft.schedule.rule.weekdays_set().to_string())
381    .bind(draft.schedule.timezone.as_str())
382    .bind(draft.schedule.start_on.format("%Y-%m-%d").to_string())
383    .bind(format_local_time(draft.schedule.available_local_time))
384    .bind(draft.schedule.due_policy.as_str())
385    .bind(&created_at)
386    .bind(&created_at)
387    .execute(&mut *tx)
388    .await?;
389    for label in &labels {
390        sqlx::query(
391            "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
392             VALUES (?, ?, ?)",
393        )
394        .bind(&workspace.id)
395        .bind(&series_id)
396        .bind(label)
397        .execute(&mut *tx)
398        .await?;
399    }
400    let create_change_id = append_change(
401        &mut tx,
402        ChangeEntity::RecurrenceSeries,
403        series_id.as_str(),
404        None,
405        op_type::CREATE_RECURRENCE_SERIES,
406        ChangePayload::workspace(workspace)
407            .set("series_id", series_id.as_str())
408            .set("title", &draft.title)
409            .set("description", &draft.description)
410            .set("project_id", project.id.as_str())
411            .set("project_key", &project.key)
412            .set("project_name", &project.name)
413            .set("project_prefix", &project.prefix)
414            .set("priority", priority.as_str())
415            .set("initial_status", initial_status.as_str())
416            .set("frequency", draft.schedule.rule.frequency().as_str())
417            .set("interval", draft.schedule.rule.interval())
418            .set("weekdays", draft.schedule.rule.weekdays_set().to_string())
419            .set("timezone", draft.schedule.timezone.as_str())
420            .set(
421                "start_on",
422                draft.schedule.start_on.format("%Y-%m-%d").to_string(),
423            )
424            .set(
425                "available_local_time",
426                format_local_time(draft.schedule.available_local_time),
427            )
428            .set("due_policy", draft.schedule.due_policy.as_str())
429            .set("labels", &labels)
430            .set("state", "active")
431            .set("stopped_at", "")
432            .set("created_at", &created_at)
433            .set("updated_at", &created_at),
434    )
435    .await?;
436    for field in SERIES_TEMPLATE_FIELDS {
437        set_entity_field_version(
438            &mut tx,
439            &workspace.id,
440            MutableEntityType::RecurrenceSeries,
441            series_id.as_str(),
442            field,
443            &create_change_id,
444        )
445        .await?;
446    }
447    let series = load_series(&mut tx, &workspace.id, &series_id).await?;
448    let occurrence =
449        materialize_occurrence(&mut tx, workspace, &series, &labels, first_slot).await?;
450    let task_id = occurrence
451        .task_id
452        .as_ref()
453        .expect("materialized occurrence has a task")
454        .clone();
455    let task = get_task_in_workspace(&mut tx, workspace, &task_id).await?;
456    let series_ref = recurrence_series_ref(&mut tx, &workspace.id, &series_id).await?;
457    tx.commit().await?;
458    Ok(RecurrenceCreateOutcome {
459        series,
460        series_ref,
461        occurrence,
462        task,
463    })
464}
465
466pub async fn update_recurrence_template(
467    conn: &mut SqliteConnection,
468    workspace: &Workspace,
469    series_id: &RecurrenceSeriesId,
470    update: RecurrenceTemplateUpdate,
471) -> Result<RecurrenceTemplateUpdateOutcome> {
472    update_recurrence_template_with_options(
473        conn,
474        workspace,
475        series_id,
476        update,
477        RecurrenceTemplateUpdateOptions::default(),
478    )
479    .await
480}
481
482pub async fn update_recurrence_template_with_options(
483    conn: &mut SqliteConnection,
484    workspace: &Workspace,
485    series_id: &RecurrenceSeriesId,
486    update: RecurrenceTemplateUpdate,
487    options: RecurrenceTemplateUpdateOptions,
488) -> Result<RecurrenceTemplateUpdateOutcome> {
489    if let Some(priority) = update.priority.as_deref() {
490        TaskPriority::parse(priority)?;
491    }
492    if let Some(status) = update.initial_status.as_deref() {
493        let status = TaskStatus::parse(status)?;
494        ensure!(
495            status.is_open(),
496            "error recurrence-initial-status-terminal status={}",
497            status.as_str()
498        );
499    }
500
501    let mut tx = begin_immediate(conn).await?;
502    let current = load_series(&mut tx, &workspace.id, series_id).await?;
503    let mut values = Vec::<(&str, String)>::new();
504    if let Some(title) = update.title
505        && title != current.title
506    {
507        values.push(("title", title));
508    }
509    if let Some(description) = update.description
510        && description != current.description
511    {
512        values.push(("description", description));
513    }
514    if let Some(priority) = update.priority
515        && priority != current.priority.as_str()
516    {
517        values.push(("priority", priority));
518    }
519    if let Some(status) = update.initial_status
520        && status != current.initial_status.as_str()
521    {
522        values.push(("initial_status", status));
523    }
524    if let Some(time) = update.available_local_time {
525        let value = format_local_time(time);
526        if value != format_local_time(current.available_local_time) {
527            values.push(("available_local_time", value));
528        }
529    }
530    if let Some(due_policy) = update.due_policy
531        && due_policy != current.due_policy
532    {
533        values.push(("due_policy", due_policy.as_str().to_string()));
534    }
535    if let Some(project) = update.project {
536        let project =
537            resolve_or_create_project_in_workspace(&mut tx, &workspace.id, &project).await?;
538        if project.id != current.project_id {
539            values.push(("project", project.id.to_string()));
540        }
541    }
542
543    let current_labels = load_series_labels(&mut tx, &workspace.id, series_id).await?;
544    let target_labels = if let Some(labels) = update.labels {
545        if options.create_missing_labels {
546            resolve_or_create_labels_in_workspace(&mut tx, workspace, &labels)
547                .await?
548                .names
549        } else {
550            resolve_labels_in_workspace(&mut tx, &workspace.id, &labels).await?
551        }
552    } else {
553        current_labels.clone()
554    };
555    let labels_changed = current_labels != target_labels;
556    if values.is_empty() && !labels_changed {
557        tx.commit().await?;
558        return Ok(RecurrenceTemplateUpdateOutcome {
559            series: current,
560            changed: false,
561        });
562    }
563
564    for (field, _) in &values {
565        ensure_no_series_conflict(&mut tx, &workspace.id, series_id, field).await?;
566    }
567    if labels_changed {
568        ensure_no_series_conflict(&mut tx, &workspace.id, series_id, "labels").await?;
569    }
570    let mut base_versions = BTreeMap::new();
571    for (field, _) in &values {
572        base_versions.insert(
573            (*field).to_string(),
574            entity_field_version(
575                &mut tx,
576                &workspace.id,
577                MutableEntityType::RecurrenceSeries,
578                series_id.as_str(),
579                field,
580            )
581            .await?,
582        );
583    }
584    if labels_changed {
585        base_versions.insert(
586            "labels".to_string(),
587            entity_field_version(
588                &mut tx,
589                &workspace.id,
590                MutableEntityType::RecurrenceSeries,
591                series_id.as_str(),
592                "labels",
593            )
594            .await?,
595        );
596    }
597    let updated_at = now();
598    for (field, value) in &values {
599        update_series_template_scalar(&mut tx, &workspace.id, series_id, field, value, &updated_at)
600            .await?;
601    }
602    if labels_changed {
603        sqlx::query(
604            "DELETE FROM recurrence_series_labels WHERE workspace_id = ? AND series_id = ?",
605        )
606        .bind(&workspace.id)
607        .bind(series_id)
608        .execute(&mut *tx)
609        .await?;
610        for label in &target_labels {
611            sqlx::query(
612                "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
613                 VALUES (?, ?, ?)",
614            )
615            .bind(&workspace.id)
616            .bind(series_id)
617            .bind(label)
618            .execute(&mut *tx)
619            .await?;
620        }
621    }
622    let change_id = append_change(
623        &mut tx,
624        ChangeEntity::RecurrenceSeries,
625        series_id.as_str(),
626        None,
627        op_type::UPDATE_RECURRENCE_TEMPLATE,
628        ChangePayload::workspace(workspace)
629            .set("fields", &values)
630            .set("base_versions", &base_versions)
631            .set("labels_changed", labels_changed)
632            .set("labels", &target_labels)
633            .set("updated_at", &updated_at),
634    )
635    .await?;
636    for (field, _) in &values {
637        set_entity_field_version(
638            &mut tx,
639            &workspace.id,
640            MutableEntityType::RecurrenceSeries,
641            series_id.as_str(),
642            field,
643            &change_id,
644        )
645        .await?;
646    }
647    if labels_changed {
648        set_entity_field_version(
649            &mut tx,
650            &workspace.id,
651            MutableEntityType::RecurrenceSeries,
652            series_id.as_str(),
653            "labels",
654            &change_id,
655        )
656        .await?;
657    }
658    let series = load_series(&mut tx, &workspace.id, series_id).await?;
659    tx.commit().await?;
660    Ok(RecurrenceTemplateUpdateOutcome {
661        series,
662        changed: true,
663    })
664}
665
666async fn reconcile_recurrence_series_once(
667    conn: &mut SqliteConnection,
668    workspace: &Workspace,
669    series_id: &RecurrenceSeriesId,
670    at: DateTime<Utc>,
671) -> Result<RecurrenceReconcileOutcome> {
672    let mut tx = begin_immediate(conn).await?;
673    let outcome =
674        reconcile_recurrence_series_in_transaction(&mut tx, workspace, series_id, at).await?;
675    tx.commit().await?;
676    Ok(outcome)
677}
678
679pub(crate) async fn reconcile_recurrence_series_in_transaction(
680    conn: &mut SqliteConnection,
681    workspace: &Workspace,
682    series_id: &RecurrenceSeriesId,
683    at: DateTime<Utc>,
684) -> Result<RecurrenceReconcileOutcome> {
685    let series = load_series(conn, &workspace.id, series_id).await?;
686    let projected = load_projected_occurrence(conn, &workspace.id, series_id).await?;
687    if !matches!(series.state, RecurrenceSeriesState::Active) {
688        return Ok(RecurrenceReconcileOutcome {
689            series,
690            occurrence: projected,
691            changed: false,
692            lifecycle_blocked: false,
693        });
694    }
695    if lifecycle_conflict_exists(conn, &workspace.id, series_id).await? {
696        return Ok(RecurrenceReconcileOutcome {
697            series,
698            occurrence: projected,
699            changed: false,
700            lifecycle_blocked: true,
701        });
702    }
703
704    let schedule = series.schedule();
705    let target = projection_slot_at(&schedule, at)?;
706    if projected
707        .as_ref()
708        .is_some_and(|occurrence| occurrence.slot_on >= target)
709    {
710        return Ok(RecurrenceReconcileOutcome {
711            series,
712            occurrence: projected,
713            changed: false,
714            lifecycle_blocked: false,
715        });
716    }
717
718    let changed_at = format_utc(at);
719    if let Some(projected) = projected {
720        sqlx::query(
721            "UPDATE recurrence_occurrences
722             SET projection_state = 'archived', archived_at = ?
723             WHERE workspace_id = ? AND series_id = ? AND slot_on = ?
724             AND projection_state = 'projected'",
725        )
726        .bind(&changed_at)
727        .bind(&workspace.id)
728        .bind(series_id)
729        .bind(projected.slot_on.format("%Y-%m-%d").to_string())
730        .execute(&mut *conn)
731        .await?;
732    }
733    if let Some(existing) = load_occurrence(conn, &workspace.id, series_id, target).await?
734        && matches!(
735            existing.projection_state,
736            RecurrenceProjectionState::Archived
737        )
738        && existing.outcome.is_none()
739    {
740        let expected = derive_occurrence_identity(&workspace.id, series_id, &schedule, target)?;
741        ensure!(
742            existing.task_id.as_ref() == Some(&expected.task_id),
743            "error recurrence-generation-conflict slot={target} field=task_id"
744        );
745        let task = get_task_in_workspace(conn, workspace, &expected.task_id).await?;
746        ensure!(
747            task.status.is_open() && !task.deleted,
748            "error recurrence-generation-conflict slot={target} field=task"
749        );
750        sqlx::query(
751            "UPDATE recurrence_occurrences
752             SET projection_state = 'projected', archived_at = ''
753             WHERE workspace_id = ? AND series_id = ? AND slot_on = ?
754             AND projection_state = 'archived' AND outcome = ''",
755        )
756        .bind(&workspace.id)
757        .bind(series_id)
758        .bind(target.format("%Y-%m-%d").to_string())
759        .execute(&mut *conn)
760        .await?;
761        let occurrence = load_occurrence(conn, &workspace.id, series_id, target)
762            .await?
763            .context("promoted recurrence occurrence missing")?;
764        return Ok(RecurrenceReconcileOutcome {
765            series,
766            occurrence: Some(occurrence),
767            changed: true,
768            lifecycle_blocked: false,
769        });
770    }
771    let labels = load_series_labels(conn, &workspace.id, series_id).await?;
772    let occurrence = materialize_occurrence(conn, workspace, &series, &labels, target).await?;
773    Ok(RecurrenceReconcileOutcome {
774        series,
775        occurrence: Some(occurrence),
776        changed: true,
777        lifecycle_blocked: false,
778    })
779}
780
781pub(crate) async fn resolve_recurrence_occurrence_in_transaction(
782    conn: &mut SqliteConnection,
783    workspace: &Workspace,
784    task_id: &TaskId,
785    outcome: RecurrenceOutcome,
786    resolved_at: &str,
787) -> Result<RecurrenceResolveOutcome> {
788    let mut occurrence = load_occurrence_for_task(conn, &workspace.id, task_id)
789        .await?
790        .context("error recurrence-occurrence-not-found")?;
791    if matches!(
792        occurrence.projection_state,
793        RecurrenceProjectionState::Projected
794    ) {
795        reconcile_recurrence_series_in_transaction(
796            conn,
797            workspace,
798            &occurrence.series_id,
799            DateTime::parse_from_rfc3339(resolved_at)
800                .context("invalid recurrence resolution time")?
801                .with_timezone(&Utc),
802        )
803        .await?;
804        occurrence = load_occurrence_for_task(conn, &workspace.id, task_id)
805            .await?
806            .context("error recurrence-occurrence-not-found")?;
807    }
808    ensure!(
809        matches!(
810            occurrence.projection_state,
811            RecurrenceProjectionState::Projected
812        ),
813        "error recurrence-occurrence-not-current task_id={task_id}"
814    );
815    let task = get_task_in_workspace(conn, workspace, task_id).await?;
816    ensure!(
817        task.status.is_open(),
818        "error recurrence-occurrence-terminal task_id={task_id}"
819    );
820    let series = load_series(conn, &workspace.id, &occurrence.series_id).await?;
821    let target_status = match outcome {
822        RecurrenceOutcome::Completed => TaskStatus::Done,
823        RecurrenceOutcome::Skipped => TaskStatus::Canceled,
824    };
825    let status_change_id = write_task_status(
826        conn,
827        workspace,
828        task_id,
829        task.status,
830        target_status,
831        resolved_at,
832    )
833    .await?;
834
835    let successor_slot = if matches!(series.state, RecurrenceSeriesState::Active) {
836        Some(
837            next_slot_after(&series.rule, series.start_on, occurrence.slot_on)
838                .context("recurrence schedule has no representable successor")?,
839        )
840    } else {
841        None
842    };
843    let successor_task_id = successor_slot
844        .map(|slot| {
845            derive_occurrence_identity(&workspace.id, &series.id, &series.schedule(), slot)
846                .map(|identity| identity.task_id)
847        })
848        .transpose()?;
849    let outcome_change_id = append_change(
850        conn,
851        ChangeEntity::RecurrenceSeries,
852        series.id.as_str(),
853        Some("outcome"),
854        op_type::RESOLVE_RECURRENCE_OCCURRENCE,
855        ChangePayload::workspace(workspace)
856            .set("slot_on", occurrence.slot_on.format("%Y-%m-%d").to_string())
857            .set("task_id", task_id.as_str())
858            .set("outcome", outcome.as_str())
859            .set("task_status", target_status.as_str())
860            .set("resolved_at", resolved_at)
861            .set("task_status_change_id", &status_change_id)
862            .set(
863                "successor_task_id",
864                successor_task_id.as_ref().map(TaskId::as_str).unwrap_or(""),
865            )
866            .set("frequency", series.rule.frequency().as_str())
867            .set("interval", series.rule.interval())
868            .set("weekdays", series.rule.weekdays_set().to_string())
869            .set("timezone", series.timezone.as_str())
870            .set("start_on", series.start_on.format("%Y-%m-%d").to_string())
871            .set(
872                "available_local_time",
873                format_local_time(series.available_local_time),
874            )
875            .set("due_policy", series.due_policy.as_str()),
876    )
877    .await?;
878    sqlx::query(
879        "UPDATE recurrence_occurrences
880         SET outcome = ?, resolved_at = ?, outcome_change_id = ?, projection_state = 'resolved'
881         WHERE workspace_id = ? AND series_id = ? AND slot_on = ?
882         AND projection_state = 'projected'",
883    )
884    .bind(outcome.as_str())
885    .bind(resolved_at)
886    .bind(&outcome_change_id)
887    .bind(&workspace.id)
888    .bind(&series.id)
889    .bind(occurrence.slot_on.format("%Y-%m-%d").to_string())
890    .execute(&mut *conn)
891    .await?;
892
893    let labels = load_series_labels(conn, &workspace.id, &series.id).await?;
894    let successor = if let Some(next_slot) = successor_slot {
895        let successor_occurrence =
896            materialize_occurrence(conn, workspace, &series, &labels, next_slot).await?;
897        let successor_id = successor_occurrence
898            .task_id
899            .as_ref()
900            .expect("materialized successor has a task");
901        Some(get_task_in_workspace(conn, workspace, successor_id).await?)
902    } else {
903        None
904    };
905    let resolved = load_occurrence(conn, &workspace.id, &series.id, occurrence.slot_on)
906        .await?
907        .expect("resolved occurrence remains stored");
908    let task = get_task_in_workspace(conn, workspace, task_id).await?;
909    Ok(RecurrenceResolveOutcome {
910        series,
911        resolved,
912        task,
913        successor,
914    })
915}
916
917pub async fn pause_recurrence_series(
918    conn: &mut SqliteConnection,
919    workspace: &Workspace,
920    series_id: &RecurrenceSeriesId,
921    paused_at: &str,
922) -> Result<RecurrenceStateOutcome> {
923    let paused_at_utc = DateTime::parse_from_rfc3339(paused_at)
924        .context("invalid recurrence pause time")?
925        .with_timezone(&Utc);
926    let mut tx = begin_immediate(conn).await?;
927    let series = load_series(&mut tx, &workspace.id, series_id).await?;
928    ensure!(
929        matches!(series.state, RecurrenceSeriesState::Active),
930        "error recurrence-pause-invalid-state state={}",
931        series.state.as_str()
932    );
933    ensure_no_series_conflict(&mut tx, &workspace.id, series_id, "state").await?;
934    reconcile_recurrence_series_in_transaction(&mut tx, workspace, series_id, paused_at_utc)
935        .await?;
936    let projected = load_projected_occurrence(&mut tx, &workspace.id, series_id).await?;
937    let change_id = set_series_state(
938        &mut tx,
939        workspace,
940        series_id,
941        RecurrenceSeriesState::Paused,
942        None,
943        paused_at,
944        op_type::SET_RECURRENCE_STATE,
945    )
946    .await?;
947    let interval_id = new_id();
948    sqlx::query(
949        "INSERT INTO recurrence_pause_intervals(
950            workspace_id, id, series_id, paused_at, resumed_at, suspended_slot_on,
951            suspended_task_id, created_by_change_id, resolved_by_change_id
952         ) VALUES (?, ?, ?, ?, '', ?, ?, ?, '')",
953    )
954    .bind(&workspace.id)
955    .bind(&interval_id)
956    .bind(series_id)
957    .bind(paused_at)
958    .bind(
959        projected
960            .as_ref()
961            .map(|occurrence| occurrence.slot_on.format("%Y-%m-%d").to_string())
962            .unwrap_or_default(),
963    )
964    .bind(
965        projected
966            .as_ref()
967            .and_then(|occurrence| occurrence.task_id.as_ref())
968            .map(TaskId::as_str)
969            .unwrap_or(""),
970    )
971    .bind(&change_id)
972    .execute(&mut *tx)
973    .await?;
974    append_change(
975        &mut tx,
976        ChangeEntity::RecurrenceSeries,
977        series_id.as_str(),
978        Some("pause"),
979        op_type::OPEN_RECURRENCE_PAUSE,
980        ChangePayload::workspace(workspace)
981            .set("interval_id", &interval_id)
982            .set("paused_at", paused_at)
983            .set(
984                "suspended_slot_on",
985                projected
986                    .as_ref()
987                    .map(|occurrence| occurrence.slot_on.format("%Y-%m-%d").to_string())
988                    .unwrap_or_default(),
989            )
990            .set(
991                "suspended_task_id",
992                projected
993                    .as_ref()
994                    .and_then(|occurrence| occurrence.task_id.as_ref())
995                    .map(TaskId::as_str)
996                    .unwrap_or(""),
997            ),
998    )
999    .await?;
1000    let series = load_series(&mut tx, &workspace.id, series_id).await?;
1001    tx.commit().await?;
1002    Ok(RecurrenceStateOutcome {
1003        series,
1004        occurrence: projected,
1005    })
1006}
1007
1008pub async fn resume_recurrence_series(
1009    conn: &mut SqliteConnection,
1010    workspace: &Workspace,
1011    series_id: &RecurrenceSeriesId,
1012    at: DateTime<Utc>,
1013) -> Result<RecurrenceStateOutcome> {
1014    let mut resumed_at = format_utc(at);
1015    let mut tx = begin_immediate(conn).await?;
1016    let series = load_series(&mut tx, &workspace.id, series_id).await?;
1017    ensure!(
1018        matches!(series.state, RecurrenceSeriesState::Paused),
1019        "error recurrence-resume-invalid-state state={}",
1020        series.state.as_str()
1021    );
1022    ensure_no_series_conflict(&mut tx, &workspace.id, series_id, "state").await?;
1023    let interval = sqlx::query(
1024        "SELECT id, paused_at, suspended_slot_on, suspended_task_id
1025         FROM recurrence_pause_intervals
1026         WHERE workspace_id = ? AND series_id = ? AND resumed_at = ''",
1027    )
1028    .bind(&workspace.id)
1029    .bind(series_id)
1030    .fetch_optional(&mut *tx)
1031    .await?
1032    .context("error recurrence-open-pause-missing")?;
1033    let interval_id: String = interval.get("id");
1034    let paused_at: String = interval.get("paused_at");
1035    resumed_at = timestamp_strictly_after(&paused_at, &resumed_at)?;
1036    let suspended_slot_text: String = interval.get("suspended_slot_on");
1037    let suspended_slot = (!suspended_slot_text.is_empty())
1038        .then(|| suspended_slot_text.parse::<NaiveDate>())
1039        .transpose()?;
1040
1041    let close_change_id = append_change(
1042        &mut tx,
1043        ChangeEntity::RecurrenceSeries,
1044        series_id.as_str(),
1045        Some("pause"),
1046        op_type::CLOSE_RECURRENCE_PAUSE,
1047        ChangePayload::workspace(workspace)
1048            .set("interval_id", &interval_id)
1049            .set("paused_at", &paused_at)
1050            .set("resumed_at", &resumed_at),
1051    )
1052    .await?;
1053    sqlx::query(
1054        "UPDATE recurrence_pause_intervals SET resumed_at = ?, resolved_by_change_id = ?
1055         WHERE workspace_id = ? AND id = ? AND resumed_at = ''",
1056    )
1057    .bind(&resumed_at)
1058    .bind(&close_change_id)
1059    .bind(&workspace.id)
1060    .bind(&interval_id)
1061    .execute(&mut *tx)
1062    .await?;
1063    set_series_state(
1064        &mut tx,
1065        workspace,
1066        series_id,
1067        RecurrenceSeriesState::Active,
1068        None,
1069        &resumed_at,
1070        op_type::SET_RECURRENCE_STATE,
1071    )
1072    .await?;
1073
1074    let schedule = series.schedule();
1075    let mut occurrence = load_projected_occurrence(&mut tx, &workspace.id, series_id).await?;
1076    let suspended_still_live = if let Some(suspended_slot) = suspended_slot {
1077        occurrence
1078            .as_ref()
1079            .is_some_and(|value| value.slot_on == suspended_slot)
1080            && at < slot_cutoff(&schedule, suspended_slot)?
1081    } else {
1082        false
1083    };
1084    if occurrence.is_some() && !suspended_still_live {
1085        let slot = occurrence.as_ref().expect("checked occurrence").slot_on;
1086        sqlx::query(
1087            "UPDATE recurrence_occurrences
1088             SET projection_state = 'archived', archived_at = ?
1089             WHERE workspace_id = ? AND series_id = ? AND slot_on = ?
1090             AND projection_state = 'projected'",
1091        )
1092        .bind(&resumed_at)
1093        .bind(&workspace.id)
1094        .bind(series_id)
1095        .bind(slot.format("%Y-%m-%d").to_string())
1096        .execute(&mut *tx)
1097        .await?;
1098        occurrence = None;
1099    }
1100    if occurrence.is_none() {
1101        let mut target = projection_slot_at(&schedule, at)?;
1102        let boundary = slot_values(&schedule, target)?.boundary_at;
1103        if boundary >= paused_at && boundary < resumed_at {
1104            target = next_slot_after(&series.rule, series.start_on, target)
1105                .context("recurrence schedule has no representable resumed slot")?;
1106        }
1107        let labels = load_series_labels(&mut tx, &workspace.id, series_id).await?;
1108        occurrence =
1109            Some(materialize_occurrence(&mut tx, workspace, &series, &labels, target).await?);
1110    }
1111    let series = load_series(&mut tx, &workspace.id, series_id).await?;
1112    tx.commit().await?;
1113    Ok(RecurrenceStateOutcome { series, occurrence })
1114}
1115
1116pub async fn stop_recurrence_series(
1117    conn: &mut SqliteConnection,
1118    workspace: &Workspace,
1119    series_id: &RecurrenceSeriesId,
1120    skip_current: bool,
1121    stopped_at: &str,
1122) -> Result<RecurrenceStateOutcome> {
1123    let mut stopped_at = stopped_at.to_string();
1124    let mut tx = begin_immediate(conn).await?;
1125    let series = load_series(&mut tx, &workspace.id, series_id).await?;
1126    ensure!(
1127        !matches!(series.state, RecurrenceSeriesState::Stopped),
1128        "error recurrence-already-stopped"
1129    );
1130    ensure_no_series_conflict(&mut tx, &workspace.id, series_id, "state").await?;
1131    let open_pause = if matches!(series.state, RecurrenceSeriesState::Paused) {
1132        let interval = sqlx::query(
1133            "SELECT id, paused_at FROM recurrence_pause_intervals
1134             WHERE workspace_id = ? AND series_id = ? AND resumed_at = ''",
1135        )
1136        .bind(&workspace.id)
1137        .bind(series_id)
1138        .fetch_optional(&mut *tx)
1139        .await?
1140        .context("error recurrence-open-pause-missing")?;
1141        let interval_id: String = interval.get("id");
1142        let paused_at: String = interval.get("paused_at");
1143        stopped_at = timestamp_strictly_after(&paused_at, &stopped_at)?;
1144        Some((interval_id, paused_at))
1145    } else {
1146        None
1147    };
1148    let stopped_at_utc = DateTime::parse_from_rfc3339(&stopped_at)
1149        .context("invalid recurrence stop time")?
1150        .with_timezone(&Utc);
1151    reconcile_recurrence_series_in_transaction(&mut tx, workspace, series_id, stopped_at_utc)
1152        .await?;
1153    if let Some((interval_id, paused_at)) = open_pause {
1154        let close_change_id = append_change(
1155            &mut tx,
1156            ChangeEntity::RecurrenceSeries,
1157            series_id.as_str(),
1158            Some("pause"),
1159            op_type::CLOSE_RECURRENCE_PAUSE,
1160            ChangePayload::workspace(workspace)
1161                .set("interval_id", &interval_id)
1162                .set("paused_at", &paused_at)
1163                .set("resumed_at", &stopped_at),
1164        )
1165        .await?;
1166        sqlx::query(
1167            "UPDATE recurrence_pause_intervals SET resumed_at = ?, resolved_by_change_id = ?
1168             WHERE workspace_id = ? AND id = ? AND resumed_at = ''",
1169        )
1170        .bind(&stopped_at)
1171        .bind(&close_change_id)
1172        .bind(&workspace.id)
1173        .bind(&interval_id)
1174        .execute(&mut *tx)
1175        .await?;
1176    }
1177    set_series_state(
1178        &mut tx,
1179        workspace,
1180        series_id,
1181        RecurrenceSeriesState::Stopped,
1182        Some(&stopped_at),
1183        &stopped_at,
1184        op_type::STOP_RECURRENCE_SERIES,
1185    )
1186    .await?;
1187    let projected = load_projected_occurrence(&mut tx, &workspace.id, series_id).await?;
1188    let occurrence = if skip_current {
1189        let projected = projected.context("error recurrence-current-occurrence-missing")?;
1190        let task_id = projected
1191            .task_id
1192            .as_ref()
1193            .expect("projected occurrence has a task");
1194        Some(
1195            resolve_recurrence_occurrence_in_transaction(
1196                &mut tx,
1197                workspace,
1198                task_id,
1199                RecurrenceOutcome::Skipped,
1200                &stopped_at,
1201            )
1202            .await?
1203            .resolved,
1204        )
1205    } else {
1206        projected
1207    };
1208    let series = load_series(&mut tx, &workspace.id, series_id).await?;
1209    tx.commit().await?;
1210    Ok(RecurrenceStateOutcome { series, occurrence })
1211}
1212
1213pub(crate) async fn route_recurrence_task_field(
1214    conn: &mut SqliteConnection,
1215    workspace: &Workspace,
1216    task_id: &TaskId,
1217    field: &str,
1218    value: &str,
1219) -> Result<Option<bool>> {
1220    let Some(mut occurrence) = load_occurrence_for_task(conn, &workspace.id, task_id).await? else {
1221        return Ok(None);
1222    };
1223    if matches!(
1224        occurrence.projection_state,
1225        RecurrenceProjectionState::Projected
1226    ) {
1227        reconcile_recurrence_series_in_transaction(
1228            conn,
1229            workspace,
1230            &occurrence.series_id,
1231            Utc::now(),
1232        )
1233        .await?;
1234        occurrence = load_occurrence_for_task(conn, &workspace.id, task_id)
1235            .await?
1236            .context("error recurrence-occurrence-not-found")?;
1237    }
1238    if matches!(
1239        occurrence.projection_state,
1240        RecurrenceProjectionState::Archived
1241    ) {
1242        bail!("error recurrence-occurrence-archived task_id={task_id}");
1243    }
1244    if field == "status" {
1245        let task = get_task_in_workspace(conn, workspace, task_id).await?;
1246        let target = TaskStatus::parse(value)?;
1247        if task.status == target {
1248            return Ok(Some(false));
1249        }
1250        if task.status.is_terminal() {
1251            if target.is_open() {
1252                bail!(
1253                    "error recurrence-terminal-reopen task_id={task_id} hint=\"use immediate undo\""
1254                );
1255            }
1256            bail!("error recurrence-outcome-final task_id={task_id} hint=\"use immediate undo\"");
1257        }
1258        if target.is_terminal() {
1259            ensure!(
1260                matches!(
1261                    occurrence.projection_state,
1262                    RecurrenceProjectionState::Projected
1263                ),
1264                "error recurrence-occurrence-not-current task_id={task_id}"
1265            );
1266            let outcome = if target == TaskStatus::Done {
1267                RecurrenceOutcome::Completed
1268            } else {
1269                RecurrenceOutcome::Skipped
1270            };
1271            resolve_recurrence_occurrence_in_transaction(conn, workspace, task_id, outcome, &now())
1272                .await?;
1273            return Ok(Some(true));
1274        }
1275        return Ok(None);
1276    }
1277    if field == "deleted"
1278        && matches!(
1279            occurrence.projection_state,
1280            RecurrenceProjectionState::Projected
1281        )
1282        && value == "1"
1283    {
1284        let series = load_series(conn, &workspace.id, &occurrence.series_id).await?;
1285        let guidance = match series.state {
1286            RecurrenceSeriesState::Active => "skip, pause, or stop the series",
1287            RecurrenceSeriesState::Paused => "skip the occurrence or stop the series",
1288            RecurrenceSeriesState::Stopped => "complete or cancel the final occurrence",
1289        };
1290        bail!("error recurrence-current-delete task_id={task_id} hint=\"{guidance}\"");
1291    }
1292    Ok(None)
1293}
1294
1295pub(crate) async fn undo_recurrence_resolution(
1296    conn: &mut SqliteConnection,
1297    workspace_id: &WorkspaceId,
1298    task_id: &TaskId,
1299    before_status: &str,
1300    after_status: &str,
1301) -> Result<bool> {
1302    let Some(occurrence) = load_occurrence_for_task(conn, workspace_id, task_id).await? else {
1303        return Ok(false);
1304    };
1305    if !matches!(
1306        occurrence.projection_state,
1307        RecurrenceProjectionState::Resolved
1308    ) || occurrence.outcome.is_none()
1309    {
1310        return Ok(false);
1311    }
1312    let task_status: String =
1313        sqlx::query_scalar("SELECT status FROM tasks WHERE workspace_id = ? AND id = ?")
1314            .bind(workspace_id)
1315            .bind(task_id)
1316            .fetch_one(&mut *conn)
1317            .await?;
1318    ensure!(
1319        task_status == after_status,
1320        "error undo-state-changed task_id={task_id} field=status"
1321    );
1322    let series = load_series(conn, workspace_id, &occurrence.series_id).await?;
1323    let outcome_change_id = occurrence
1324        .outcome_change_id
1325        .as_deref()
1326        .context("error recurrence-undo-missing-outcome-change")?;
1327    ensure_change_is_latest_series_transition(conn, workspace_id, &series.id, outcome_change_id)
1328        .await?;
1329    ensure_change_unsynced(conn, outcome_change_id).await?;
1330    let status_change_id = entity_field_version(
1331        conn,
1332        workspace_id,
1333        MutableEntityType::Task,
1334        task_id.as_str(),
1335        "status",
1336    )
1337    .await?
1338    .context("error recurrence-undo-missing-status-change")?;
1339    ensure_change_unsynced(conn, &status_change_id).await?;
1340    let prior_status_version: Option<String> =
1341        sqlx::query_scalar("SELECT base_version FROM changes WHERE change_id = ?")
1342            .bind(&status_change_id)
1343            .fetch_one(&mut *conn)
1344            .await?;
1345
1346    let successor = load_projected_occurrence(conn, workspace_id, &series.id).await?;
1347    match series.state {
1348        RecurrenceSeriesState::Active => {
1349            let successor = successor.context("error recurrence-undo-successor-missing")?;
1350            let expected_slot = next_slot_after(&series.rule, series.start_on, occurrence.slot_on)
1351                .context("recurrence schedule has no representable successor")?;
1352            ensure!(
1353                successor.slot_on == expected_slot,
1354                "error recurrence-undo-successor-changed"
1355            );
1356            ensure_successor_untouched(conn, workspace_id, &series, &successor).await?;
1357            remove_materialized_occurrence(conn, workspace_id, &series, &successor).await?;
1358        }
1359        RecurrenceSeriesState::Paused | RecurrenceSeriesState::Stopped => {
1360            ensure!(
1361                successor.is_none(),
1362                "error recurrence-undo-successor-exists"
1363            );
1364        }
1365    }
1366
1367    apply_field_value_in_workspace(conn, workspace_id, task_id, "status", before_status).await?;
1368    if let Some(version) = prior_status_version {
1369        set_entity_field_version(
1370            conn,
1371            workspace_id,
1372            MutableEntityType::Task,
1373            task_id.as_str(),
1374            "status",
1375            &version,
1376        )
1377        .await?;
1378    } else {
1379        sqlx::query(
1380            "DELETE FROM field_versions
1381             WHERE workspace_id = ? AND entity_type = 'task' AND entity_id = ? AND field = 'status'",
1382        )
1383        .bind(workspace_id)
1384        .bind(task_id)
1385        .execute(&mut *conn)
1386        .await?;
1387    }
1388    sqlx::query(
1389        "UPDATE recurrence_occurrences
1390         SET outcome = '', resolved_at = '', outcome_change_id = '', projection_state = 'projected'
1391         WHERE workspace_id = ? AND series_id = ? AND slot_on = ?",
1392    )
1393    .bind(workspace_id)
1394    .bind(&series.id)
1395    .bind(occurrence.slot_on.format("%Y-%m-%d").to_string())
1396    .execute(&mut *conn)
1397    .await?;
1398    sqlx::query("DELETE FROM changes WHERE change_id IN (?, ?)")
1399        .bind(&status_change_id)
1400        .bind(outcome_change_id)
1401        .execute(&mut *conn)
1402        .await?;
1403    Ok(true)
1404}
1405
1406async fn materialize_occurrence(
1407    conn: &mut SqliteConnection,
1408    workspace: &Workspace,
1409    series: &RecurrenceSeries,
1410    labels: &[String],
1411    slot_on: NaiveDate,
1412) -> Result<RecurrenceOccurrence> {
1413    let schedule = series.schedule();
1414    let slot = slot_values(&schedule, slot_on)?;
1415    let identity = derive_occurrence_identity(&workspace.id, &series.id, &schedule, slot_on)?;
1416    if let Some(existing) = load_occurrence(conn, &workspace.id, &series.id, slot_on).await? {
1417        verify_materialized_occurrence(
1418            conn, workspace, series, labels, &slot, &identity, &existing,
1419        )
1420        .await?;
1421        return Ok(existing);
1422    }
1423
1424    sqlx::query(
1425        "INSERT INTO tasks(
1426            workspace_id, id, title, description, project_id, status, priority,
1427            created_at, updated_at, queue_activity_at, available_at, due_on, deleted, is_epic
1428         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)",
1429    )
1430    .bind(&workspace.id)
1431    .bind(&identity.task_id)
1432    .bind(&series.title)
1433    .bind(&series.description)
1434    .bind(&series.project_id)
1435    .bind(series.initial_status.as_str())
1436    .bind(series.priority.as_str())
1437    .bind(&identity.created_at)
1438    .bind(&identity.updated_at)
1439    .bind(&identity.created_at)
1440    .bind(&slot.available_at)
1441    .bind(slot.due_on.as_deref().unwrap_or(""))
1442    .execute(&mut *conn)
1443    .await?;
1444    for label in labels {
1445        sqlx::query("INSERT INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)")
1446            .bind(&workspace.id)
1447            .bind(&identity.task_id)
1448            .bind(label)
1449            .execute(&mut *conn)
1450            .await?;
1451    }
1452    sqlx::query(
1453        "INSERT INTO recurrence_occurrences(
1454            workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
1455            outcome_change_id, projection_state, archived_at
1456         ) VALUES (?, ?, ?, ?, '', '', '', 'projected', '')",
1457    )
1458    .bind(&workspace.id)
1459    .bind(&series.id)
1460    .bind(slot_on.format("%Y-%m-%d").to_string())
1461    .bind(&identity.task_id)
1462    .execute(&mut *conn)
1463    .await?;
1464
1465    let payload = deterministic_task_payload(workspace, series, labels, &slot, &identity);
1466    insert_change_with_identity(
1467        conn,
1468        IdentifiedChange {
1469            change_id: &identity.task_change_id,
1470            entity_type: "task",
1471            entity_id: identity.task_id.as_str(),
1472            field: None,
1473            op_type: op_type::CREATE_TASK,
1474            payload,
1475            base_version: None,
1476            created_at: &identity.created_at,
1477        },
1478    )
1479    .await?;
1480    let occurrence_payload = ChangePayload::workspace(workspace)
1481        .set("series_id", series.id.as_str())
1482        .set("slot_on", slot_on.format("%Y-%m-%d").to_string())
1483        .set("task_id", identity.task_id.as_str())
1484        .set("projected_at", &identity.occurrence_link.projected_at)
1485        .set("task_change_id", &identity.task_change_id)
1486        .set("occurrence_change_id", &identity.occurrence_change_id)
1487        .set(
1488            "task_field_version_seed",
1489            &identity.field_version_seeds.task,
1490        )
1491        .set(
1492            "occurrence_field_version_seed",
1493            &identity.field_version_seeds.occurrence,
1494        )
1495        .set("frequency", series.rule.frequency().as_str())
1496        .set("interval", series.rule.interval())
1497        .set("weekdays", series.rule.weekdays_set().to_string())
1498        .set("timezone", series.timezone.as_str())
1499        .set("start_on", series.start_on.format("%Y-%m-%d").to_string())
1500        .set(
1501            "available_local_time",
1502            format_local_time(series.available_local_time),
1503        )
1504        .set("due_policy", series.due_policy.as_str())
1505        .into_value();
1506    insert_change_with_identity(
1507        conn,
1508        IdentifiedChange {
1509            change_id: &identity.occurrence_change_id,
1510            entity_type: "recurrence_series",
1511            entity_id: series.id.as_str(),
1512            field: Some("projection"),
1513            op_type: op_type::PROJECT_RECURRENCE_OCCURRENCE,
1514            payload: occurrence_payload,
1515            base_version: None,
1516            created_at: &identity.occurrence_link.projected_at,
1517        },
1518    )
1519    .await?;
1520    for field in TaskField::VERSIONED {
1521        set_entity_field_version(
1522            conn,
1523            &workspace.id,
1524            MutableEntityType::Task,
1525            identity.task_id.as_str(),
1526            field.as_str(),
1527            &identity.field_version_seeds.task,
1528        )
1529        .await?;
1530    }
1531    load_occurrence(conn, &workspace.id, &series.id, slot_on)
1532        .await?
1533        .context("materialized recurrence occurrence missing")
1534}
1535
1536async fn verify_materialized_occurrence(
1537    conn: &mut SqliteConnection,
1538    workspace: &Workspace,
1539    series: &RecurrenceSeries,
1540    labels: &[String],
1541    slot: &crate::recurrence::RecurrenceSlot,
1542    identity: &crate::recurrence::RecurrenceOccurrenceIdentity,
1543    occurrence: &RecurrenceOccurrence,
1544) -> Result<()> {
1545    ensure!(
1546        occurrence.task_id.as_ref() == Some(&identity.task_id),
1547        "error recurrence-generation-conflict slot={} field=task_id",
1548        occurrence.slot_on
1549    );
1550    let task = get_task_in_workspace(conn, workspace, &identity.task_id).await?;
1551    ensure!(
1552        task.title == series.title
1553            && task.description == series.description
1554            && task.project_id == series.project_id
1555            && task.status == series.initial_status
1556            && task.priority == series.priority
1557            && task.created_at == identity.created_at
1558            && task.updated_at == identity.updated_at
1559            && task.queue_activity_at == identity.created_at
1560            && task.available_at.as_deref() == Some(slot.available_at.as_str())
1561            && task.due_on == slot.due_on
1562            && !task.deleted
1563            && !task.is_epic,
1564        "error recurrence-generation-conflict slot={} field=task",
1565        occurrence.slot_on
1566    );
1567    let stored_labels: Vec<String> = sqlx::query_scalar(
1568        "SELECT label FROM task_labels WHERE workspace_id = ? AND task_id = ? ORDER BY label",
1569    )
1570    .bind(&workspace.id)
1571    .bind(&identity.task_id)
1572    .fetch_all(&mut *conn)
1573    .await?;
1574    ensure!(
1575        stored_labels == labels,
1576        "error recurrence-generation-conflict slot={} field=labels",
1577        occurrence.slot_on
1578    );
1579    for field in TaskField::VERSIONED {
1580        let version = entity_field_version(
1581            conn,
1582            &workspace.id,
1583            MutableEntityType::Task,
1584            identity.task_id.as_str(),
1585            field.as_str(),
1586        )
1587        .await?;
1588        ensure!(
1589            version.as_deref() == Some(identity.field_version_seeds.task.as_str()),
1590            "error recurrence-generation-conflict slot={} field=field_versions",
1591            occurrence.slot_on
1592        );
1593    }
1594    let payload = deterministic_task_payload(workspace, series, labels, slot, identity);
1595    insert_change_with_identity(
1596        conn,
1597        IdentifiedChange {
1598            change_id: &identity.task_change_id,
1599            entity_type: "task",
1600            entity_id: identity.task_id.as_str(),
1601            field: None,
1602            op_type: op_type::CREATE_TASK,
1603            payload,
1604            base_version: None,
1605            created_at: &identity.created_at,
1606        },
1607    )
1608    .await?;
1609    Ok(())
1610}
1611
1612fn deterministic_task_payload(
1613    workspace: &Workspace,
1614    series: &RecurrenceSeries,
1615    labels: &[String],
1616    slot: &crate::recurrence::RecurrenceSlot,
1617    identity: &crate::recurrence::RecurrenceOccurrenceIdentity,
1618) -> Value {
1619    ChangePayload::workspace(workspace)
1620        .set("task_id", identity.task_id.as_str())
1621        .set("series_id", series.id.as_str())
1622        .set("slot_on", slot.scheduled_on.format("%Y-%m-%d").to_string())
1623        .set("title", &series.title)
1624        .set("description", &series.description)
1625        .set("project_id", series.project_id.as_str())
1626        .set("status", series.initial_status.as_str())
1627        .set("priority", series.priority.as_str())
1628        .set("available_at", &slot.available_at)
1629        .set("due_on", slot.due_on.as_deref().unwrap_or(""))
1630        .set("is_epic", "0")
1631        .set("labels", labels)
1632        .set("created_at", &identity.created_at)
1633        .set("updated_at", &identity.updated_at)
1634        .set("task_change_id", &identity.task_change_id)
1635        .set("occurrence_change_id", &identity.occurrence_change_id)
1636        .set(
1637            "task_field_version_seed",
1638            &identity.field_version_seeds.task,
1639        )
1640        .set(
1641            "occurrence_field_version_seed",
1642            &identity.field_version_seeds.occurrence,
1643        )
1644        .set("frequency", series.rule.frequency().as_str())
1645        .set("interval", series.rule.interval())
1646        .set("weekdays", series.rule.weekdays_set().to_string())
1647        .set("timezone", series.timezone.as_str())
1648        .set("start_on", series.start_on.format("%Y-%m-%d").to_string())
1649        .set(
1650            "available_local_time",
1651            format_local_time(series.available_local_time),
1652        )
1653        .set("due_policy", series.due_policy.as_str())
1654        .into_value()
1655}
1656
1657async fn write_task_status(
1658    conn: &mut SqliteConnection,
1659    workspace: &Workspace,
1660    task_id: &TaskId,
1661    before: TaskStatus,
1662    after: TaskStatus,
1663    resolved_at: &str,
1664) -> Result<String> {
1665    let field = "status";
1666    ensure!(
1667        !crate::db::conflict_exists(conn, &workspace.id, task_id, field).await?,
1668        "error conflicted-field ref={} field=status hint=\"use conflict resolve\"",
1669        task_id
1670    );
1671    let base = entity_field_version(
1672        conn,
1673        &workspace.id,
1674        MutableEntityType::Task,
1675        task_id.as_str(),
1676        field,
1677    )
1678    .await?;
1679    apply_field_value_in_workspace(conn, &workspace.id, task_id, field, after.as_str()).await?;
1680    let change_id = insert_change(
1681        conn,
1682        "task",
1683        task_id.as_str(),
1684        Some(field),
1685        op_type::SET_FIELD,
1686        TaskField::Status.scalar_payload(&workspace.id, &workspace.key, after.as_str())?,
1687        base.as_deref(),
1688    )
1689    .await?;
1690    set_field_version(conn, task_id, field, &change_id).await?;
1691    ensure!(
1692        before.is_open() && after.is_terminal(),
1693        "error recurrence-invalid-terminal-transition"
1694    );
1695    sqlx::query(
1696        "UPDATE tasks SET updated_at = ?, queue_activity_at = ?
1697         WHERE workspace_id = ? AND id = ?",
1698    )
1699    .bind(resolved_at)
1700    .bind(resolved_at)
1701    .bind(&workspace.id)
1702    .bind(task_id)
1703    .execute(&mut *conn)
1704    .await?;
1705    Ok(change_id)
1706}
1707
1708async fn set_series_state(
1709    conn: &mut SqliteConnection,
1710    workspace: &Workspace,
1711    series_id: &RecurrenceSeriesId,
1712    state: RecurrenceSeriesState,
1713    stopped_at: Option<&str>,
1714    changed_at: &str,
1715    operation: &'static str,
1716) -> Result<String> {
1717    let base = entity_field_version(
1718        conn,
1719        &workspace.id,
1720        MutableEntityType::RecurrenceSeries,
1721        series_id.as_str(),
1722        "state",
1723    )
1724    .await?;
1725    sqlx::query(
1726        "UPDATE recurrence_series SET state = ?, stopped_at = ?, updated_at = ?
1727         WHERE workspace_id = ? AND id = ?",
1728    )
1729    .bind(state.as_str())
1730    .bind(stopped_at.unwrap_or(""))
1731    .bind(changed_at)
1732    .bind(&workspace.id)
1733    .bind(series_id)
1734    .execute(&mut *conn)
1735    .await?;
1736    let change_id = insert_change(
1737        conn,
1738        "recurrence_series",
1739        series_id.as_str(),
1740        Some("state"),
1741        operation,
1742        ChangePayload::workspace(workspace)
1743            .set("state", state.as_str())
1744            .set("stopped_at", stopped_at.unwrap_or(""))
1745            .set("changed_at", changed_at)
1746            .into_value(),
1747        base.as_deref(),
1748    )
1749    .await?;
1750    set_entity_field_version(
1751        conn,
1752        &workspace.id,
1753        MutableEntityType::RecurrenceSeries,
1754        series_id.as_str(),
1755        "state",
1756        &change_id,
1757    )
1758    .await?;
1759    if matches!(state, RecurrenceSeriesState::Stopped) {
1760        set_entity_field_version(
1761            conn,
1762            &workspace.id,
1763            MutableEntityType::RecurrenceSeries,
1764            series_id.as_str(),
1765            "stopped_at",
1766            &change_id,
1767        )
1768        .await?;
1769    }
1770    Ok(change_id)
1771}
1772
1773async fn update_series_template_scalar(
1774    conn: &mut SqliteConnection,
1775    workspace_id: &WorkspaceId,
1776    series_id: &RecurrenceSeriesId,
1777    field: &str,
1778    value: &str,
1779    updated_at: &str,
1780) -> Result<()> {
1781    let query = match field {
1782        "title" => {
1783            "UPDATE recurrence_series SET title = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1784        }
1785        "description" => {
1786            "UPDATE recurrence_series SET description = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1787        }
1788        "project" => {
1789            "UPDATE recurrence_series SET project_id = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1790        }
1791        "priority" => {
1792            "UPDATE recurrence_series SET priority = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1793        }
1794        "initial_status" => {
1795            "UPDATE recurrence_series SET initial_status = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1796        }
1797        "available_local_time" => {
1798            "UPDATE recurrence_series SET available_local_time = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1799        }
1800        "due_policy" => {
1801            "UPDATE recurrence_series SET due_policy = ?, updated_at = ? WHERE workspace_id = ? AND id = ?"
1802        }
1803        _ => bail!("invalid recurrence template field: {field}"),
1804    };
1805    sqlx::query(query)
1806        .bind(value)
1807        .bind(updated_at)
1808        .bind(workspace_id)
1809        .bind(series_id)
1810        .execute(&mut *conn)
1811        .await?;
1812    Ok(())
1813}
1814
1815async fn load_series(
1816    conn: &mut SqliteConnection,
1817    workspace_id: &WorkspaceId,
1818    series_id: &RecurrenceSeriesId,
1819) -> Result<RecurrenceSeries> {
1820    let row = sqlx::query(
1821        "SELECT workspace_id, id, title, description, project_id, priority, initial_status,
1822                frequency, interval, weekdays, timezone, start_on, available_local_time,
1823                due_policy, state, stopped_at, created_at, updated_at, deleted
1824         FROM recurrence_series WHERE workspace_id = ? AND id = ?",
1825    )
1826    .bind(workspace_id)
1827    .bind(series_id)
1828    .fetch_optional(&mut *conn)
1829    .await?
1830    .with_context(|| format!("error recurrence-series-not-found series_id={series_id}"))?;
1831    recurrence_series_from_row(&row)
1832}
1833
1834async fn load_series_labels(
1835    conn: &mut SqliteConnection,
1836    workspace_id: &WorkspaceId,
1837    series_id: &RecurrenceSeriesId,
1838) -> Result<Vec<String>> {
1839    Ok(sqlx::query_scalar(
1840        "SELECT label FROM recurrence_series_labels
1841         WHERE workspace_id = ? AND series_id = ? ORDER BY label",
1842    )
1843    .bind(workspace_id)
1844    .bind(series_id)
1845    .fetch_all(&mut *conn)
1846    .await?)
1847}
1848
1849async fn load_occurrence(
1850    conn: &mut SqliteConnection,
1851    workspace_id: &WorkspaceId,
1852    series_id: &RecurrenceSeriesId,
1853    slot_on: NaiveDate,
1854) -> Result<Option<RecurrenceOccurrence>> {
1855    let row = sqlx::query(
1856        "SELECT workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
1857                outcome_change_id, projection_state, archived_at
1858         FROM recurrence_occurrences
1859         WHERE workspace_id = ? AND series_id = ? AND slot_on = ?",
1860    )
1861    .bind(workspace_id)
1862    .bind(series_id)
1863    .bind(slot_on.format("%Y-%m-%d").to_string())
1864    .fetch_optional(&mut *conn)
1865    .await?;
1866    row.as_ref().map(recurrence_occurrence_from_row).transpose()
1867}
1868
1869async fn load_projected_occurrence(
1870    conn: &mut SqliteConnection,
1871    workspace_id: &WorkspaceId,
1872    series_id: &RecurrenceSeriesId,
1873) -> Result<Option<RecurrenceOccurrence>> {
1874    let row = sqlx::query(
1875        "SELECT workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
1876                outcome_change_id, projection_state, archived_at
1877         FROM recurrence_occurrences
1878         WHERE workspace_id = ? AND series_id = ? AND projection_state = 'projected'",
1879    )
1880    .bind(workspace_id)
1881    .bind(series_id)
1882    .fetch_optional(&mut *conn)
1883    .await?;
1884    row.as_ref().map(recurrence_occurrence_from_row).transpose()
1885}
1886
1887async fn load_occurrence_for_task(
1888    conn: &mut SqliteConnection,
1889    workspace_id: &WorkspaceId,
1890    task_id: &TaskId,
1891) -> Result<Option<RecurrenceOccurrence>> {
1892    let row = sqlx::query(
1893        "SELECT workspace_id, series_id, slot_on, task_id, outcome, resolved_at,
1894                outcome_change_id, projection_state, archived_at
1895         FROM recurrence_occurrences WHERE workspace_id = ? AND task_id = ?",
1896    )
1897    .bind(workspace_id)
1898    .bind(task_id)
1899    .fetch_optional(&mut *conn)
1900    .await?;
1901    row.as_ref().map(recurrence_occurrence_from_row).transpose()
1902}
1903
1904async fn lifecycle_conflict_exists(
1905    conn: &mut SqliteConnection,
1906    workspace_id: &WorkspaceId,
1907    series_id: &RecurrenceSeriesId,
1908) -> Result<bool> {
1909    for field in ["state", "stopped_at"] {
1910        if entity_conflict_exists(
1911            conn,
1912            workspace_id,
1913            MutableEntityType::RecurrenceSeries,
1914            series_id.as_str(),
1915            field,
1916        )
1917        .await?
1918        {
1919            return Ok(true);
1920        }
1921    }
1922    Ok(false)
1923}
1924
1925async fn ensure_no_series_conflict(
1926    conn: &mut SqliteConnection,
1927    workspace_id: &WorkspaceId,
1928    series_id: &RecurrenceSeriesId,
1929    field: &str,
1930) -> Result<()> {
1931    ensure!(
1932        !entity_conflict_exists(
1933            conn,
1934            workspace_id,
1935            MutableEntityType::RecurrenceSeries,
1936            series_id.as_str(),
1937            field,
1938        )
1939        .await?,
1940        "error recurrence-conflicted-field series_id={series_id} field={field}"
1941    );
1942    Ok(())
1943}
1944
1945pub async fn resolve_recurrence_ref(
1946    conn: &mut SqliteConnection,
1947    workspace: &Workspace,
1948    input: &str,
1949) -> Result<RecurrenceSeries> {
1950    let (hint, suffix) = split_ref(input);
1951    if suffix.len() < 3 {
1952        bail!("error recurrence-ref-too-short input={input} minimum=3");
1953    }
1954    let series_rows = sqlx::query(
1955        "SELECT workspace_id, id, title, description, project_id, priority, initial_status,
1956                frequency, interval, weekdays, timezone, start_on, available_local_time,
1957                due_policy, state, stopped_at, created_at, updated_at, deleted
1958         FROM recurrence_series WHERE workspace_id = ? AND id LIKE ? || '%' ORDER BY id",
1959    )
1960    .bind(&workspace.id)
1961    .bind(&suffix)
1962    .fetch_all(&mut *conn)
1963    .await?;
1964    let mut series = series_rows
1965        .iter()
1966        .map(recurrence_series_from_row)
1967        .collect::<Result<Vec<_>>>()?;
1968    if hint
1969        .as_deref()
1970        .is_some_and(|value| value != SERIES_REF_PREFIX)
1971    {
1972        series.clear();
1973    }
1974    if series.len() == 1 {
1975        return Ok(series.remove(0));
1976    }
1977    if series.len() > 1 {
1978        bail!("error ambiguous-recurrence-ref input={input}");
1979    }
1980    let task = crate::refs::resolve_task_ref_in_workspace(conn, workspace, input).await?;
1981    let occurrence = load_occurrence_for_task(conn, &workspace.id, &task.id)
1982        .await?
1983        .context("error task-is-not-recurring")?;
1984    load_series(conn, &workspace.id, &occurrence.series_id).await
1985}
1986
1987async fn recurrence_series_ref(
1988    conn: &mut SqliteConnection,
1989    workspace_id: &WorkspaceId,
1990    series_id: &RecurrenceSeriesId,
1991) -> Result<String> {
1992    let ids = sqlx::query_scalar::<_, RecurrenceSeriesId>(
1993        "SELECT id FROM recurrence_series WHERE workspace_id = ? ORDER BY id",
1994    )
1995    .bind(workspace_id)
1996    .fetch_all(&mut *conn)
1997    .await?;
1998    ensure!(
1999        ids.iter().any(|candidate| candidate == series_id),
2000        "error recurrence-series-not-found series_id={series_id}"
2001    );
2002    Ok(recurrence_series_display_ref(series_id, &ids))
2003}
2004
2005fn split_ref(input: &str) -> (Option<String>, String) {
2006    let (hint, suffix) = input
2007        .split_once('-')
2008        .map_or((None, input), |(hint, suffix)| (Some(hint), suffix));
2009    let suffix = suffix
2010        .chars()
2011        .filter(|value| value.is_ascii_alphanumeric())
2012        .map(|value| match value.to_ascii_uppercase() {
2013            'O' => '0',
2014            'I' | 'L' => '1',
2015            value => value,
2016        })
2017        .collect();
2018    (hint.map(|value| value.to_ascii_uppercase()), suffix)
2019}
2020
2021async fn ensure_change_is_latest_series_transition(
2022    conn: &mut SqliteConnection,
2023    workspace_id: &WorkspaceId,
2024    series_id: &RecurrenceSeriesId,
2025    change_id: &str,
2026) -> Result<()> {
2027    let latest: Option<String> = sqlx::query_scalar(
2028        "SELECT change_id FROM changes
2029         WHERE entity_type = 'recurrence_series' AND entity_id = ?
2030         AND json_extract(payload, '$.workspace_id') = ?
2031         AND op_type != 'project_recurrence_occurrence'
2032         ORDER BY local_seq DESC LIMIT 1",
2033    )
2034    .bind(series_id)
2035    .bind(workspace_id.as_str())
2036    .fetch_optional(&mut *conn)
2037    .await?;
2038    ensure!(
2039        latest.as_deref() == Some(change_id),
2040        "error recurrence-undo-later-operation"
2041    );
2042    Ok(())
2043}
2044
2045async fn ensure_change_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<()> {
2046    let server_seq: Option<i64> =
2047        sqlx::query_scalar("SELECT server_seq FROM changes WHERE change_id = ?")
2048            .bind(change_id)
2049            .fetch_one(&mut *conn)
2050            .await?;
2051    ensure!(server_seq.is_none(), "error recurrence-undo-already-synced");
2052    Ok(())
2053}
2054
2055async fn ensure_successor_untouched(
2056    conn: &mut SqliteConnection,
2057    workspace_id: &WorkspaceId,
2058    series: &RecurrenceSeries,
2059    occurrence: &RecurrenceOccurrence,
2060) -> Result<()> {
2061    let task_id = occurrence
2062        .task_id
2063        .as_ref()
2064        .context("error recurrence-undo-successor-missing-task")?;
2065    let identity = derive_occurrence_identity(
2066        workspace_id,
2067        &series.id,
2068        &series.schedule(),
2069        occurrence.slot_on,
2070    )?;
2071    ensure!(
2072        task_id == &identity.task_id,
2073        "error recurrence-undo-successor-changed"
2074    );
2075    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2076    let labels = load_series_labels(conn, workspace_id, &series.id).await?;
2077    let slot = slot_values(&series.schedule(), occurrence.slot_on)?;
2078    verify_materialized_occurrence(
2079        conn, &workspace, series, &labels, &slot, &identity, occurrence,
2080    )
2081    .await
2082    .map_err(|_| anyhow::anyhow!("error recurrence-undo-successor-touched"))?;
2083    ensure_change_unsynced(conn, &identity.task_change_id).await?;
2084    ensure_change_unsynced(conn, &identity.occurrence_change_id).await?;
2085    let extra_changes: i64 = sqlx::query_scalar(
2086        "SELECT count(*) FROM changes
2087         WHERE entity_type = 'task' AND entity_id = ? AND change_id != ?",
2088    )
2089    .bind(task_id)
2090    .bind(&identity.task_change_id)
2091    .fetch_one(&mut *conn)
2092    .await?;
2093    let notes: i64 =
2094        sqlx::query_scalar("SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ?")
2095            .bind(workspace_id)
2096            .bind(task_id)
2097            .fetch_one(&mut *conn)
2098            .await?;
2099    let attachments: i64 = sqlx::query_scalar(
2100        "SELECT count(*) FROM task_attachments WHERE workspace_id = ? AND task_id = ?",
2101    )
2102    .bind(workspace_id)
2103    .bind(task_id)
2104    .fetch_one(&mut *conn)
2105    .await?;
2106    let dependencies: i64 = sqlx::query_scalar(
2107        "SELECT count(*) FROM task_dependencies
2108         WHERE workspace_id = ? AND (task_id = ? OR depends_on_task_id = ?)",
2109    )
2110    .bind(workspace_id)
2111    .bind(task_id)
2112    .bind(task_id)
2113    .fetch_one(&mut *conn)
2114    .await?;
2115    let epics: i64 = sqlx::query_scalar(
2116        "SELECT count(*) FROM task_epic_links
2117         WHERE workspace_id = ? AND (child_task_id = ? OR epic_task_id = ?)",
2118    )
2119    .bind(workspace_id)
2120    .bind(task_id)
2121    .bind(task_id)
2122    .fetch_one(&mut *conn)
2123    .await?;
2124    ensure!(
2125        extra_changes == 0 && notes == 0 && attachments == 0 && dependencies == 0 && epics == 0,
2126        "error recurrence-undo-successor-touched"
2127    );
2128    Ok(())
2129}
2130
2131async fn remove_materialized_occurrence(
2132    conn: &mut SqliteConnection,
2133    workspace_id: &WorkspaceId,
2134    series: &RecurrenceSeries,
2135    occurrence: &RecurrenceOccurrence,
2136) -> Result<()> {
2137    let task_id = occurrence
2138        .task_id
2139        .as_ref()
2140        .expect("verified successor has task");
2141    let identity = derive_occurrence_identity(
2142        workspace_id,
2143        &series.id,
2144        &series.schedule(),
2145        occurrence.slot_on,
2146    )?;
2147    sqlx::query(
2148        "DELETE FROM recurrence_occurrences
2149         WHERE workspace_id = ? AND series_id = ? AND slot_on = ?",
2150    )
2151    .bind(workspace_id)
2152    .bind(&series.id)
2153    .bind(occurrence.slot_on.format("%Y-%m-%d").to_string())
2154    .execute(&mut *conn)
2155    .await?;
2156    sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
2157        .bind(workspace_id)
2158        .bind(task_id)
2159        .execute(&mut *conn)
2160        .await?;
2161    sqlx::query(
2162        "DELETE FROM field_versions
2163         WHERE workspace_id = ? AND entity_type = 'task' AND entity_id = ?",
2164    )
2165    .bind(workspace_id)
2166    .bind(task_id)
2167    .execute(&mut *conn)
2168    .await?;
2169    sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
2170        .bind(workspace_id)
2171        .bind(task_id)
2172        .execute(&mut *conn)
2173        .await?;
2174    sqlx::query("DELETE FROM changes WHERE change_id IN (?, ?)")
2175        .bind(&identity.task_change_id)
2176        .bind(&identity.occurrence_change_id)
2177        .execute(&mut *conn)
2178        .await?;
2179    Ok(())
2180}
2181
2182fn retryable_reconcile_error(error: &anyhow::Error) -> bool {
2183    error
2184        .downcast_ref::<sqlx::Error>()
2185        .and_then(sqlx::Error::as_database_error)
2186        .and_then(|error| error.code())
2187        .is_some_and(|code| matches!(code.as_ref(), "5" | "6" | "19"))
2188}
2189
2190pub(crate) fn timestamp_strictly_after(earlier: &str, candidate: &str) -> Result<String> {
2191    if candidate > earlier {
2192        return Ok(candidate.to_string());
2193    }
2194    let earlier = DateTime::parse_from_rfc3339(earlier)?.with_timezone(&Utc);
2195    let adjusted = earlier
2196        .checked_add_signed(chrono::Duration::seconds(1))
2197        .context("recurrence lifecycle timestamp is out of range")?;
2198    Ok(format_utc(adjusted))
2199}
2200
2201fn format_local_time(value: Option<NaiveTime>) -> String {
2202    value
2203        .map(|value| value.format("%H:%M:%S").to_string())
2204        .unwrap_or_default()
2205}
2206
2207fn format_utc(value: DateTime<Utc>) -> String {
2208    value.format("%Y-%m-%dT%H:%M:%SZ").to_string()
2209}
2210
2211fn utc_now() -> Result<DateTime<Utc>> {
2212    Ok(DateTime::parse_from_rfc3339(&now())?.with_timezone(&Utc))
2213}