Add mode toggle and score tooltips to Next up section

Amolith created

Change summary

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(-)

Detailed changes

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<String>,
+
     // In-progress section.
     ip_priority: Option<String>,
     ip_effort: Option<String>,
@@ -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<ScoredEntry> = 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,

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<ScoredEntry>,
+    /// Current scoring mode: "impact" or "effort".
+    pub(super) next_mode: String,
     pub(super) all_labels: Vec<String>,
     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 = &section.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 = &section.sort_ctx.prefix;

templates/project.html 🔗

@@ -49,6 +49,22 @@
 {% if !next_up.is_empty() %}
 <details open class="mt-4">
   <summary>Next up</summary>
+  <form method="get" action="/projects/{{ project_name }}" class="mt-2 mb-2">
+    {% let preserve = self.section_only_qs() %}
+    {% if !preserve.is_empty() %}
+    {% for pair in preserve.split('&') %}
+    {% let kv = pair.splitn(2, '=').collect::<Vec<_>>() %}
+    {% if kv.len() == 2 %}
+    <input type="hidden" name="{{ kv[0] }}" value="{{ kv[1] }}">
+    {% endif %}
+    {% endfor %}
+    {% endif %}
+    <label>
+      <input type="checkbox" role="switch" name="next_mode" value="effort"{% if next_mode == "effort" %} checked{% endif %} onchange="this.form.submit()">
+      Effort mode
+    </label>
+    <noscript><button type="submit" class="outline small">Apply</button></noscript>
+  </form>
   <div class="table">
     <table>
       <caption class="sr-only">Top scored tasks recommended to work on next</caption>
@@ -66,7 +82,7 @@
         <tr>
           <td>{{ i + 1 }}</td>
           <td><a href="/projects/{{ project_name }}/tasks/{{ s.id }}"><code>{{ s.short_id }}</code></a></td>
-          <td>{{ s.score }}</td>
+          <td title="{{ s.equation }}&#10;{{ s.unblocks_display }}">{{ s.score }}</td>
           <td>{{ s.title }}</td>
           <td>
             <ot-dropdown>