views.rs

  1use askama::Template;
  2
  3use super::sorting::SortField;
  4
  5/// Minimal view-model for a scored task in the "Next Up" table.
  6pub(in crate::cmd::webui) struct ScoredEntry {
  7    pub(in crate::cmd::webui) id: String,
  8    pub(in crate::cmd::webui) short_id: String,
  9    pub(in crate::cmd::webui) title: String,
 10    pub(in crate::cmd::webui) score: String,
 11    pub(in crate::cmd::webui) status: String,
 12    pub(in crate::cmd::webui) status_display: &'static str,
 13    /// Pre-formatted equation string for the score tooltip.
 14    pub(in crate::cmd::webui) equation: String,
 15    /// Human-friendly unblocks summary for the score tooltip.
 16    pub(in crate::cmd::webui) unblocks_display: String,
 17    pub(in crate::cmd::webui) labels: Vec<String>,
 18}
 19
 20/// Minimal view-model for a task row in the project task table.
 21pub(in crate::cmd::webui) struct TaskRow {
 22    pub(in crate::cmd::webui) full_id: String,
 23    pub(in crate::cmd::webui) short_id: String,
 24    pub(in crate::cmd::webui) status: String,
 25    pub(in crate::cmd::webui) status_display: &'static str,
 26    pub(in crate::cmd::webui) task_type: String,
 27    pub(in crate::cmd::webui) priority: String,
 28    pub(in crate::cmd::webui) effort: String,
 29    pub(in crate::cmd::webui) title: String,
 30    pub(in crate::cmd::webui) labels: Vec<String>,
 31    pub(in crate::cmd::webui) created_at: String,
 32    pub(in crate::cmd::webui) created_at_display: String,
 33}
 34
 35/// Sort context for a section's task table. Column headers become clickable
 36/// links that set sort/order query params namespaced by the section prefix.
 37pub(in crate::cmd::webui) struct SortContext {
 38    /// Base URL for sort links (e.g. `/projects/myproj`).
 39    pub(in crate::cmd::webui) base_href: String,
 40    /// Query param prefix for this section (e.g. "ip_", "open_", "closed_").
 41    pub(in crate::cmd::webui) prefix: String,
 42    /// Current sort field.
 43    pub(in crate::cmd::webui) field: String,
 44    /// Current sort order ("asc" or "desc").
 45    pub(in crate::cmd::webui) order: String,
 46    /// Full query string for the current page state (all sections' params),
 47    /// excluding this section's sort/order/page. Used for building links
 48    /// that preserve other sections' state.
 49    pub(in crate::cmd::webui) preserve_qs: String,
 50}
 51
 52impl SortContext {
 53    /// Build the href for a column header link. Clicking the currently-active
 54    /// column toggles direction; clicking a different column uses its default.
 55    fn column_href(&self, col: &str) -> String {
 56        let order = if col == self.field {
 57            // Toggle current direction.
 58            match self.order.as_str() {
 59                "asc" => "desc",
 60                _ => "asc",
 61            }
 62        } else {
 63            // Use the column's sensible default.
 64            SortField::parse(col)
 65                .map(|f| f.default_order().as_str())
 66                .unwrap_or("asc")
 67        };
 68        let mut qs = self.preserve_qs.clone();
 69        if !qs.is_empty() {
 70            qs.push('&');
 71        }
 72        qs.push_str(&format!(
 73            "{}sort={col}&{}order={order}",
 74            self.prefix, self.prefix
 75        ));
 76        format!("{}?{qs}", self.base_href)
 77    }
 78
 79    /// Return the arrow indicator for the active column, or empty string.
 80    fn arrow(&self, col: &str) -> &str {
 81        if col == self.field {
 82            match self.order.as_str() {
 83                "asc" => "",
 84                "desc" => "",
 85                _ => "",
 86            }
 87        } else {
 88            ""
 89        }
 90    }
 91}
 92
 93/// All the state needed to render one status section (in-progress, open, or
 94/// closed) on the project page.
 95pub(in crate::cmd::webui) struct SectionState {
 96    /// Human-friendly label (e.g. "In progress").
 97    pub(in crate::cmd::webui) label: &'static str,
 98    /// Total tasks with this status (unfiltered).
 99    pub(in crate::cmd::webui) total_count: usize,
100    /// Tasks matching current filters.
101    pub(in crate::cmd::webui) filtered_count: usize,
102    /// Task rows for the current page.
103    pub(in crate::cmd::webui) tasks: Vec<TaskRow>,
104    /// Sort context for this section's table.
105    pub(in crate::cmd::webui) sort_ctx: SortContext,
106    /// Current filter values for this section.
107    pub(in crate::cmd::webui) filter_priority: Option<String>,
108    pub(in crate::cmd::webui) filter_effort: Option<String>,
109    pub(in crate::cmd::webui) filter_label: Option<String>,
110    pub(in crate::cmd::webui) filter_type: Option<String>,
111    pub(in crate::cmd::webui) filter_search: String,
112    /// Current page number (1-indexed).
113    pub(in crate::cmd::webui) page: usize,
114    pub(in crate::cmd::webui) total_pages: usize,
115    pub(in crate::cmd::webui) pagination_pages: Vec<usize>,
116    /// Whether any non-default params are set for this section (used for
117    /// details open heuristic).
118    pub(in crate::cmd::webui) has_user_params: bool,
119}
120
121impl SectionState {
122    /// Build a query-string fragment for this section's current filters
123    /// (excludes sort, order, page).
124    fn section_filter_qs(&self) -> String {
125        let prefix = &self.sort_ctx.prefix;
126        let mut parts = Vec::new();
127        if let Some(ref p) = self.filter_priority {
128            parts.push(format!("{prefix}priority={p}"));
129        }
130        if let Some(ref e) = self.filter_effort {
131            parts.push(format!("{prefix}effort={e}"));
132        }
133        if let Some(ref l) = self.filter_label {
134            parts.push(format!("{prefix}label={l}"));
135        }
136        if let Some(ref tt) = self.filter_type {
137            parts.push(format!("{prefix}type={tt}"));
138        }
139        if !self.filter_search.is_empty() {
140            parts.push(format!("{prefix}q={}", self.filter_search));
141        }
142        parts.join("&")
143    }
144
145    /// Build a pagination link for this section, preserving all page state.
146    fn pagination_href(&self, target_page: &usize) -> String {
147        let target_page = *target_page;
148        let prefix = &self.sort_ctx.prefix;
149        let mut qs = self.sort_ctx.preserve_qs.clone();
150        // Add this section's filter params.
151        let fqs = self.section_filter_qs();
152        if !fqs.is_empty() {
153            if !qs.is_empty() {
154                qs.push('&');
155            }
156            qs.push_str(&fqs);
157        }
158        // Add sort/order for this section.
159        if !qs.is_empty() {
160            qs.push('&');
161        }
162        qs.push_str(&format!(
163            "{prefix}sort={}&{prefix}order={}",
164            self.sort_ctx.field, self.sort_ctx.order
165        ));
166        qs.push_str(&format!("&{prefix}page={target_page}"));
167        format!("{}?{qs}", self.sort_ctx.base_href)
168    }
169}
170
171#[derive(Template)]
172#[template(path = "project.html")]
173pub(super) struct ProjectTemplate {
174    pub(super) all_projects: Vec<String>,
175    pub(super) active_project: Option<String>,
176    pub(super) project_name: String,
177    pub(super) stats_open: usize,
178    pub(super) stats_in_progress: usize,
179    pub(super) stats_closed: usize,
180    pub(super) next_up: Vec<ScoredEntry>,
181    /// Current scoring mode: "impact" or "effort".
182    pub(super) next_mode: String,
183    pub(super) all_labels: Vec<String>,
184    pub(super) in_progress: SectionState,
185    pub(super) open: SectionState,
186    pub(super) closed: SectionState,
187}
188
189impl ProjectTemplate {
190    /// Build the complete current-page query string (all sections) for use
191    /// in mutation redirect hidden fields.
192    fn full_current_qs(&self) -> String {
193        let mut parts = Vec::new();
194        if self.next_mode == "effort" {
195            parts.push("next_mode=effort".to_string());
196        }
197        for section in [&self.in_progress, &self.open, &self.closed] {
198            let prefix = &section.sort_ctx.prefix;
199            let fqs = section.section_filter_qs();
200            if !fqs.is_empty() {
201                parts.push(fqs);
202            }
203            if section.sort_ctx.field != "priority" || section.sort_ctx.order != "asc" {
204                parts.push(format!(
205                    "{prefix}sort={}&{prefix}order={}",
206                    section.sort_ctx.field, section.sort_ctx.order
207                ));
208            }
209            if section.page > 1 {
210                parts.push(format!("{prefix}page={}", section.page));
211            }
212        }
213        parts.join("&")
214    }
215
216    /// Build the current query string without next_mode, for use as hidden
217    /// fields in the mode toggle form.
218    fn section_only_qs(&self) -> String {
219        let mut parts = Vec::new();
220        for section in [&self.in_progress, &self.open, &self.closed] {
221            let prefix = &section.sort_ctx.prefix;
222            let fqs = section.section_filter_qs();
223            if !fqs.is_empty() {
224                parts.push(fqs);
225            }
226            if section.sort_ctx.field != "priority" || section.sort_ctx.order != "asc" {
227                parts.push(format!(
228                    "{prefix}sort={}&{prefix}order={}",
229                    section.sort_ctx.field, section.sort_ctx.order
230                ));
231            }
232            if section.page > 1 {
233                parts.push(format!("{prefix}page={}", section.page));
234            }
235        }
236        parts.join("&")
237    }
238
239    /// Build the full redirect URL for mutation forms, preserving current
240    /// page state.
241    fn mutation_redirect(&self) -> String {
242        let qs = self.full_current_qs();
243        if qs.is_empty() {
244            format!("/projects/{}", self.project_name)
245        } else {
246            format!("/projects/{}?{qs}", self.project_name)
247        }
248    }
249}