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}