Skip to main content

track/webui/
routes.rs

1//! HTTP route handlers for the WebUI.
2
3use crate::db::Database;
4use crate::models::TodoStatus;
5use crate::models::{
6    AgentGuardrails, GitAgentContext, JjAgentContext, TodoAgentView, VcsMode, WorkflowContext,
7};
8use crate::services::agent_context::build_agent_extensions;
9use crate::services::{
10    LinkService, RepoService, ScrapService, TaskService, TodoService, WorktreeService,
11};
12use crate::use_cases::ApplyTodoActionUseCase;
13use crate::utils::TrackError;
14use crate::webui::error::WebError;
15use crate::webui::state::{AppState, SseEvent};
16use crate::webui::templates::SharedTemplates;
17use axum::{
18    extract::{Path, State},
19    response::Html,
20    Form, Json,
21};
22use serde::{Deserialize, Serialize};
23
24/// Extended application state with templates
25#[derive(Clone)]
26pub struct WebState {
27    pub app: AppState,
28    pub templates: SharedTemplates,
29}
30
31/// Error response wrapper
32pub type AppError = WebError;
33
34/// Status response for JSON API
35#[derive(Serialize)]
36pub struct StatusResponse {
37    pub task: Option<serde_json::Value>,
38    pub todos: Vec<serde_json::Value>,
39    pub links: Vec<serde_json::Value>,
40    pub scraps: Vec<serde_json::Value>,
41    pub worktrees: Vec<serde_json::Value>,
42    pub repos: Vec<serde_json::Value>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub workflow: Option<WorkflowContext>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub vcs_mode: Option<VcsMode>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub jj: Option<JjAgentContext>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub git: Option<GitAgentContext>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub todos_agent: Option<Vec<TodoAgentView>>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub guardrails: Option<AgentGuardrails>,
55}
56
57/// Form data for adding a todo
58#[derive(Deserialize)]
59pub struct AddTodoForm {
60    pub content: String,
61    #[serde(default)]
62    pub create_worktree: bool,
63}
64
65/// Form data for adding a scrap
66#[derive(Deserialize)]
67pub struct AddScrapForm {
68    pub content: String,
69}
70
71/// Form data for updating description
72#[derive(Deserialize)]
73pub struct UpdateDescriptionForm {
74    pub description: String,
75}
76
77/// Form data for updating ticket
78#[derive(Deserialize)]
79pub struct UpdateTicketForm {
80    pub ticket_id: String,
81    pub ticket_url: Option<String>,
82}
83
84/// Form data for adding a link
85#[derive(Deserialize)]
86pub struct AddLinkForm {
87    pub url: String,
88    pub title: Option<String>,
89}
90
91/// Main dashboard page
92pub async fn index(State(state): State<WebState>) -> Result<Html<String>, AppError> {
93    let db = state.app.db.lock().await;
94
95    let current_task_id = match db.get_current_task_id()? {
96        Some(id) => id,
97        None => {
98            let html = state.templates.render(
99                "index.html",
100                serde_json::json!({
101                    "task": null,
102                    "todos": [],
103                    "scraps": [],
104                    "links": [],
105                    "repos": [],
106                    "worktrees": [],
107                }),
108            )?;
109            return Ok(Html(html));
110        }
111    };
112
113    let context = build_status_context(&db, current_task_id)?;
114    let html = state.templates.render("index.html", context)?;
115    Ok(Html(html))
116}
117
118/// JSON API endpoint for status data
119pub async fn api_status(State(state): State<WebState>) -> Result<Json<StatusResponse>, AppError> {
120    let db = state.app.db.lock().await;
121
122    let current_task_id = match db.get_current_task_id()? {
123        Some(id) => id,
124        None => {
125            return Ok(Json(StatusResponse {
126                task: None,
127                todos: vec![],
128                links: vec![],
129                scraps: vec![],
130                worktrees: vec![],
131                repos: vec![],
132                workflow: None,
133                vcs_mode: None,
134                jj: None,
135                git: None,
136                todos_agent: None,
137                guardrails: None,
138            }));
139        }
140    };
141
142    let task_service = TaskService::new(&db);
143    let task = task_service.get_task(current_task_id)?;
144
145    let todo_service = TodoService::new(&db);
146    let todos = todo_service.list_todos(current_task_id)?;
147
148    let link_service = LinkService::new(&db);
149    let links = link_service.list_links(current_task_id)?;
150
151    let scrap_service = ScrapService::new(&db);
152    let scraps = scrap_service.list_scraps(current_task_id)?;
153
154    let worktree_service = WorktreeService::new(&db);
155    let worktrees = worktree_service.list_worktrees(current_task_id)?;
156
157    let repo_service = RepoService::new(&db);
158    let repos = repo_service.list_repos(current_task_id)?;
159
160    let vcs_mode = db.get_vcs_mode()?;
161    let agent = build_agent_extensions(
162        vcs_mode,
163        &task,
164        &todos,
165        &worktrees,
166        &repos,
167        &worktree_service,
168    );
169
170    Ok(Json(StatusResponse {
171        task: Some(serde_json::to_value(&task)?),
172        todos: format_todos(todos, &worktrees, &scraps),
173        links: links
174            .iter()
175            .map(|l| serde_json::to_value(l).unwrap_or_default())
176            .collect(),
177        scraps: scraps
178            .iter()
179            .map(|s| serde_json::to_value(s).unwrap_or_default())
180            .collect(),
181        worktrees: worktrees
182            .iter()
183            .map(|w| serde_json::to_value(w).unwrap_or_default())
184            .collect(),
185        repos: repos
186            .iter()
187            .map(|r| serde_json::to_value(r).unwrap_or_default())
188            .collect(),
189        workflow: Some(agent.workflow),
190        vcs_mode: Some(agent.vcs_mode),
191        jj: agent.jj,
192        git: agent.git,
193        todos_agent: Some(agent.todos_agent),
194        guardrails: Some(agent.guardrails),
195    }))
196}
197
198/// Get description card HTML
199pub async fn get_description(State(state): State<WebState>) -> Result<Html<String>, AppError> {
200    let db = state.app.db.lock().await;
201    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
202
203    let task_service = TaskService::new(&db);
204    let task = task_service.get_task(current_task_id)?;
205
206    let html = state.templates.render(
207        "partials/description.html",
208        serde_json::json!({
209            "task": task,
210        }),
211    )?;
212
213    Ok(Html(html))
214}
215
216/// Get ticket card HTML
217pub async fn get_ticket(State(state): State<WebState>) -> Result<Html<String>, AppError> {
218    let db = state.app.db.lock().await;
219    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
220
221    let task_service = TaskService::new(&db);
222    let task = task_service.get_task(current_task_id)?;
223
224    let html = state.templates.render(
225        "partials/ticket.html",
226        serde_json::json!({
227            "task": task,
228        }),
229    )?;
230
231    Ok(Html(html))
232}
233
234/// Get links card HTML
235pub async fn get_links(State(state): State<WebState>) -> Result<Html<String>, AppError> {
236    let db = state.app.db.lock().await;
237    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
238
239    let link_service = LinkService::new(&db);
240    let links = link_service.list_links(current_task_id)?;
241
242    let html = state.templates.render(
243        "partials/links.html",
244        serde_json::json!({
245            "links": links,
246        }),
247    )?;
248
249    Ok(Html(html))
250}
251
252/// Get repos card HTML
253pub async fn get_repos(State(state): State<WebState>) -> Result<Html<String>, AppError> {
254    let db = state.app.db.lock().await;
255    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
256
257    let repo_service = RepoService::new(&db);
258    let repos = repo_service.list_repos(current_task_id)?;
259
260    let html = state.templates.render(
261        "partials/repos.html",
262        serde_json::json!({
263            "repos": repos,
264        }),
265    )?;
266
267    Ok(Html(html))
268}
269
270/// Get todos card HTML
271pub async fn get_todos(State(state): State<WebState>) -> Result<Html<String>, AppError> {
272    let db = state.app.db.lock().await;
273    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
274
275    let todo_service = TodoService::new(&db);
276    let todos = todo_service.list_todos(current_task_id)?;
277
278    let scrap_service = ScrapService::new(&db);
279    let scraps = scrap_service.list_scraps(current_task_id)?;
280
281    let worktree_service = WorktreeService::new(&db);
282    let worktrees = worktree_service.list_worktrees(current_task_id)?;
283
284    let html = state.templates.render(
285        "partials/todo_list.html",
286        serde_json::json!({
287            "todos": format_todos(todos, &worktrees, &scraps),
288        }),
289    )?;
290
291    Ok(Html(html))
292}
293
294/// Get scraps card HTML
295pub async fn get_scraps(State(state): State<WebState>) -> Result<Html<String>, AppError> {
296    let db = state.app.db.lock().await;
297    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
298
299    let scrap_service = ScrapService::new(&db);
300    let scraps = scrap_service.list_scraps(current_task_id)?;
301
302    let html = state.templates.render(
303        "partials/scrap_list.html",
304        serde_json::json!({
305            "scraps": format_scraps(&scraps),
306        }),
307    )?;
308
309    Ok(Html(html))
310}
311
312/// Add a new todo
313pub async fn add_todo(
314    State(state): State<WebState>,
315    Form(form): Form<AddTodoForm>,
316) -> Result<Html<String>, AppError> {
317    let db = state.app.db.lock().await;
318
319    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
320
321    let todo_service = TodoService::new(&db);
322    let _todo = todo_service.add_todo(current_task_id, &form.content, form.create_worktree)?;
323
324    // Broadcast SSE event
325    state.app.broadcast(SseEvent::Todos);
326
327    // Return updated todo list partial
328    let todos = todo_service.list_todos(current_task_id)?;
329    let scrap_service = ScrapService::new(&db);
330    let scraps = scrap_service.list_scraps(current_task_id)?;
331    let worktree_service = WorktreeService::new(&db);
332    let worktrees = worktree_service.list_worktrees(current_task_id)?;
333
334    let html = state.templates.render(
335        "partials/todo_list.html",
336        serde_json::json!({
337            "todos": format_todos(todos, &worktrees, &scraps),
338        }),
339    )?;
340
341    Ok(Html(html))
342}
343
344/// Update todo status
345pub async fn update_todo_status(
346    State(state): State<WebState>,
347    Path((todo_index, new_status)): Path<(i64, String)>,
348) -> Result<Html<String>, AppError> {
349    let db = state.app.db.lock().await;
350
351    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
352
353    let todo_service = TodoService::new(&db);
354    let todo = todo_service.get_todo_by_index(current_task_id, todo_index)?;
355
356    if new_status.as_str() == TodoStatus::PENDING {
357        todo_service.update_status(todo.id, &new_status)?;
358    } else {
359        let action = crate::models::TodoAction::from_web_route(&new_status)?;
360        ApplyTodoActionUseCase::new(&db).execute(current_task_id, todo_index, action)?;
361    }
362
363    // Broadcast SSE event
364    state.app.broadcast(SseEvent::Todos);
365
366    // Return updated todo list partial
367    let todos = todo_service.list_todos(current_task_id)?;
368    let scrap_service = ScrapService::new(&db);
369    let scraps = scrap_service.list_scraps(current_task_id)?;
370    let worktree_service = WorktreeService::new(&db);
371    let worktrees = worktree_service.list_worktrees(current_task_id)?;
372
373    let html = state.templates.render(
374        "partials/todo_list.html",
375        serde_json::json!({
376            "todos": format_todos(todos, &worktrees, &scraps),
377        }),
378    )?;
379
380    Ok(Html(html))
381}
382
383/// Delete a todo by task-scoped index
384pub async fn delete_todo(
385    State(state): State<WebState>,
386    Path(todo_index): Path<i64>,
387) -> Result<Html<String>, AppError> {
388    let db = state.app.db.lock().await;
389
390    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
391
392    let todo_service = TodoService::new(&db);
393    let todo = todo_service.get_todo_by_index(current_task_id, todo_index)?;
394    todo_service.delete_todo(todo.id)?;
395
396    // Broadcast SSE event
397    state.app.broadcast(SseEvent::Todos);
398
399    // Return updated todo list partial
400    let todos = todo_service.list_todos(current_task_id)?;
401    let scrap_service = ScrapService::new(&db);
402    let scraps = scrap_service.list_scraps(current_task_id)?;
403    let worktree_service = WorktreeService::new(&db);
404    let worktrees = worktree_service.list_worktrees(current_task_id)?;
405
406    let html = state.templates.render(
407        "partials/todo_list.html",
408        serde_json::json!({
409            "todos": format_todos(todos, &worktrees, &scraps),
410        }),
411    )?;
412
413    Ok(Html(html))
414}
415
416/// Move a todo to the front (make it the next todo to work on)
417pub async fn move_todo_to_next(
418    State(state): State<WebState>,
419    Path(todo_index): Path<i64>,
420) -> Result<Html<String>, AppError> {
421    let db = state.app.db.lock().await;
422
423    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
424
425    let todo_service = TodoService::new(&db);
426    todo_service.move_to_next(current_task_id, todo_index)?;
427
428    // Broadcast SSE event
429    state.app.broadcast(SseEvent::Todos);
430
431    // Return updated todo list partial
432    let todos = todo_service.list_todos(current_task_id)?;
433    let scrap_service = ScrapService::new(&db);
434    let scraps = scrap_service.list_scraps(current_task_id)?;
435    let worktree_service = WorktreeService::new(&db);
436    let worktrees = worktree_service.list_worktrees(current_task_id)?;
437
438    let html = state.templates.render(
439        "partials/todo_list.html",
440        serde_json::json!({
441            "todos": format_todos(todos, &worktrees, &scraps),
442        }),
443    )?;
444
445    Ok(Html(html))
446}
447
448/// Add a new scrap
449pub async fn add_scrap(
450    State(state): State<WebState>,
451    Form(form): Form<AddScrapForm>,
452) -> Result<Html<String>, AppError> {
453    let db = state.app.db.lock().await;
454
455    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
456
457    let scrap_service = ScrapService::new(&db);
458    let _scrap = scrap_service.add_scrap(current_task_id, &form.content)?;
459
460    // Broadcast SSE event
461    state.app.broadcast(SseEvent::Scraps);
462
463    // Return updated scrap list partial
464    let scraps = scrap_service.list_scraps(current_task_id)?;
465    let html = state.templates.render(
466        "partials/scrap_list.html",
467        serde_json::json!({
468            "scraps": format_scraps(&scraps),
469        }),
470    )?;
471
472    Ok(Html(html))
473}
474
475/// Update task description
476pub async fn update_description(
477    State(state): State<WebState>,
478    Form(form): Form<UpdateDescriptionForm>,
479) -> Result<Html<String>, AppError> {
480    let db = state.app.db.lock().await;
481
482    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
483
484    let task_service = TaskService::new(&db);
485    task_service.set_description(current_task_id, &form.description)?;
486
487    // Get updated task
488    let task = task_service.get_task(current_task_id)?;
489
490    // Broadcast SSE event
491    state.app.broadcast(SseEvent::Description);
492
493    // Return updated description section
494    let html = state.templates.render(
495        "partials/description.html",
496        serde_json::json!({
497            "task": task,
498        }),
499    )?;
500
501    Ok(Html(html))
502}
503
504/// Build status context for templates
505fn build_status_context(db: &Database, task_id: i64) -> anyhow::Result<serde_json::Value> {
506    let task_service = TaskService::new(db);
507    let task = task_service.get_task(task_id)?;
508
509    let todo_service = TodoService::new(db);
510    let todos = todo_service.list_todos(task_id)?;
511
512    let link_service = LinkService::new(db);
513    let links = link_service.list_links(task_id)?;
514
515    let scrap_service = ScrapService::new(db);
516    let scraps = scrap_service.list_scraps(task_id)?;
517
518    let worktree_service = WorktreeService::new(db);
519    let worktrees = worktree_service.list_worktrees(task_id)?;
520
521    let repo_service = RepoService::new(db);
522    let repos = repo_service.list_repos(task_id)?;
523
524    // Calculate base branch
525    let base_branch = if let Some(base_wt) = worktrees.iter().find(|wt| wt.is_base) {
526        base_wt.branch.clone()
527    } else if let Some(ref ticket_id) = task.ticket_id {
528        format!("task/{}", ticket_id)
529    } else {
530        format!("task/task-{}", task.id)
531    };
532
533    // Get calendar ID if configured
534    let calendar_id = db.get_app_state("calendar_id").ok().flatten();
535
536    Ok(serde_json::json!({
537        "task": task,
538        "todos": format_todos(todos, &worktrees, &scraps),
539        "links": links,
540        "scraps": format_scraps(&scraps),
541        "worktrees": worktrees,
542        "repos": repos,
543        "base_branch": base_branch,
544        "calendar_id": calendar_id,
545    }))
546}
547
548/// Format todos with worktree information and hidden fields
549fn format_todos(
550    todos: Vec<crate::models::Todo>,
551    worktrees: &[crate::models::Worktree],
552    scraps: &[crate::models::Scrap],
553) -> Vec<serde_json::Value> {
554    // Find the oldest pending todo (lowest task_index among pending todos)
555    let oldest_pending_id = todos
556        .iter()
557        .filter(|t| t.status == TodoStatus::Pending)
558        .min_by_key(|t| t.task_index)
559        .map(|t| t.id);
560
561    todos
562        .into_iter()
563        .map(|todo| {
564            let todo_worktrees: Vec<String> = worktrees
565                .iter()
566                .filter(|wt| wt.todo_id == Some(todo.id))
567                .map(|wt| wt.path.clone())
568                .collect();
569
570            // Count scraps associated with this todo
571            let scrap_count = scraps
572                .iter()
573                .filter(|s| s.active_todo_id == Some(todo.task_index))
574                .count();
575
576            // Check if this is the oldest pending todo (in progress)
577            let is_in_progress = oldest_pending_id == Some(todo.id);
578
579            let mut value = serde_json::to_value(&todo).unwrap_or_default();
580
581            if let Some(obj) = value.as_object_mut() {
582                // Reinject skipped fields that are needed for UI
583                obj.insert(
584                    "worktree_requested".to_string(),
585                    serde_json::Value::Bool(todo.worktree_requested),
586                );
587                obj.insert(
588                    "worktree_paths".to_string(),
589                    serde_json::json!(todo_worktrees),
590                );
591                obj.insert(
592                    "content_html".to_string(),
593                    serde_json::Value::String(todo.content_html()),
594                );
595                obj.insert(
596                    "has_scraps".to_string(),
597                    serde_json::Value::Bool(scrap_count > 0),
598                );
599                obj.insert(
600                    "is_in_progress".to_string(),
601                    serde_json::Value::Bool(is_in_progress),
602                );
603            }
604            value
605        })
606        .collect()
607}
608
609/// Format scraps with human-readable timestamps
610fn format_scraps(scraps: &[crate::models::Scrap]) -> Vec<serde_json::Value> {
611    use chrono::Local;
612
613    scraps
614        .iter()
615        .map(|scrap| {
616            let formatted_time = scrap
617                .created_at
618                .with_timezone(&Local)
619                .format("%Y-%m-%d %H:%M:%S")
620                .to_string();
621
622            serde_json::json!({
623                "scrap_id": scrap.scrap_id,
624                "content": scrap.content,
625                "content_html": scrap.content_html(),
626                "created_at": formatted_time,
627                "active_todo_id": scrap.active_todo_id,
628            })
629        })
630        .collect()
631}
632
633/// Update task ticket
634pub async fn update_ticket(
635    State(state): State<WebState>,
636    Form(form): Form<UpdateTicketForm>,
637) -> Result<Html<String>, AppError> {
638    let db = state.app.db.lock().await;
639
640    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
641
642    let task_service = TaskService::new(&db);
643
644    // Clean up ticket_url if empty
645    let ticket_url = form.ticket_url.filter(|url| !url.trim().is_empty());
646    let ticket_url_str = ticket_url.as_deref().unwrap_or("");
647
648    task_service.link_ticket(current_task_id, &form.ticket_id, ticket_url_str)?;
649
650    // Get updated task
651    let task = task_service.get_task(current_task_id)?;
652
653    // Broadcast SSE event
654    state.app.broadcast(SseEvent::Ticket);
655
656    // Return updated ticket section
657    let html = state.templates.render(
658        "partials/ticket.html",
659        serde_json::json!({
660            "task": task,
661        }),
662    )?;
663
664    Ok(Html(html))
665}
666
667/// Add a new link
668pub async fn add_link(
669    State(state): State<WebState>,
670    Form(form): Form<AddLinkForm>,
671) -> Result<Html<String>, AppError> {
672    let db = state.app.db.lock().await;
673
674    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
675
676    let link_service = LinkService::new(&db);
677
678    // Clean up title if empty
679    let title = form.title.filter(|t| !t.trim().is_empty());
680
681    link_service.add_link(current_task_id, &form.url, title.as_deref())?;
682
683    // Broadcast SSE event
684    state.app.broadcast(SseEvent::Links);
685
686    // Return updated links list partial
687    let links = link_service.list_links(current_task_id)?;
688    let html = state.templates.render(
689        "partials/links.html",
690        serde_json::json!({
691            "links": links,
692        }),
693    )?;
694
695    Ok(Html(html))
696}
697
698/// Delete a link by task-scoped index
699pub async fn delete_link(
700    State(state): State<WebState>,
701    Path(link_index): Path<i64>,
702) -> Result<Html<String>, AppError> {
703    let db = state.app.db.lock().await;
704
705    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
706
707    let link_service = LinkService::new(&db);
708    let links = link_service.list_links(current_task_id)?;
709
710    // Find link by task_index
711    let link = links
712        .iter()
713        .find(|l| l.task_index == link_index)
714        .ok_or_else(|| TrackError::Other(format!("Link #{} not found", link_index)))?;
715
716    // Delete link via service
717    link_service.delete_link(link.id)?;
718
719    // Broadcast SSE event
720    state.app.broadcast(SseEvent::Links);
721
722    // Return updated links list partial
723    let links = link_service.list_links(current_task_id)?;
724    let html = state.templates.render(
725        "partials/links.html",
726        serde_json::json!({
727            "links": links,
728        }),
729    )?;
730
731    Ok(Html(html))
732}