From 37038f5de4b51a6416dc6f2d9de9d14b186d2abd Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 18 Mar 2026 22:23:11 -0600 Subject: [PATCH] Add mode toggle and score tooltips to Next up section --- src/cmd/webui/project/mod.rs | 57 +++++++++++++++++++++++++++++----- src/cmd/webui/project/views.rs | 32 +++++++++++++++++++ templates/project.html | 18 ++++++++++- 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/cmd/webui/project/mod.rs b/src/cmd/webui/project/mod.rs index 1725ecbdaa608b80484a69abba56719111c29a34..f053699b17271ce0771b6d33b2a7d4601b629102 100644 --- a/src/cmd/webui/project/mod.rs +++ b/src/cmd/webui/project/mod.rs @@ -24,6 +24,9 @@ const PAGE_SIZE: usize = 25; /// set of filter/sort/page params (prefixed ip_, open_, closed_). #[derive(serde::Deserialize, Default)] pub(super) struct ProjectQuery { + // Next-up scoring mode. + next_mode: Option, + // In-progress section. ip_priority: Option, ip_effort: Option, @@ -84,6 +87,11 @@ impl SectionQuery { fn build_preserve_qs(query: &ProjectQuery, exclude_prefix: &str) -> String { let mut parts = Vec::new(); + // Carry next_mode through all section links. + if query.next_mode.as_deref() == Some("effort") { + parts.push("next_mode=effort".to_string()); + } + for (prefix, sq) in [ ("ip_", extract_section(query, "ip_")), ("open_", extract_section(query, "open_")), @@ -346,23 +354,55 @@ pub(in crate::cmd::webui) async fn project_handler( .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string())) .collect(); + let next_mode = match query.next_mode.as_deref() { + Some("effort") => score::Mode::Effort, + _ => score::Mode::Impact, + }; + let next_mode_str = match next_mode { + score::Mode::Effort => "effort", + score::Mode::Impact => "impact", + }; + let scored = score::rank( &open_tasks, &edges, &parents_with_open_children, - score::Mode::Impact, + next_mode, 5, ); let next_up: Vec = scored .into_iter() - .map(|s| ScoredEntry { - short_id: TaskId::display_id(&s.id), - id: s.id, - title: s.title, - score: format!("{:.2}", s.score), - status: "open".to_string(), - status_display: friendly_status("open"), + .map(|s| { + let equation = match next_mode { + score::Mode::Impact => format!( + "({:.2} + 1.00) × {:.2} / {:.2}^0.25 = {:.2}", + s.downstream_score, s.priority_weight, s.effort_weight, s.score + ), + score::Mode::Effort => format!( + "({:.2} × 0.25 + 1.00) × {:.2} / {:.2}² = {:.2}", + s.downstream_score, s.priority_weight, s.effort_weight, s.score + ), + }; + let task_word = if s.total_unblocked == 1 { + "task" + } else { + "tasks" + }; + let unblocks_display = format!( + "Unblocks: {} {} ({} directly)", + s.total_unblocked, task_word, s.direct_unblocked + ); + ScoredEntry { + short_id: TaskId::display_id(&s.id), + id: s.id, + title: s.title, + score: format!("{:.2}", s.score), + status: "open".to_string(), + status_display: friendly_status("open"), + equation, + unblocks_display, + } }) .collect(); @@ -414,6 +454,7 @@ pub(in crate::cmd::webui) async fn project_handler( stats_in_progress, stats_closed, next_up, + next_mode: next_mode_str.to_string(), all_labels, in_progress, open, diff --git a/src/cmd/webui/project/views.rs b/src/cmd/webui/project/views.rs index fc60bdf732a1a6471be5ba6ef3237dde537d7eda..747d84607fb92c7268cd807b0e775a4de52130c2 100644 --- a/src/cmd/webui/project/views.rs +++ b/src/cmd/webui/project/views.rs @@ -10,6 +10,10 @@ pub(in crate::cmd::webui) struct ScoredEntry { pub(in crate::cmd::webui) score: String, pub(in crate::cmd::webui) status: String, pub(in crate::cmd::webui) status_display: &'static str, + /// Pre-formatted equation string for the score tooltip. + pub(in crate::cmd::webui) equation: String, + /// Human-friendly unblocks summary for the score tooltip. + pub(in crate::cmd::webui) unblocks_display: String, } /// Minimal view-model for a task row in the project task table. @@ -167,6 +171,8 @@ pub(super) struct ProjectTemplate { pub(super) stats_in_progress: usize, pub(super) stats_closed: usize, pub(super) next_up: Vec, + /// Current scoring mode: "impact" or "effort". + pub(super) next_mode: String, pub(super) all_labels: Vec, pub(super) in_progress: SectionState, pub(super) open: SectionState, @@ -177,6 +183,32 @@ impl ProjectTemplate { /// Build the complete current-page query string (all sections) for use /// in mutation redirect hidden fields. fn full_current_qs(&self) -> String { + let mut parts = Vec::new(); + if self.next_mode == "effort" { + parts.push("next_mode=effort".to_string()); + } + for section in [&self.in_progress, &self.open, &self.closed] { + let prefix = §ion.sort_ctx.prefix; + let fqs = section.section_filter_qs(); + if !fqs.is_empty() { + parts.push(fqs); + } + if section.sort_ctx.field != "priority" || section.sort_ctx.order != "asc" { + parts.push(format!( + "{prefix}sort={}&{prefix}order={}", + section.sort_ctx.field, section.sort_ctx.order + )); + } + if section.page > 1 { + parts.push(format!("{prefix}page={}", section.page)); + } + } + parts.join("&") + } + + /// Build the current query string without next_mode, for use as hidden + /// fields in the mode toggle form. + fn section_only_qs(&self) -> String { let mut parts = Vec::new(); for section in [&self.in_progress, &self.open, &self.closed] { let prefix = §ion.sort_ctx.prefix; diff --git a/templates/project.html b/templates/project.html index 079c2f0d4434c871fd08146e69210f48ec1756fb..43433e2ac4e6935704e962bd28eaad6c27ea1c6f 100644 --- a/templates/project.html +++ b/templates/project.html @@ -49,6 +49,22 @@ {% if !next_up.is_empty() %}
Next up +
+ {% let preserve = self.section_only_qs() %} + {% if !preserve.is_empty() %} + {% for pair in preserve.split('&') %} + {% let kv = pair.splitn(2, '=').collect::>() %} + {% if kv.len() == 2 %} + + {% endif %} + {% endfor %} + {% endif %} + + +
@@ -66,7 +82,7 @@ - +
Top scored tasks recommended to work on next
{{ i + 1 }} {{ s.short_id }}{{ s.score }}{{ s.score }} {{ s.title }}