@@ -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,
@@ -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 = §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;