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 = §ion.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 = §ion.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}