Skip to main content

track/webui/
view.rs

1//! WebUI presentation: template context and JSON API responses.
2
3use crate::db::Database;
4use crate::models::TodoStatus;
5use crate::models::{
6    AgentGuardrails, GitAgentContext, JjAgentContext, Scrap, Todo, TodoAgentView, VcsMode,
7    WorkflowContext, Worktree,
8};
9use crate::services::agent_context::{build_agent_extensions, AgentStatusExtensions};
10use crate::services::WorktreeService;
11use crate::use_cases::{GetTaskInfoUseCase, TaskInfoSnapshot};
12use crate::utils::{Result, TrackError};
13use serde::Serialize;
14
15/// JSON API payload for `/api/status`.
16#[derive(Serialize)]
17pub struct StatusResponse {
18    pub task: Option<serde_json::Value>,
19    pub todos: Vec<serde_json::Value>,
20    pub links: Vec<serde_json::Value>,
21    pub scraps: Vec<serde_json::Value>,
22    pub worktrees: Vec<serde_json::Value>,
23    pub repos: Vec<serde_json::Value>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub workflow: Option<WorkflowContext>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub vcs_mode: Option<VcsMode>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub jj: Option<JjAgentContext>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub git: Option<GitAgentContext>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub todos_agent: Option<Vec<TodoAgentView>>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub guardrails: Option<AgentGuardrails>,
36}
37
38impl StatusResponse {
39    pub fn empty() -> Self {
40        Self {
41            task: None,
42            todos: vec![],
43            links: vec![],
44            scraps: vec![],
45            worktrees: vec![],
46            repos: vec![],
47            workflow: None,
48            vcs_mode: None,
49            jj: None,
50            git: None,
51            todos_agent: None,
52            guardrails: None,
53        }
54    }
55}
56
57/// Build Minijinja context for the dashboard and status cards.
58pub fn build_template_context(db: &Database, task_id: i64) -> Result<serde_json::Value> {
59    let info = GetTaskInfoUseCase::new(db);
60    let snapshot = info.load(task_id)?;
61    let calendar_id = db.get_app_state("calendar_id").ok().flatten();
62    to_template_context(db, &snapshot, calendar_id)
63}
64
65/// Build `/api/status` response for the active task.
66pub fn build_api_status(db: &Database, snapshot: &TaskInfoSnapshot) -> Result<StatusResponse> {
67    let agent = agent_extensions(db, snapshot)?;
68    let todos = format_todos(&snapshot.todos, &snapshot.worktrees, &snapshot.scraps)?;
69
70    Ok(StatusResponse {
71        task: Some(to_json(&snapshot.task)?),
72        todos,
73        links: snapshot
74            .links
75            .iter()
76            .map(to_json)
77            .collect::<Result<Vec<_>>>()?,
78        scraps: snapshot
79            .scraps
80            .iter()
81            .map(to_json)
82            .collect::<Result<Vec<_>>>()?,
83        worktrees: snapshot
84            .worktrees
85            .iter()
86            .map(to_json)
87            .collect::<Result<Vec<_>>>()?,
88        repos: snapshot
89            .repos
90            .iter()
91            .map(to_json)
92            .collect::<Result<Vec<_>>>()?,
93        workflow: Some(agent.workflow),
94        vcs_mode: Some(agent.vcs_mode),
95        jj: agent.jj,
96        git: agent.git,
97        todos_agent: Some(agent.todos_agent),
98        guardrails: Some(agent.guardrails),
99    })
100}
101
102fn to_template_context(
103    db: &Database,
104    snapshot: &TaskInfoSnapshot,
105    calendar_id: Option<String>,
106) -> Result<serde_json::Value> {
107    let agent = agent_extensions(db, snapshot)?;
108
109    Ok(serde_json::json!({
110        "task": snapshot.task,
111        "todos": format_todos(&snapshot.todos, &snapshot.worktrees, &snapshot.scraps)?,
112        "links": snapshot.links,
113        "scraps": format_scraps(&snapshot.scraps),
114        "worktrees": snapshot.worktrees,
115        "repos": snapshot.repos,
116        "base_branch": GetTaskInfoUseCase::base_bookmark(snapshot),
117        "calendar_id": calendar_id,
118        "workflow": agent.workflow,
119        "vcs_mode": agent.vcs_mode,
120        "jj": agent.jj,
121        "git": agent.git,
122        "guardrails": agent.guardrails,
123    }))
124}
125
126fn agent_extensions(db: &Database, snapshot: &TaskInfoSnapshot) -> Result<AgentStatusExtensions> {
127    let worktree_service = WorktreeService::new(db);
128    Ok(build_agent_extensions(
129        snapshot.vcs_mode,
130        &snapshot.task,
131        &snapshot.todos,
132        &snapshot.worktrees,
133        &snapshot.repos,
134        &worktree_service,
135    ))
136}
137
138fn to_json<T: serde::Serialize>(value: &T) -> Result<serde_json::Value> {
139    serde_json::to_value(value).map_err(|err| TrackError::SerializationFailed(err.to_string()))
140}
141
142/// Format todos with worktree information and template-only fields.
143pub fn format_todos(
144    todos: &[Todo],
145    worktrees: &[Worktree],
146    scraps: &[Scrap],
147) -> Result<Vec<serde_json::Value>> {
148    let oldest_pending_id = todos
149        .iter()
150        .filter(|todo| todo.status == TodoStatus::Pending)
151        .min_by_key(|todo| todo.task_index)
152        .map(|todo| todo.id);
153
154    todos
155        .iter()
156        .map(|todo| {
157            let todo_worktrees: Vec<String> = worktrees
158                .iter()
159                .filter(|worktree| worktree.todo_id == Some(todo.id))
160                .map(|worktree| worktree.path.clone())
161                .collect();
162
163            let scrap_count = scraps
164                .iter()
165                .filter(|scrap| scrap.active_todo_id == Some(todo.task_index))
166                .count();
167
168            let is_in_progress = oldest_pending_id == Some(todo.id);
169            let mut value = to_json(todo)?;
170
171            if let Some(obj) = value.as_object_mut() {
172                obj.insert(
173                    "worktree_requested".to_string(),
174                    serde_json::Value::Bool(todo.worktree_requested),
175                );
176                obj.insert(
177                    "requires_workspace".to_string(),
178                    serde_json::Value::Bool(todo.requires_workspace),
179                );
180                obj.insert(
181                    "worktree_paths".to_string(),
182                    serde_json::json!(todo_worktrees),
183                );
184                obj.insert(
185                    "content_html".to_string(),
186                    serde_json::Value::String(todo.content_html()),
187                );
188                obj.insert(
189                    "has_scraps".to_string(),
190                    serde_json::Value::Bool(scrap_count > 0),
191                );
192                obj.insert(
193                    "is_in_progress".to_string(),
194                    serde_json::Value::Bool(is_in_progress),
195                );
196            }
197
198            Ok(value)
199        })
200        .collect()
201}
202
203/// Format scraps with human-readable timestamps for templates.
204pub fn format_scraps(scraps: &[Scrap]) -> Vec<serde_json::Value> {
205    use chrono::Local;
206
207    scraps
208        .iter()
209        .map(|scrap| {
210            let formatted_time = scrap
211                .created_at
212                .with_timezone(&Local)
213                .format("%Y-%m-%d %H:%M:%S")
214                .to_string();
215
216            serde_json::json!({
217                "scrap_id": scrap.scrap_id,
218                "content": scrap.content,
219                "content_html": scrap.content_html(),
220                "created_at": formatted_time,
221                "active_todo_id": scrap.active_todo_id,
222            })
223        })
224        .collect()
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::services::{TaskService, TodoService};
231
232    #[test]
233    fn template_context_includes_formatted_todos() {
234        let db = Database::new_in_memory().unwrap();
235        let task = TaskService::new(&db)
236            .create_task("Web", None, None, None)
237            .unwrap();
238        TodoService::new(&db)
239            .add_todo(task.id, "Item", false)
240            .unwrap();
241
242        let context = build_template_context(&db, task.id).unwrap();
243        assert_eq!(context["task"]["name"], "Web");
244        assert_eq!(
245            context["todos"].as_array().map(|items| items.len()),
246            Some(1)
247        );
248        assert!(context["todos"][0]["content_html"].is_string());
249    }
250}