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