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 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}