views.rs

  1use askama::Template;
  2
  3use super::helpers::SortField;
  4
  5/// A project card on the root page — either healthy or failed.
  6pub(super) enum ProjectCard {
  7    Ok {
  8        name: String,
  9        open: usize,
 10        in_progress: usize,
 11        closed: usize,
 12        total: usize,
 13    },
 14    Err {
 15        name: String,
 16        error: String,
 17    },
 18}
 19
 20/// Minimal view-model for a scored task in the "Next Up" table.
 21pub(super) struct ScoredEntry {
 22    pub(super) id: String,
 23    pub(super) short_id: String,
 24    pub(super) title: String,
 25    pub(super) score: String,
 26    pub(super) status: String,
 27    pub(super) status_display: &'static str,
 28}
 29
 30/// Minimal view-model for a task row in the project task table.
 31pub(super) struct TaskRow {
 32    pub(super) full_id: String,
 33    pub(super) short_id: String,
 34    pub(super) status: String,
 35    pub(super) status_display: &'static str,
 36    pub(super) priority: String,
 37    pub(super) effort: String,
 38    pub(super) title: String,
 39    pub(super) created_at: String,
 40    pub(super) created_at_display: String,
 41}
 42
 43/// View-model for the task detail page.
 44pub(super) struct TaskView {
 45    pub(super) full_id: String,
 46    pub(super) short_id: String,
 47    pub(super) title: String,
 48    pub(super) description: String,
 49    pub(super) task_type: String,
 50    pub(super) status: String,
 51    pub(super) priority: String,
 52    pub(super) effort: String,
 53    pub(super) created_at: String,
 54    pub(super) created_at_display: String,
 55    pub(super) updated_at: String,
 56    pub(super) updated_at_display: String,
 57    pub(super) labels: Vec<String>,
 58    pub(super) logs: Vec<LogView>,
 59}
 60
 61pub(super) struct LogView {
 62    pub(super) timestamp: String,
 63    pub(super) timestamp_display: String,
 64    pub(super) message: String,
 65}
 66
 67/// A blocker reference for the task detail page.
 68pub(super) struct BlockerRef {
 69    pub(super) full_id: String,
 70    pub(super) short_id: String,
 71}
 72
 73/// Sort context passed to the task_table macro. When present, column headers
 74/// become clickable links that set sort/order query params.
 75pub(super) struct SortContext {
 76    /// Base URL for sort links (e.g. `/projects/myproj`).
 77    pub(super) base_href: String,
 78    /// Current sort field.
 79    pub(super) field: String,
 80    /// Current sort order ("asc" or "desc").
 81    pub(super) order: String,
 82    /// Query string fragment for the current filters (without sort/order/page),
 83    /// suitable for appending to hrefs.
 84    pub(super) filter_qs: String,
 85}
 86
 87impl SortContext {
 88    /// Build the href for a column header link. Clicking the currently-active
 89    /// column toggles direction; clicking a different column uses its default.
 90    fn column_href(&self, col: &str) -> String {
 91        let order = if col == self.field {
 92            // Toggle current direction.
 93            match self.order.as_str() {
 94                "asc" => "desc",
 95                _ => "asc",
 96            }
 97        } else {
 98            // Use the column's sensible default.
 99            SortField::parse(col)
100                .map(|f| f.default_order().as_str())
101                .unwrap_or("asc")
102        };
103        let mut qs = self.filter_qs.clone();
104        if !qs.is_empty() {
105            qs.push('&');
106        }
107        qs.push_str(&format!("sort={col}&order={order}"));
108        format!("{}?{qs}", self.base_href)
109    }
110
111    /// Return the arrow indicator for the active column, or empty string.
112    fn arrow(&self, col: &str) -> &str {
113        if col == self.field {
114            match self.order.as_str() {
115                "asc" => "",
116                "desc" => "",
117                _ => "",
118            }
119        } else {
120            ""
121        }
122    }
123}
124
125#[derive(Template)]
126#[template(path = "index.html")]
127pub(super) struct IndexTemplate {
128    pub(super) all_projects: Vec<String>,
129    pub(super) active_project: Option<String>,
130    pub(super) projects: Vec<ProjectCard>,
131}
132
133#[derive(Template)]
134#[template(path = "project.html")]
135pub(super) struct ProjectTemplate {
136    pub(super) all_projects: Vec<String>,
137    pub(super) active_project: Option<String>,
138    pub(super) project_name: String,
139    pub(super) stats_open: usize,
140    pub(super) stats_in_progress: usize,
141    pub(super) stats_closed: usize,
142    pub(super) next_up: Vec<ScoredEntry>,
143    pub(super) page_tasks: Vec<TaskRow>,
144    pub(super) all_labels: Vec<String>,
145    pub(super) filter_status: Option<String>,
146    pub(super) filter_priority: Option<String>,
147    pub(super) filter_effort: Option<String>,
148    pub(super) filter_label: Option<String>,
149    pub(super) filter_search: String,
150    pub(super) page: usize,
151    pub(super) total_pages: usize,
152    pub(super) pagination_pages: Vec<usize>,
153    pub(super) sort_ctx: SortContext,
154}
155
156impl ProjectTemplate {
157    /// Build a query-string fragment containing the current filters (no sort,
158    /// no page). Reused by both pagination and sort helpers.
159    fn filter_qs(&self) -> String {
160        let mut parts = Vec::new();
161        if let Some(ref s) = self.filter_status {
162            parts.push(format!("status={s}"));
163        }
164        if let Some(ref p) = self.filter_priority {
165            parts.push(format!("priority={p}"));
166        }
167        if let Some(ref e) = self.filter_effort {
168            parts.push(format!("effort={e}"));
169        }
170        if let Some(ref l) = self.filter_label {
171            parts.push(format!("label={l}"));
172        }
173        if !self.filter_search.is_empty() {
174            parts.push(format!("q={}", self.filter_search));
175        }
176        parts.join("&")
177    }
178
179    /// Build a pagination link preserving current filter and sort params.
180    fn pagination_href(&self, target_page: &usize) -> String {
181        let target_page = *target_page;
182        let mut qs = self.filter_qs();
183        if !qs.is_empty() {
184            qs.push('&');
185        }
186        qs.push_str(&format!(
187            "sort={}&order={}",
188            self.sort_ctx.field, self.sort_ctx.order
189        ));
190        qs.push_str(&format!("&page={target_page}"));
191        format!("/projects/{}?{qs}", self.project_name)
192    }
193}
194
195#[derive(Template)]
196#[template(path = "task.html")]
197pub(super) struct TaskTemplate {
198    pub(super) all_projects: Vec<String>,
199    pub(super) active_project: Option<String>,
200    pub(super) project_name: String,
201    pub(super) task: TaskView,
202    pub(super) blockers_open: Vec<BlockerRef>,
203    pub(super) blockers_resolved: Vec<BlockerRef>,
204    pub(super) subtasks: Vec<TaskRow>,
205}
206
207#[derive(Template)]
208#[template(path = "error.html")]
209pub(super) struct ErrorTemplate {
210    pub(super) all_projects: Vec<String>,
211    pub(super) active_project: Option<String>,
212    pub(super) status_code: u16,
213    pub(super) message: String,
214}