Skip to main content

track/webui/routes/
mod.rs

1//! HTTP route handlers for the WebUI.
2
3use crate::models::TodoStatus;
4use crate::services::{LinkService, RepoService, ScrapService, TaskService, TodoService};
5use crate::use_cases::{ApplyTodoActionUseCase, GetTaskInfoUseCase};
6use crate::utils::TrackError;
7use crate::webui::error::WebError;
8use crate::webui::state::{AppState, SseEvent};
9use crate::webui::templates::SharedTemplates;
10use crate::webui::view::{self, format_scraps, format_todos, StatusResponse};
11use axum::{
12    extract::{Path, State},
13    response::Html,
14    Form, Json,
15};
16use serde::Deserialize;
17
18/// Extended application state with templates
19#[derive(Clone)]
20pub struct WebState {
21    pub app: AppState,
22    pub templates: SharedTemplates,
23}
24
25/// Error response wrapper
26pub type AppError = WebError;
27
28/// Form data for adding a todo
29#[derive(Deserialize)]
30pub struct AddTodoForm {
31    pub content: String,
32    #[serde(default)]
33    pub create_worktree: bool,
34    #[serde(default)]
35    pub no_workspace: bool,
36}
37
38/// Form data for adding a scrap
39#[derive(Deserialize)]
40pub struct AddScrapForm {
41    pub content: String,
42}
43
44/// Form data for updating description
45#[derive(Deserialize)]
46pub struct UpdateDescriptionForm {
47    pub description: String,
48}
49
50/// Form data for updating ticket
51#[derive(Deserialize)]
52pub struct UpdateTicketForm {
53    pub ticket_id: String,
54    pub ticket_url: Option<String>,
55}
56
57/// Form data for adding a link
58#[derive(Deserialize)]
59pub struct AddLinkForm {
60    pub url: String,
61    pub title: Option<String>,
62}
63
64fn render_todo_list_html(
65    templates: &crate::webui::templates::Templates,
66    db: &crate::db::Database,
67    task_id: i64,
68) -> Result<String, AppError> {
69    let snapshot = GetTaskInfoUseCase::new(db).load(task_id)?;
70    let todos = format_todos(&snapshot.todos, &snapshot.worktrees, &snapshot.scraps)?;
71    Ok(templates.render(
72        "partials/todo_list.html",
73        serde_json::json!({ "todos": todos }),
74    )?)
75}
76
77/// Main dashboard page
78pub async fn index(State(state): State<WebState>) -> Result<Html<String>, AppError> {
79    let db = state.app.db.lock().await;
80
81    let current_task_id = match db.get_current_task_id()? {
82        Some(id) => id,
83        None => {
84            let html = state.templates.render(
85                "index.html",
86                serde_json::json!({
87                    "task": null,
88                    "todos": [],
89                    "scraps": [],
90                    "links": [],
91                    "repos": [],
92                    "worktrees": [],
93                }),
94            )?;
95            return Ok(Html(html));
96        }
97    };
98
99    let context = view::build_template_context(&db, current_task_id)?;
100    let html = state.templates.render("index.html", context)?;
101    Ok(Html(html))
102}
103
104/// JSON API endpoint for status data
105pub async fn api_status(State(state): State<WebState>) -> Result<Json<StatusResponse>, AppError> {
106    let db = state.app.db.lock().await;
107
108    let current_task_id = match db.get_current_task_id()? {
109        Some(id) => id,
110        None => return Ok(Json(StatusResponse::empty())),
111    };
112
113    let info = GetTaskInfoUseCase::new(&db);
114    let snapshot = info.load(current_task_id)?;
115    let response = view::build_api_status(&db, &snapshot)?;
116
117    Ok(Json(response))
118}
119
120/// Get description card HTML
121pub async fn get_description(State(state): State<WebState>) -> Result<Html<String>, AppError> {
122    let db = state.app.db.lock().await;
123    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
124
125    let task_service = TaskService::new(&db);
126    let task = task_service.get_task(current_task_id)?;
127
128    let html = state.templates.render(
129        "partials/description.html",
130        serde_json::json!({
131            "task": task,
132        }),
133    )?;
134
135    Ok(Html(html))
136}
137
138/// Get ticket card HTML
139pub async fn get_ticket(State(state): State<WebState>) -> Result<Html<String>, AppError> {
140    let db = state.app.db.lock().await;
141    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
142
143    let task_service = TaskService::new(&db);
144    let task = task_service.get_task(current_task_id)?;
145
146    let html = state.templates.render(
147        "partials/ticket.html",
148        serde_json::json!({
149            "task": task,
150        }),
151    )?;
152
153    Ok(Html(html))
154}
155
156/// Get links card HTML
157pub async fn get_links(State(state): State<WebState>) -> Result<Html<String>, AppError> {
158    let db = state.app.db.lock().await;
159    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
160
161    let link_service = LinkService::new(&db);
162    let links = link_service.list_links(current_task_id)?;
163
164    let html = state.templates.render(
165        "partials/links.html",
166        serde_json::json!({
167            "links": links,
168        }),
169    )?;
170
171    Ok(Html(html))
172}
173
174/// Get repos card HTML
175pub async fn get_repos(State(state): State<WebState>) -> Result<Html<String>, AppError> {
176    let db = state.app.db.lock().await;
177    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
178
179    let repo_service = RepoService::new(&db);
180    let repos = repo_service.list_repos(current_task_id)?;
181
182    let html = state.templates.render(
183        "partials/repos.html",
184        serde_json::json!({
185            "repos": repos,
186        }),
187    )?;
188
189    Ok(Html(html))
190}
191
192/// Get todos card HTML
193pub async fn get_todos(State(state): State<WebState>) -> Result<Html<String>, AppError> {
194    let db = state.app.db.lock().await;
195    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
196    let html = render_todo_list_html(&state.templates, &db, current_task_id)?;
197    Ok(Html(html))
198}
199
200/// Get scraps card HTML
201pub async fn get_scraps(State(state): State<WebState>) -> Result<Html<String>, AppError> {
202    let db = state.app.db.lock().await;
203    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
204
205    let scrap_service = ScrapService::new(&db);
206    let scraps = scrap_service.list_scraps(current_task_id)?;
207
208    let html = state.templates.render(
209        "partials/scrap_list.html",
210        serde_json::json!({
211            "scraps": format_scraps(&scraps),
212        }),
213    )?;
214
215    Ok(Html(html))
216}
217
218/// Add a new todo
219pub async fn add_todo(
220    State(state): State<WebState>,
221    Form(form): Form<AddTodoForm>,
222) -> Result<Html<String>, AppError> {
223    let db = state.app.db.lock().await;
224
225    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
226
227    if form.create_worktree {
228        return Err(TrackError::WorktreeFlagRemoved.into());
229    }
230
231    let todo_service = TodoService::new(&db);
232    let _todo = todo_service.add_todo(
233        current_task_id,
234        &form.content,
235        crate::models::TodoAddOptions::from_flags(false, form.no_workspace),
236    )?;
237
238    // Broadcast SSE event
239    state.app.broadcast(SseEvent::Todos);
240
241    let html = render_todo_list_html(&state.templates, &db, current_task_id)?;
242    Ok(Html(html))
243}
244
245/// Update todo status
246pub async fn update_todo_status(
247    State(state): State<WebState>,
248    Path((todo_index, new_status)): Path<(i64, String)>,
249) -> Result<Html<String>, AppError> {
250    let db = state.app.db.lock().await;
251
252    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
253
254    let todo_service = TodoService::new(&db);
255    let todo = todo_service.get_todo_by_index(current_task_id, todo_index)?;
256
257    if new_status.as_str() == TodoStatus::PENDING {
258        todo_service.update_status(todo.id, &new_status)?;
259    } else {
260        let action = crate::models::TodoAction::from_web_route(&new_status)?;
261        ApplyTodoActionUseCase::new(&db).execute(current_task_id, todo_index, action)?;
262    }
263
264    // Broadcast SSE event
265    state.app.broadcast(SseEvent::Todos);
266
267    let html = render_todo_list_html(&state.templates, &db, current_task_id)?;
268    Ok(Html(html))
269}
270
271/// Delete a todo by task-scoped index
272pub async fn delete_todo(
273    State(state): State<WebState>,
274    Path(todo_index): Path<i64>,
275) -> Result<Html<String>, AppError> {
276    let db = state.app.db.lock().await;
277
278    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
279
280    let todo_service = TodoService::new(&db);
281    let todo = todo_service.get_todo_by_index(current_task_id, todo_index)?;
282    todo_service.delete_todo(todo.id)?;
283
284    // Broadcast SSE event
285    state.app.broadcast(SseEvent::Todos);
286
287    let html = render_todo_list_html(&state.templates, &db, current_task_id)?;
288    Ok(Html(html))
289}
290
291/// Move a todo to the front (make it the next todo to work on)
292pub async fn move_todo_to_next(
293    State(state): State<WebState>,
294    Path(todo_index): Path<i64>,
295) -> Result<Html<String>, AppError> {
296    let db = state.app.db.lock().await;
297
298    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
299
300    let todo_service = TodoService::new(&db);
301    todo_service.move_to_next(current_task_id, todo_index)?;
302
303    // Broadcast SSE event
304    state.app.broadcast(SseEvent::Todos);
305
306    let html = render_todo_list_html(&state.templates, &db, current_task_id)?;
307    Ok(Html(html))
308}
309
310/// Add a new scrap
311pub async fn add_scrap(
312    State(state): State<WebState>,
313    Form(form): Form<AddScrapForm>,
314) -> Result<Html<String>, AppError> {
315    let db = state.app.db.lock().await;
316
317    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
318
319    let scrap_service = ScrapService::new(&db);
320    let _scrap = scrap_service.add_scrap(current_task_id, &form.content)?;
321
322    // Broadcast SSE event
323    state.app.broadcast(SseEvent::Scraps);
324
325    // Return updated scrap list partial
326    let scraps = scrap_service.list_scraps(current_task_id)?;
327    let html = state.templates.render(
328        "partials/scrap_list.html",
329        serde_json::json!({
330            "scraps": format_scraps(&scraps),
331        }),
332    )?;
333
334    Ok(Html(html))
335}
336
337/// Update task description
338pub async fn update_description(
339    State(state): State<WebState>,
340    Form(form): Form<UpdateDescriptionForm>,
341) -> Result<Html<String>, AppError> {
342    let db = state.app.db.lock().await;
343
344    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
345
346    let task_service = TaskService::new(&db);
347    task_service.set_description(current_task_id, &form.description)?;
348
349    // Get updated task
350    let task = task_service.get_task(current_task_id)?;
351
352    // Broadcast SSE event
353    state.app.broadcast(SseEvent::Description);
354
355    // Return updated description section
356    let html = state.templates.render(
357        "partials/description.html",
358        serde_json::json!({
359            "task": task,
360        }),
361    )?;
362
363    Ok(Html(html))
364}
365
366/// Update task ticket
367pub async fn update_ticket(
368    State(state): State<WebState>,
369    Form(form): Form<UpdateTicketForm>,
370) -> Result<Html<String>, AppError> {
371    let db = state.app.db.lock().await;
372
373    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
374
375    let task_service = TaskService::new(&db);
376
377    // Clean up ticket_url if empty
378    let ticket_url = form.ticket_url.filter(|url| !url.trim().is_empty());
379    let ticket_url_str = ticket_url.as_deref().unwrap_or("");
380
381    task_service.link_ticket(current_task_id, &form.ticket_id, ticket_url_str)?;
382
383    // Get updated task
384    let task = task_service.get_task(current_task_id)?;
385
386    // Broadcast SSE event
387    state.app.broadcast(SseEvent::Ticket);
388
389    // Return updated ticket section
390    let html = state.templates.render(
391        "partials/ticket.html",
392        serde_json::json!({
393            "task": task,
394        }),
395    )?;
396
397    Ok(Html(html))
398}
399
400/// Add a new link
401pub async fn add_link(
402    State(state): State<WebState>,
403    Form(form): Form<AddLinkForm>,
404) -> Result<Html<String>, AppError> {
405    let db = state.app.db.lock().await;
406
407    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
408
409    let link_service = LinkService::new(&db);
410
411    // Clean up title if empty
412    let title = form.title.filter(|t| !t.trim().is_empty());
413
414    link_service.add_link(current_task_id, &form.url, title.as_deref())?;
415
416    // Broadcast SSE event
417    state.app.broadcast(SseEvent::Links);
418
419    // Return updated links list partial
420    let links = link_service.list_links(current_task_id)?;
421    let html = state.templates.render(
422        "partials/links.html",
423        serde_json::json!({
424            "links": links,
425        }),
426    )?;
427
428    Ok(Html(html))
429}
430
431/// Delete a link by task-scoped index
432pub async fn delete_link(
433    State(state): State<WebState>,
434    Path(link_index): Path<i64>,
435) -> Result<Html<String>, AppError> {
436    let db = state.app.db.lock().await;
437
438    let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
439
440    let link_service = LinkService::new(&db);
441    let links = link_service.list_links(current_task_id)?;
442
443    // Find link by task_index
444    let link = links
445        .iter()
446        .find(|l| l.task_index == link_index)
447        .ok_or(TrackError::LinkNotFound(link_index))?;
448
449    // Delete link via service
450    link_service.delete_link(link.id)?;
451
452    // Broadcast SSE event
453    state.app.broadcast(SseEvent::Links);
454
455    // Return updated links list partial
456    let links = link_service.list_links(current_task_id)?;
457    let html = state.templates.render(
458        "partials/links.html",
459        serde_json::json!({
460            "links": links,
461        }),
462    )?;
463
464    Ok(Html(html))
465}