Skip to main content

aven_core/query/
recent_actions.rs

1use crate::ids::{TaskId, WorkspaceId};
2use anyhow::Result;
3use serde_json::Value;
4use sqlx::{QueryBuilder, Row, Sqlite, SqliteConnection};
5use std::collections::HashMap;
6
7use crate::change_log::op_type;
8use crate::query::types::{RecentActionItem, RecentActionTarget, TaskListItem};
9use crate::refs::DisplayRefContext;
10use crate::task_fields::TaskField;
11
12impl RecentActionItem {
13    pub fn task_activity_summary(&self, task_title: &str) -> String {
14        let summary = self
15            .summary
16            .strip_suffix(&format!(": {}", task_title))
17            .unwrap_or(&self.summary);
18        let Some(detail) = self.task_activity_detail() else {
19            return summary.to_string();
20        };
21        format!("{summary} · {detail}")
22    }
23
24    fn task_activity_detail(&self) -> Option<&str> {
25        let detail = self.detail.as_deref().filter(|detail| !detail.is_empty())?;
26        let include = match self.op_type.as_str() {
27            crate::change_log::op_type::LABEL_ADD
28            | crate::change_log::op_type::LABEL_REMOVE
29            | crate::change_log::op_type::NOTE_ADD
30            | crate::change_log::op_type::NOTE_EDIT
31            | crate::change_log::op_type::NOTE_DELETE
32            | crate::change_log::op_type::DEPENDENCY_ADD
33            | crate::change_log::op_type::DEPENDENCY_REMOVE
34            | crate::change_log::op_type::RELATED_ADD
35            | crate::change_log::op_type::RELATED_REMOVE
36            | crate::change_log::op_type::EPIC_LINK_ADD
37            | crate::change_log::op_type::EPIC_LINK_REMOVE
38            | crate::change_log::op_type::ATTACHMENT_ADD
39            | crate::change_log::op_type::ATTACHMENT_DELETE
40            | crate::change_log::op_type::SET_TASK_METADATA
41            | crate::change_log::op_type::REMOVE_TASK_METADATA => true,
42            crate::change_log::op_type::SET_FIELD => !matches!(
43                self.field.as_deref(),
44                Some("description" | "status" | "priority" | "deleted" | "is_epic")
45            ),
46            _ => false,
47        };
48        include.then_some(detail)
49    }
50
51    fn establishes_queue_activity(&self) -> bool {
52        match self.op_type.as_str() {
53            crate::change_log::op_type::CREATE_TASK
54            | crate::change_log::op_type::NOTE_ADD
55            | crate::change_log::op_type::NOTE_EDIT
56            | crate::change_log::op_type::NOTE_DELETE => true,
57            crate::change_log::op_type::SET_FIELD | crate::change_log::op_type::RESOLVE_FIELD => {
58                self.field
59                    .as_deref()
60                    .and_then(TaskField::parse)
61                    .is_some_and(TaskField::updates_queue_activity)
62            }
63            _ => false,
64        }
65    }
66}
67
68impl TaskListItem {
69    pub fn queue_idle_activity_index(&self) -> Option<usize> {
70        if self.queue.band == crate::queue::QueueBand::Available
71            || self.queue.idle_seconds.is_none()
72        {
73            return None;
74        }
75        self.activity.iter().position(|action| {
76            action.created_at == self.task.queue_activity_at && action.establishes_queue_activity()
77        })
78    }
79}
80
81const RECENT_ACTION_LIMIT: i64 = 80;
82const TASK_ACTIVITY_LIMIT: i64 = 8;
83const SQLITE_BIND_CHUNK_SIZE: usize = 900;
84
85pub async fn list_recent_actions_in_workspace(
86    conn: &mut SqliteConnection,
87    workspace_id: &WorkspaceId,
88    project_scope: Option<&str>,
89) -> Result<Vec<RecentActionItem>> {
90    let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
91    let series_refs = super::recurrence::SeriesRefContext::load(conn, workspace_id).await?;
92
93    let rows = sqlx::query(
94        "SELECT c.change_id, c.entity_type, c.entity_id, c.field, c.op_type, c.payload,
95                c.created_at, c.server_seq,
96                t.title AS task_title, t.status AS task_status, t.deleted AS task_deleted,
97                p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix,
98                rs.id AS recurrence_series_id, rs.title AS recurrence_series_title,
99                related.id AS related_task_id, related_project.prefix AS related_project_prefix
100         FROM changes c INDEXED BY idx_changes_workspace_recent_actions
101         LEFT JOIN changes resolving_status
102           ON resolving_status.recurrence_task_status_change_id = c.change_id
103         LEFT JOIN changes resolving_create
104           ON c.op_type = 'create_task'
105          AND resolving_create.recurrence_successor_task_id = c.entity_id
106         LEFT JOIN changes resolving_projection
107           ON c.op_type = 'project_recurrence_occurrence'
108          AND resolving_projection.recurrence_successor_task_id =
109              json_extract(c.payload, '$.task_id')
110         LEFT JOIN tasks t
111           ON c.entity_type = 'task'
112          AND t.workspace_id = ?
113          AND t.id = c.entity_id
114         LEFT JOIN recurrence_occurrences ro
115           ON c.entity_type = 'task'
116          AND ro.workspace_id = ?
117          AND ro.task_id = c.entity_id
118         LEFT JOIN recurrence_series rs
119           ON rs.workspace_id = ?
120          AND rs.id = CASE
121              WHEN c.entity_type = 'recurrence_series' THEN c.entity_id
122              ELSE ro.series_id
123          END
124         LEFT JOIN projects p
125           ON p.workspace_id = ?
126          AND ((c.entity_type = 'project' AND p.id = c.entity_id)
127               OR p.id = t.project_id OR p.id = rs.project_id)
128         LEFT JOIN tasks related
129           ON related.workspace_id = ?
130          AND related.id = COALESCE(
131              json_extract(c.payload, '$.depends_on_task_id'),
132              json_extract(c.payload, '$.related_task_id'),
133              json_extract(c.payload, '$.epic_task_id')
134          )
135         LEFT JOIN projects related_project
136           ON related_project.workspace_id = ?
137          AND related_project.id = related.project_id
138         WHERE CASE WHEN json_valid(c.payload)
139                    THEN json_extract(c.payload, '$.workspace_id') END = ?
140           AND resolving_status.rowid IS NULL
141           AND resolving_create.rowid IS NULL
142           AND resolving_projection.rowid IS NULL
143           AND (? IS NULL
144                OR p.key = ?
145                OR json_extract(c.payload, '$.project_key') = ?)
146         ORDER BY c.created_at DESC, c.local_seq DESC
147         LIMIT ?",
148    )
149    .bind(workspace_id)
150    .bind(workspace_id)
151    .bind(workspace_id)
152    .bind(workspace_id)
153    .bind(workspace_id)
154    .bind(workspace_id)
155    .bind(workspace_id)
156    .bind(project_scope)
157    .bind(project_scope)
158    .bind(project_scope)
159    .bind(RECENT_ACTION_LIMIT)
160    .fetch_all(&mut *conn)
161    .await?;
162
163    rows.into_iter()
164        .map(|row| action_from_row(row, workspace_id, &display_refs, &series_refs))
165        .collect()
166}
167
168pub(crate) async fn task_activity_for_tasks_in_workspace(
169    conn: &mut SqliteConnection,
170    workspace_id: &WorkspaceId,
171    task_ids: &[TaskId],
172) -> Result<HashMap<TaskId, Vec<RecentActionItem>>> {
173    let mut activity_by_task = HashMap::new();
174    if task_ids.is_empty() {
175        return Ok(activity_by_task);
176    }
177    let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
178    let series_refs = super::recurrence::SeriesRefContext::load(conn, workspace_id).await?;
179
180    for chunk in task_ids.chunks(SQLITE_BIND_CHUNK_SIZE) {
181        let mut query = QueryBuilder::<Sqlite>::new("WITH requested(task_id) AS (VALUES ");
182        for (index, task_id) in chunk.iter().enumerate() {
183            if index > 0 {
184                query.push(", ");
185            }
186            query.push("(");
187            query.push_bind(task_id);
188            query.push(")");
189        }
190        query.push(
191            "), top_changes AS (
192                 SELECT c.change_id, c.entity_type, c.entity_id, c.field, c.op_type,
193                        c.payload, c.created_at, c.local_seq, c.server_seq
194                 FROM requested r
195                 JOIN changes c
196                   ON c.rowid IN (
197                   SELECT candidate.rowid
198                   FROM changes candidate INDEXED BY idx_changes_task_activity
199                   WHERE candidate.entity_type = 'task'
200                     AND candidate.entity_id = r.task_id
201                     AND json_extract(candidate.payload, '$.workspace_id') = ",
202        );
203        query.push_bind(workspace_id);
204        push_unsuppressed_task_change(&mut query, "candidate");
205        query.push(
206            " ORDER BY candidate.created_at DESC, candidate.local_seq DESC
207                   LIMIT ",
208        );
209        query.push_bind(TASK_ACTIVITY_LIMIT);
210        query.push(
211            " )
212             ), driver_changes AS (
213                 SELECT c.change_id, c.entity_type, c.entity_id, c.field, c.op_type,
214                        c.payload, c.created_at, c.local_seq, c.server_seq
215                 FROM requested r
216                 JOIN tasks t
217                   ON t.workspace_id = ",
218        );
219        query.push_bind(workspace_id);
220        query.push(
221            " AND t.id = r.task_id
222                 JOIN changes c
223                   ON c.entity_type = 'task' AND c.entity_id = r.task_id
224                 WHERE json_extract(c.payload, '$.workspace_id') = ",
225        );
226        query.push_bind(workspace_id);
227        query.push(
228            " AND c.rowid = (
229                   SELECT driver.rowid
230                   FROM changes driver INDEXED BY idx_changes_task_activity
231                   WHERE driver.entity_type = 'task'
232                     AND driver.entity_id = r.task_id
233                     AND json_extract(driver.payload, '$.workspace_id') = ",
234        );
235        query.push_bind(workspace_id);
236        query.push(
237            " AND driver.created_at = t.queue_activity_at
238                     AND (
239                       driver.op_type IN ('create_task', 'note_add', 'note_edit', 'note_delete')
240                       OR (driver.op_type IN ('set_field', 'resolve_field')
241                           AND driver.field IN ('status', 'priority'))
242                     )",
243        );
244        push_unsuppressed_task_change(&mut query, "driver");
245        query.push(
246            " ORDER BY driver.created_at DESC, driver.local_seq DESC
247                   LIMIT 1
248                 )
249             ), selected AS (
250                 SELECT * FROM top_changes
251                 UNION
252                 SELECT * FROM driver_changes
253             )
254             SELECT selected.change_id, selected.entity_type, selected.entity_id,
255                    selected.field, selected.op_type, selected.payload,
256                    selected.created_at, selected.server_seq,
257                    t.queue_activity_at AS task_queue_activity_at,
258                    t.title AS task_title, t.status AS task_status, t.deleted AS task_deleted,
259                    p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix,
260                    rs.id AS recurrence_series_id, rs.title AS recurrence_series_title,
261                    related.id AS related_task_id,
262                    related_project.prefix AS related_project_prefix
263             FROM selected
264             JOIN tasks t
265               ON t.workspace_id = ",
266        );
267        query.push_bind(workspace_id);
268        query.push(
269            " AND t.id = selected.entity_id
270             LEFT JOIN recurrence_occurrences ro
271               ON ro.workspace_id = ",
272        );
273        query.push_bind(workspace_id);
274        query.push(
275            " AND ro.task_id = selected.entity_id
276             LEFT JOIN recurrence_series rs
277               ON rs.workspace_id = ",
278        );
279        query.push_bind(workspace_id);
280        query.push(
281            " AND rs.id = ro.series_id
282             LEFT JOIN projects p
283               ON p.workspace_id = ",
284        );
285        query.push_bind(workspace_id);
286        query.push(
287            " AND p.id = t.project_id
288             LEFT JOIN tasks related
289               ON related.workspace_id = ",
290        );
291        query.push_bind(workspace_id);
292        query.push(
293            " AND related.id = COALESCE(
294                   json_extract(selected.payload, '$.depends_on_task_id'),
295                   json_extract(selected.payload, '$.related_task_id'),
296                   json_extract(selected.payload, '$.epic_task_id')
297               )
298             LEFT JOIN projects related_project
299               ON related_project.workspace_id = ",
300        );
301        query.push_bind(workspace_id);
302        query.push(
303            " AND related_project.id = related.project_id
304             ORDER BY selected.entity_id, selected.created_at DESC, selected.local_seq DESC",
305        );
306
307        for row in query.build().fetch_all(&mut *conn).await? {
308            let action = action_from_row(row, workspace_id, &display_refs, &series_refs)?;
309            let task_id = action.entity_id.parse()?;
310            activity_by_task
311                .entry(task_id)
312                .or_insert_with(Vec::new)
313                .push(action);
314        }
315    }
316    Ok(activity_by_task)
317}
318
319fn push_unsuppressed_task_change(query: &mut QueryBuilder<Sqlite>, alias: &str) {
320    query.push(" AND NOT EXISTS (SELECT 1 FROM changes resolving WHERE resolving.");
321    query.push("recurrence_task_status_change_id = ");
322    query.push(alias);
323    query.push(".change_id)");
324    query.push(" AND (");
325    query.push(alias);
326    query.push(
327        ".op_type != 'create_task' OR NOT EXISTS (SELECT 1 FROM changes resolving WHERE resolving.",
328    );
329    query.push("recurrence_successor_task_id = ");
330    query.push(alias);
331    query.push(".entity_id))");
332    query.push(" AND (");
333    query.push(alias);
334    query.push(".op_type != 'project_recurrence_occurrence' OR NOT EXISTS (SELECT 1 FROM changes resolving WHERE resolving.");
335    query.push("recurrence_successor_task_id = json_extract(");
336    query.push(alias);
337    query.push(".payload, '$.task_id')))");
338}
339
340fn action_from_row(
341    row: sqlx::sqlite::SqliteRow,
342    workspace_id: &WorkspaceId,
343    display_refs: &DisplayRefContext,
344    series_refs: &super::recurrence::SeriesRefContext,
345) -> Result<RecentActionItem> {
346    let change_id: String = row.try_get("change_id")?;
347    let entity_type: String = row.try_get("entity_type")?;
348    let entity_id: String = row.try_get("entity_id")?;
349    let field: Option<String> = row.try_get("field")?;
350    let op_type: String = row.try_get("op_type")?;
351    let payload_raw: String = row.try_get("payload")?;
352    let payload: Value = serde_json::from_str(&payload_raw).unwrap_or(Value::Null);
353    let created_at: String = row.try_get("created_at")?;
354    let server_seq: Option<i64> = row.try_get("server_seq")?;
355    let task_title: Option<String> = row.try_get("task_title")?;
356    let task_status: Option<String> = row.try_get("task_status")?;
357    let task_deleted: Option<i64> = row.try_get("task_deleted")?;
358    let project_key: Option<String> = row.try_get("project_key")?;
359    let project_name: Option<String> = row.try_get("project_name")?;
360    let project_prefix: Option<String> = row.try_get("project_prefix")?;
361    let recurrence_series_id: Option<crate::recurrence::RecurrenceSeriesId> =
362        row.try_get("recurrence_series_id")?;
363    let recurrence_series_title: Option<String> = row.try_get("recurrence_series_title")?;
364    let related_task_id: Option<TaskId> = row.try_get("related_task_id")?;
365    let related_project_prefix: Option<String> = row.try_get("related_project_prefix")?;
366
367    let display_ref = if let Some(series_id) = recurrence_series_id.as_ref() {
368        Some(series_refs.display_ref(series_id))
369    } else if entity_type == "task" {
370        let task_id = entity_id.parse()?;
371        project_prefix
372            .as_deref()
373            .map(|prefix| display_refs.display_ref_for_id(workspace_id, prefix, &task_id))
374    } else {
375        None
376    };
377    let (verb, summary, mut detail, accent) = action_text(
378        &entity_type,
379        &op_type,
380        field.as_deref(),
381        &payload,
382        task_title.as_deref(),
383        project_name.as_deref(),
384    );
385    if matches!(
386        op_type.as_str(),
387        op_type::DEPENDENCY_ADD
388            | op_type::DEPENDENCY_REMOVE
389            | op_type::RELATED_ADD
390            | op_type::RELATED_REMOVE
391            | op_type::EPIC_LINK_ADD
392            | op_type::EPIC_LINK_REMOVE
393    ) && let (Some(task_id), Some(prefix)) =
394        (related_task_id.as_ref(), related_project_prefix.as_deref())
395    {
396        detail = Some(display_refs.display_ref_for_id(workspace_id, prefix, task_id));
397    }
398
399    let grouped_change_count = if op_type == op_type::RESOLVE_RECURRENCE_OCCURRENCE {
400        if payload_string(&payload, "successor_task_id").is_some_and(|value| !value.is_empty()) {
401            4
402        } else {
403            2
404        }
405    } else {
406        1
407    };
408
409    Ok(RecentActionItem {
410        change_id,
411        entity_type,
412        entity_id,
413        op_type,
414        field,
415        created_at,
416        synced: server_seq.is_some(),
417        target: RecentActionTarget {
418            display_ref,
419            title: recurrence_series_title.or(task_title).or(project_name),
420            project_key,
421            status: task_status,
422            deleted: task_deleted == Some(1),
423        },
424        verb,
425        summary,
426        detail,
427        accent,
428        grouped_change_count,
429    })
430}
431
432fn action_text(
433    entity_type: &str,
434    op_type: &str,
435    field: Option<&str>,
436    payload: &Value,
437    task_title: Option<&str>,
438    project_name: Option<&str>,
439) -> (String, String, Option<String>, String) {
440    match (entity_type, op_type) {
441        ("recurrence_series", op_type::CREATE_RECURRENCE_SERIES) => (
442            "recurrence".to_string(),
443            "created recurring series".to_string(),
444            payload_string(payload, "title"),
445            "green".to_string(),
446        ),
447        ("recurrence_series", op_type::RESOLVE_RECURRENCE_OCCURRENCE) => {
448            let outcome = payload_string(payload, "outcome").unwrap_or_default();
449            (
450                "recurrence".to_string(),
451                format!("recorded recurring occurrence {outcome}"),
452                payload_string(payload, "slot_on"),
453                "green".to_string(),
454            )
455        }
456        ("recurrence_series", op_type::PROJECT_RECURRENCE_OCCURRENCE) => (
457            "recurrence".to_string(),
458            "projected recurring occurrence".to_string(),
459            payload_string(payload, "slot_on"),
460            "dim".to_string(),
461        ),
462        ("task", op_type::CREATE_TASK) => (
463            "create".to_string(),
464            title_summary("created task", task_title),
465            payload_string(payload, "title"),
466            "green".to_string(),
467        ),
468        ("task", op_type::SET_FIELD) => field_action_text(field, payload, task_title),
469        ("task", op_type::RESOLVE_FIELD) => resolved_field_action_text(field, payload, task_title),
470        ("task", op_type::ATTACHMENT_ADD) => (
471            "attachment".to_string(),
472            title_summary("added attachment", task_title),
473            payload_string(payload, "filename").or_else(|| payload_string(payload, "media_type")),
474            "blue".to_string(),
475        ),
476        ("task", op_type::ATTACHMENT_DELETE) => (
477            "attachment".to_string(),
478            title_summary("deleted attachment", task_title),
479            payload_string(payload, "filename"),
480            "red".to_string(),
481        ),
482        ("task", op_type::SET_TASK_METADATA) => {
483            let key = payload_string(payload, "key").unwrap_or_else(|| "custom".to_string());
484            (
485                "metadata".to_string(),
486                title_summary(&format!("set {key} metadata"), task_title),
487                payload_string(payload, "value"),
488                "blue".to_string(),
489            )
490        }
491        ("task", op_type::REMOVE_TASK_METADATA) => {
492            let key = payload_string(payload, "key").unwrap_or_else(|| "custom".to_string());
493            (
494                "metadata".to_string(),
495                title_summary(&format!("removed {key} metadata"), task_title),
496                payload_string(payload, "value"),
497                "red".to_string(),
498            )
499        }
500        ("task", op_type::LABEL_ADD) => (
501            "label".to_string(),
502            title_summary("added label", task_title),
503            payload_string(payload, "label"),
504            "green".to_string(),
505        ),
506        ("task", op_type::LABEL_REMOVE) => (
507            "label".to_string(),
508            title_summary("removed label", task_title),
509            payload_string(payload, "label"),
510            "red".to_string(),
511        ),
512        ("task", op_type::NOTE_ADD) => (
513            "note".to_string(),
514            title_summary("added note", task_title),
515            payload_string(payload, "body").map(|body| preview(&body, 96)),
516            "blue".to_string(),
517        ),
518        ("task", op_type::NOTE_EDIT) => (
519            "note".to_string(),
520            title_summary("edited note", task_title),
521            payload_string(payload, "body").map(|body| preview(&body, 96)),
522            "blue".to_string(),
523        ),
524        ("task", op_type::NOTE_DELETE) => (
525            "note".to_string(),
526            title_summary("deleted note", task_title),
527            payload_string(payload, "body").map(|body| preview(&body, 96)),
528            "red".to_string(),
529        ),
530        ("task", op_type::DEPENDENCY_ADD) => (
531            "blocker".to_string(),
532            title_summary("added blocker", task_title),
533            payload_string(payload, "depends_on_task_id"),
534            "pink".to_string(),
535        ),
536        ("task", op_type::DEPENDENCY_REMOVE) => (
537            "blocker".to_string(),
538            title_summary("removed blocker", task_title),
539            payload_string(payload, "depends_on_task_id"),
540            "dim".to_string(),
541        ),
542        ("task", op_type::RELATED_ADD) => (
543            "related link".to_string(),
544            title_summary("added related task", task_title),
545            payload_string(payload, "related_task_id"),
546            "blue".to_string(),
547        ),
548        ("task", op_type::RELATED_REMOVE) => (
549            "related link".to_string(),
550            title_summary("removed related task", task_title),
551            payload_string(payload, "related_task_id"),
552            "dim".to_string(),
553        ),
554        ("task", op_type::EPIC_LINK_ADD) => (
555            "epic".to_string(),
556            title_summary("added to epic", task_title),
557            payload_string(payload, "epic_task_id"),
558            "yellow".to_string(),
559        ),
560        ("task", op_type::EPIC_LINK_REMOVE) => (
561            "epic".to_string(),
562            title_summary("removed from epic", task_title),
563            payload_string(payload, "epic_task_id"),
564            "dim".to_string(),
565        ),
566        ("project", op_type::CREATE_PROJECT) => (
567            "project".to_string(),
568            title_summary("created project", project_name),
569            payload_string(payload, "name"),
570            "green".to_string(),
571        ),
572        ("project", op_type::SET_PROJECT_METADATA) => (
573            "project".to_string(),
574            title_summary("updated project", project_name),
575            payload_string(payload, "name"),
576            "blue".to_string(),
577        ),
578        ("project", op_type::PROJECT_DELETE) => (
579            "project".to_string(),
580            title_summary("deleted project", project_name),
581            payload_string(payload, "key"),
582            "red".to_string(),
583        ),
584        ("label", op_type::CREATE_LABEL) => (
585            "label".to_string(),
586            "created label".to_string(),
587            payload_string(payload, "label").or_else(|| payload_string(payload, "name")),
588            "green".to_string(),
589        ),
590        ("label", op_type::SET_LABEL_NAME) => (
591            "label".to_string(),
592            "renamed label".to_string(),
593            payload_string(payload, "new_name"),
594            "blue".to_string(),
595        ),
596        ("label", op_type::LABEL_DELETE) => (
597            "label".to_string(),
598            "deleted label".to_string(),
599            payload_string(payload, "label").or_else(|| payload_string(payload, "name")),
600            "red".to_string(),
601        ),
602        ("label", op_type::LABEL_RESTORE) => (
603            "label".to_string(),
604            "restored label".to_string(),
605            payload_string(payload, "name"),
606            "green".to_string(),
607        ),
608        ("workspace", _) => (
609            "workspace".to_string(),
610            "updated workspace".to_string(),
611            payload_string(payload, "workspace_key"),
612            "blue".to_string(),
613        ),
614        _ => (
615            "change".to_string(),
616            format!("recorded {op_type}"),
617            field.map(str::to_string),
618            "dim".to_string(),
619        ),
620    }
621}
622
623fn resolved_field_action_text(
624    field: Option<&str>,
625    payload: &Value,
626    task_title: Option<&str>,
627) -> (String, String, Option<String>, String) {
628    let value = payload_string(payload, "project_key").or_else(|| payload_string(payload, "value"));
629    let field_label = match field.unwrap_or_default() {
630        "available_at" => "availability",
631        "due_on" => "due date",
632        "is_epic" => "epic designation",
633        "" => "field",
634        other => other,
635    };
636    let summary = value
637        .as_deref()
638        .filter(|value| !value.is_empty())
639        .map(|value| format!("resolved {field_label} conflict to {value}"))
640        .unwrap_or_else(|| format!("resolved {field_label} conflict"));
641    (
642        "conflict".to_string(),
643        title_summary(&summary, task_title),
644        value.filter(|value| !value.is_empty()),
645        "blue".to_string(),
646    )
647}
648
649fn field_action_text(
650    field: Option<&str>,
651    payload: &Value,
652    task_title: Option<&str>,
653) -> (String, String, Option<String>, String) {
654    let value = payload_string(payload, "value");
655    match field.unwrap_or_default() {
656        "title" => (
657            "title".to_string(),
658            "renamed task".to_string(),
659            value,
660            "blue".to_string(),
661        ),
662        "description" => {
663            let summary = if value.as_deref().is_some_and(str::is_empty) {
664                "cleared description"
665            } else {
666                "edited description"
667            };
668            (
669                "details".to_string(),
670                title_summary(summary, task_title),
671                value.map(|description| preview(&description, 96)),
672                "blue".to_string(),
673            )
674        }
675        "status" => (
676            "status".to_string(),
677            title_summary(
678                &value
679                    .as_deref()
680                    .map(|status| format!("changed status to {status}"))
681                    .unwrap_or_else(|| "changed status".to_string()),
682                task_title,
683            ),
684            value,
685            "green".to_string(),
686        ),
687        "priority" => (
688            "priority".to_string(),
689            title_summary(
690                &value
691                    .as_deref()
692                    .map(|priority| format!("changed priority to {priority}"))
693                    .unwrap_or_else(|| "changed priority".to_string()),
694                task_title,
695            ),
696            value,
697            "yellow".to_string(),
698        ),
699        "project" => (
700            "project".to_string(),
701            title_summary("moved task", task_title),
702            payload_string(payload, "project_key").or(value),
703            "pink".to_string(),
704        ),
705        "deleted" => {
706            let deleted = value.as_deref() == Some("1") || value.as_deref() == Some("true");
707            (
708                "delete".to_string(),
709                title_summary(
710                    if deleted {
711                        "deleted task"
712                    } else {
713                        "restored task"
714                    },
715                    task_title,
716                ),
717                None,
718                if deleted { "red" } else { "green" }.to_string(),
719            )
720        }
721        "available_at" => {
722            let summary = if value.as_deref().is_none_or(str::is_empty) {
723                "cleared availability"
724            } else {
725                "changed availability"
726            };
727            (
728                "availability".to_string(),
729                title_summary(summary, task_title),
730                value.filter(|value| !value.is_empty()),
731                "blue".to_string(),
732            )
733        }
734        "due_on" => {
735            let summary = if value.as_deref().is_none_or(str::is_empty) {
736                "cleared due date"
737            } else {
738                "changed due date"
739            };
740            (
741                "due date".to_string(),
742                title_summary(summary, task_title),
743                value.filter(|value| !value.is_empty()),
744                "blue".to_string(),
745            )
746        }
747        "is_epic" => {
748            let marked = value.as_deref() == Some("1");
749            (
750                "epic".to_string(),
751                title_summary(
752                    if marked {
753                        "marked as epic"
754                    } else {
755                        "removed epic designation"
756                    },
757                    task_title,
758                ),
759                None,
760                "yellow".to_string(),
761            )
762        }
763        other => (
764            "field".to_string(),
765            title_summary(&format!("changed {}", other.replace('_', " ")), task_title),
766            value,
767            "blue".to_string(),
768        ),
769    }
770}
771
772fn title_summary(prefix: &str, title: Option<&str>) -> String {
773    title
774        .filter(|title| !title.is_empty())
775        .map(|title| format!("{prefix}: {title}"))
776        .unwrap_or_else(|| prefix.to_string())
777}
778
779fn payload_string(payload: &Value, key: &str) -> Option<String> {
780    let value = payload.get(key)?;
781    if let Some(text) = value.as_str() {
782        return Some(text.to_string());
783    }
784    if value.is_null() {
785        None
786    } else {
787        Some(value.to_string())
788    }
789}
790
791fn preview(value: &str, limit: usize) -> String {
792    let trimmed = value.split_whitespace().collect::<Vec<_>>().join(" ");
793    if trimmed.chars().count() <= limit {
794        return trimmed;
795    }
796    let mut out = trimmed
797        .chars()
798        .take(limit.saturating_sub(1))
799        .collect::<String>();
800    out.push('…');
801    out
802}
803
804#[cfg(test)]
805mod tests {
806    use serde_json::json;
807
808    use super::*;
809
810    #[tokio::test]
811    async fn recent_actions_limit_is_applied_to_ordered_visible_changes() {
812        let (_temp, mut conn) = crate::test_support::test_conn().await;
813        let workspace = crate::test_support::ensure_default_workspace(&mut conn)
814            .await
815            .unwrap();
816        let project = crate::test_support::resolve_or_create_project_in_workspace(
817            &mut conn,
818            &workspace.id,
819            "App",
820        )
821        .await
822        .unwrap();
823        let task = crate::test_support::create_task(
824            &mut conn,
825            &workspace,
826            crate::operations::TaskDraft {
827                title: "recent actions task".to_string(),
828                description: String::new(),
829                project: Some(project.key),
830                status: "todo".to_string(),
831                priority: "none".to_string(),
832                source: crate::choices::TaskSource::Tui,
833                labels: Vec::new(),
834                metadata: Vec::new(),
835                available_at: None,
836                due_on: None,
837                is_epic: false,
838            },
839        )
840        .await
841        .unwrap();
842
843        for index in 0..RECENT_ACTION_LIMIT {
844            crate::db::insert_change(
845                &mut conn,
846                "task",
847                &task.task.id,
848                Some("title"),
849                op_type::SET_FIELD,
850                json!({
851                    "workspace_id": workspace.id,
852                    "value": format!("title {index}")
853                }),
854                None,
855            )
856            .await
857            .unwrap();
858        }
859        let suppressed_change_id = crate::db::insert_change(
860            &mut conn,
861            "task",
862            &task.task.id,
863            Some("title"),
864            op_type::SET_FIELD,
865            json!({
866                "workspace_id": workspace.id,
867                "value": "suppressed"
868            }),
869            None,
870        )
871        .await
872        .unwrap();
873        crate::db::insert_change(
874            &mut conn,
875            "recurrence_series",
876            "series",
877            None,
878            op_type::RESOLVE_RECURRENCE_OCCURRENCE,
879            json!({
880                "workspace_id": workspace.id,
881                "task_status_change_id": suppressed_change_id
882            }),
883            None,
884        )
885        .await
886        .unwrap();
887
888        let actions = list_recent_actions_in_workspace(&mut conn, &workspace.id, None)
889            .await
890            .unwrap();
891
892        assert_eq!(actions.len(), RECENT_ACTION_LIMIT as usize);
893        assert!(
894            actions
895                .iter()
896                .any(|item| item.detail.as_deref() == Some("title 1"))
897        );
898        assert!(
899            !actions
900                .iter()
901                .any(|item| item.detail.as_deref() == Some("title 0"))
902        );
903        assert!(
904            !actions
905                .iter()
906                .any(|item| item.detail.as_deref() == Some("suppressed"))
907        );
908    }
909
910    #[tokio::test]
911    async fn task_activity_is_scoped_and_bounded_per_task() {
912        let (_temp, mut conn) = crate::test_support::test_conn().await;
913        let workspace = crate::test_support::ensure_default_workspace(&mut conn)
914            .await
915            .unwrap();
916        let project = crate::test_support::resolve_or_create_project_in_workspace(
917            &mut conn,
918            &workspace.id,
919            "App",
920        )
921        .await
922        .unwrap();
923        let first = crate::test_support::create_task(
924            &mut conn,
925            &workspace,
926            crate::operations::TaskDraft {
927                title: "first task".to_string(),
928                description: String::new(),
929                project: Some(project.key.clone()),
930                status: "todo".to_string(),
931                priority: "none".to_string(),
932                source: crate::choices::TaskSource::Tui,
933                labels: Vec::new(),
934                metadata: Vec::new(),
935                available_at: None,
936                due_on: None,
937                is_epic: false,
938            },
939        )
940        .await
941        .unwrap();
942        let second = crate::test_support::create_task(
943            &mut conn,
944            &workspace,
945            crate::operations::TaskDraft {
946                title: "second task".to_string(),
947                description: String::new(),
948                project: Some(project.key),
949                status: "todo".to_string(),
950                priority: "none".to_string(),
951                source: crate::choices::TaskSource::Tui,
952                labels: Vec::new(),
953                metadata: Vec::new(),
954                available_at: None,
955                due_on: None,
956                is_epic: false,
957            },
958        )
959        .await
960        .unwrap();
961        let queue_activity = crate::test_support::update_task(
962            &mut conn,
963            &workspace,
964            &first.task.id,
965            crate::operations::TaskUpdate {
966                priority: Some("low".to_string()),
967                ..crate::operations::TaskUpdate::default()
968            },
969        )
970        .await
971        .unwrap()
972        .task
973        .queue_activity_at;
974        for index in 0..10 {
975            crate::test_support::update_task(
976                &mut conn,
977                &workspace,
978                &first.task.id,
979                crate::operations::TaskUpdate {
980                    title: Some(format!("first task {index}")),
981                    ..crate::operations::TaskUpdate::default()
982                },
983            )
984            .await
985            .unwrap();
986        }
987        let suppressed_change_id = crate::db::insert_change(
988            &mut conn,
989            "task",
990            &first.task.id,
991            Some("title"),
992            op_type::SET_FIELD,
993            json!({
994                "workspace_id": workspace.id,
995                "value": "suppressed"
996            }),
997            None,
998        )
999        .await
1000        .unwrap();
1001        crate::db::insert_change(
1002            &mut conn,
1003            "recurrence_series",
1004            "series",
1005            None,
1006            op_type::RESOLVE_RECURRENCE_OCCURRENCE,
1007            json!({
1008                "workspace_id": workspace.id,
1009                "task_status_change_id": suppressed_change_id
1010            }),
1011            None,
1012        )
1013        .await
1014        .unwrap();
1015        crate::test_support::update_task(
1016            &mut conn,
1017            &workspace,
1018            &first.task.id,
1019            crate::operations::TaskUpdate {
1020                add_labels: vec!["backend".to_string()],
1021                create_missing_labels: true,
1022                ..crate::operations::TaskUpdate::default()
1023            },
1024        )
1025        .await
1026        .unwrap();
1027        crate::test_support::update_task(
1028            &mut conn,
1029            &workspace,
1030            &first.task.id,
1031            crate::operations::TaskUpdate {
1032                set_metadata: vec![crate::metadata::TaskMetadataInput {
1033                    expected_field_id: None,
1034                    key: "customer".to_string(),
1035                    value: "Acme".to_string(),
1036                }],
1037                ..crate::operations::TaskUpdate::default()
1038            },
1039        )
1040        .await
1041        .unwrap();
1042        crate::test_support::update_task(
1043            &mut conn,
1044            &workspace,
1045            &first.task.id,
1046            crate::operations::TaskUpdate {
1047                remove_metadata: vec!["customer".to_string()],
1048                ..crate::operations::TaskUpdate::default()
1049            },
1050        )
1051        .await
1052        .unwrap();
1053
1054        let activity = task_activity_for_tasks_in_workspace(
1055            &mut conn,
1056            &workspace.id,
1057            &[first.task.id.clone(), second.task.id.clone()],
1058        )
1059        .await
1060        .unwrap();
1061
1062        assert_eq!(
1063            activity[&first.task.id].len(),
1064            TASK_ACTIVITY_LIMIT as usize + 1
1065        );
1066        assert_eq!(activity[&second.task.id].len(), 1);
1067        assert!(
1068            activity[&first.task.id]
1069                .iter()
1070                .all(|action| action.entity_id == first.task.id.to_string())
1071        );
1072        assert!(
1073            activity[&first.task.id]
1074                .iter()
1075                .all(|action| action.change_id != suppressed_change_id)
1076        );
1077        assert!(activity[&first.task.id].iter().any(|action| {
1078            action.field.as_deref() == Some("priority") && action.created_at == queue_activity
1079        }));
1080        assert!(activity[&first.task.id].iter().any(|action| {
1081            action.op_type == op_type::LABEL_ADD && action.detail.as_deref() == Some("backend")
1082        }));
1083        assert!(activity[&first.task.id].iter().any(|action| {
1084            action.op_type == op_type::REMOVE_TASK_METADATA
1085                && action.summary.starts_with("removed customer metadata")
1086                && action.detail.as_deref() == Some("Acme")
1087        }));
1088    }
1089
1090    #[test]
1091    fn scalar_activity_summaries_include_the_new_value() {
1092        let status = field_action_text(Some("status"), &json!({"value": "done"}), Some("subject"));
1093        let priority =
1094            field_action_text(Some("priority"), &json!({"value": "high"}), Some("subject"));
1095
1096        assert_eq!(status.1, "changed status to done: subject");
1097        assert_eq!(status.2.as_deref(), Some("done"));
1098        assert_eq!(priority.1, "changed priority to high: subject");
1099        assert_eq!(priority.2.as_deref(), Some("high"));
1100    }
1101
1102    #[test]
1103    fn activity_presentations_retain_distinguishing_values() {
1104        let cases = [
1105            (
1106                op_type::LABEL_ADD,
1107                None,
1108                json!({"label": "backend"}),
1109                "added label: subject",
1110                Some("backend"),
1111            ),
1112            (
1113                op_type::ATTACHMENT_ADD,
1114                Some("attachments"),
1115                json!({"filename": "mockup.png"}),
1116                "added attachment: subject",
1117                Some("mockup.png"),
1118            ),
1119            (
1120                op_type::SET_TASK_METADATA,
1121                Some("metadata:field-id"),
1122                json!({"key": "customer", "value": "Acme"}),
1123                "set customer metadata: subject",
1124                Some("Acme"),
1125            ),
1126            (
1127                op_type::NOTE_DELETE,
1128                Some("notes"),
1129                json!({"body": "Confirmed the refresh race"}),
1130                "deleted note: subject",
1131                Some("Confirmed the refresh race"),
1132            ),
1133        ];
1134
1135        for (operation, field, payload, expected_summary, expected_detail) in cases {
1136            let action = action_text("task", operation, field, &payload, Some("subject"), None);
1137            assert_eq!(action.1, expected_summary);
1138            assert_eq!(action.2.as_deref(), expected_detail);
1139        }
1140
1141        let due = field_action_text(
1142            Some("due_on"),
1143            &json!({"value": "2026-08-28"}),
1144            Some("subject"),
1145        );
1146        assert_eq!(due.1, "changed due date: subject");
1147        assert_eq!(due.2.as_deref(), Some("2026-08-28"));
1148
1149        let epic = field_action_text(Some("is_epic"), &json!({"value": "1"}), Some("subject"));
1150        assert_eq!(epic.1, "marked as epic: subject");
1151        assert_eq!(epic.2, None);
1152
1153        let resolution = action_text(
1154            "task",
1155            op_type::RESOLVE_FIELD,
1156            Some("status"),
1157            &json!({"value": "todo"}),
1158            Some("subject"),
1159            None,
1160        );
1161        assert_eq!(resolution.1, "resolved status conflict to todo: subject");
1162    }
1163
1164    #[test]
1165    fn related_mutations_use_related_link_activity_vocabulary() {
1166        let payload = json!({"related_task_id": "BBBB000000000002"});
1167        let added = action_text(
1168            "task",
1169            op_type::RELATED_ADD,
1170            Some("related"),
1171            &payload,
1172            Some("subject"),
1173            None,
1174        );
1175        assert_eq!(
1176            added,
1177            (
1178                "related link".to_string(),
1179                "added related task: subject".to_string(),
1180                Some("BBBB000000000002".to_string()),
1181                "blue".to_string(),
1182            )
1183        );
1184
1185        let removed = action_text(
1186            "task",
1187            op_type::RELATED_REMOVE,
1188            Some("related"),
1189            &payload,
1190            Some("subject"),
1191            None,
1192        );
1193        assert_eq!(
1194            removed,
1195            (
1196                "related link".to_string(),
1197                "removed related task: subject".to_string(),
1198                Some("BBBB000000000002".to_string()),
1199                "dim".to_string(),
1200            )
1201        );
1202    }
1203}