Skip to main content

max / makenotwork

wam: replace priority enum with computed painhours score Ticket urgency is now a 0-100 painhours score computed from two 1-5 guesstimates (pain, scale) and the bug's age, instead of a stored Low/Medium/High/Critical enum: raw = pain^1.5 * scale^2 * age_weeks^1.5 painhours = round(100 * (1 - e^(-raw/266))) Scale is the driving factor (largest exponent); age is an unbounded anti-starvation escalator, frozen at resolved_at for terminal tickets. The old enum survives only as a color band derived from the score. Exponents and the saturation constant are named, tunable consts. Schema gains pain/scale columns and backfills pain from the legacy priority enum. Listing sorts by painhours in Rust; the priority filter, stats, CLI (--pain/--scale), CSV export, and TUI all key off the score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 16:45 UTC
Commit: e6502a74a37cd3b8d00fb7957c59cec121925fc6
Parent: 050368a
5 files changed, +330 insertions, -70 deletions
M wam/src/cli.rs +8 -5
@@ -21,9 +21,12 @@ pub enum Command {
21 21 /// Ticket body / description
22 22 #[arg(short, long)]
23 23 body: Option<String>,
24 - /// Priority (low, medium, high, critical)
25 - #[arg(short, long, default_value = "medium")]
26 - priority: Priority,
24 + /// Pain: how much it hurts a hit user, 1 (annoyance) to 5 (blocker)
25 + #[arg(short, long, default_value = "3")]
26 + pain: u8,
27 + /// Scale: how broadly it hits, 1 (isolated) to 5 (widespread)
28 + #[arg(long, default_value = "3")]
29 + scale: u8,
27 30 /// Channel (system, request, task)
28 31 #[arg(short, long, default_value = "task")]
29 32 channel: Channel,
@@ -39,7 +42,7 @@ pub enum Command {
39 42 /// Filter by status
40 43 #[arg(short, long)]
41 44 status: Option<Status>,
42 - /// Filter by priority
45 + /// Filter by painhours band (low, medium, high, critical)
43 46 #[arg(short, long)]
44 47 priority: Option<Priority>,
45 48 /// Filter by channel
@@ -73,7 +76,7 @@ pub enum Command {
73 76 #[arg(long)]
74 77 peer: Vec<String>,
75 78 },
76 - /// Show ticket aggregates: open by priority/source + avg resolution time
79 + /// Show ticket aggregates: open by painhours band/source + avg resolution time
77 80 Stats,
78 81 /// Dump tickets as JSON or CSV
79 82 Export {
M wam/src/db.rs +164 -32
@@ -37,7 +37,8 @@ fn migrate(conn: &Connection) -> Result<()> {
37 37 id TEXT PRIMARY KEY,
38 38 title TEXT NOT NULL,
39 39 body TEXT,
40 - priority TEXT NOT NULL DEFAULT 'medium',
40 + pain INTEGER NOT NULL DEFAULT 3,
41 + scale INTEGER NOT NULL DEFAULT 3,
41 42 status TEXT NOT NULL DEFAULT 'open',
42 43 channel TEXT NOT NULL DEFAULT 'system',
43 44 node_id TEXT NOT NULL DEFAULT '',
@@ -68,6 +69,31 @@ fn migrate(conn: &Connection) -> Result<()> {
68 69 )?;
69 70 }
70 71
72 + // Migrate the stored `priority` enum to the painhours `pain`/`scale`
73 + // factors. The old enum only expressed severity, so it seeds `pain`
74 + // (low->2, medium->3, high->4, critical->5) and `scale` defaults to 3.
75 + let has_pain: bool = conn.prepare("SELECT pain FROM tickets LIMIT 0").is_ok();
76 + if !has_pain {
77 + conn.execute_batch(
78 + "ALTER TABLE tickets ADD COLUMN pain INTEGER NOT NULL DEFAULT 3;
79 + ALTER TABLE tickets ADD COLUMN scale INTEGER NOT NULL DEFAULT 3;",
80 + )?;
81 + // Backfill pain from the legacy priority column if it still exists.
82 + let has_priority: bool =
83 + conn.prepare("SELECT priority FROM tickets LIMIT 0").is_ok();
84 + if has_priority {
85 + conn.execute_batch(
86 + "UPDATE tickets SET pain = CASE priority
87 + WHEN 'critical' THEN 5
88 + WHEN 'high' THEN 4
89 + WHEN 'medium' THEN 3
90 + WHEN 'low' THEN 2
91 + ELSE 3
92 + END;",
93 + )?;
94 + }
95 + }
96 +
71 97 Ok(())
72 98 }
73 99
@@ -92,7 +118,6 @@ pub fn get_or_create_node_id(conn: &Connection) -> Result<String> {
92 118 }
93 119
94 120 fn row_to_ticket(row: &Row) -> rusqlite::Result<Ticket> {
95 - let priority_str: String = row.get("priority")?;
96 121 let status_str: String = row.get("status")?;
97 122 let channel_str: String = row.get("channel")?;
98 123 let created_str: String = row.get("created_at")?;
@@ -103,7 +128,8 @@ fn row_to_ticket(row: &Row) -> rusqlite::Result<Ticket> {
103 128 id: row.get("id")?,
104 129 title: row.get("title")?,
105 130 body: row.get("body")?,
106 - priority: priority_str.parse().unwrap_or(Priority::Medium),
131 + pain: row.get::<_, i64>("pain")?.clamp(1, 5) as u8,
132 + scale: row.get::<_, i64>("scale")?.clamp(1, 5) as u8,
107 133 status: status_str.parse().unwrap_or(Status::Open),
108 134 channel: channel_str.parse().unwrap_or(Channel::System),
109 135 node_id: row.get("node_id")?,
@@ -129,13 +155,14 @@ pub fn create_ticket(conn: &Connection, new: &NewTicket, node_id: &str) -> Resul
129 155 let now = Utc::now().to_rfc3339();
130 156
131 157 conn.execute(
132 - "INSERT INTO tickets (id, title, body, priority, status, channel, node_id, source, source_ref, created_at, updated_at)
133 - VALUES (?1, ?2, ?3, ?4, 'open', ?5, ?6, ?7, ?8, ?9, ?9)",
158 + "INSERT INTO tickets (id, title, body, pain, scale, status, channel, node_id, source, source_ref, created_at, updated_at)
159 + VALUES (?1, ?2, ?3, ?4, ?5, 'open', ?6, ?7, ?8, ?9, ?10, ?10)",
134 160 params![
135 161 id,
136 162 new.title,
137 163 new.body,
138 - new.priority.to_string(),
164 + new.pain.clamp(1, 5),
165 + new.scale.clamp(1, 5),
139 166 new.channel.to_string(),
140 167 node_id,
141 168 new.source,
@@ -167,13 +194,19 @@ pub fn get_ticket(conn: &Connection, id_prefix: &str) -> Result<Ticket> {
167 194 #[derive(Default)]
168 195 pub struct ListFilter<'a> {
169 196 pub status: Option<Status>,
197 + /// Filter by painhours color band (see [`Priority`]).
170 198 pub priority: Option<Priority>,
171 199 pub channel: Option<Channel>,
172 200 pub source: Option<&'a str>,
173 201 pub search: Option<&'a str>,
174 202 }
175 203
176 - /// List tickets with optional filters, ordered by priority (desc) then date (desc).
204 + /// List tickets with optional filters, ordered by painhours score (desc) then
205 + /// creation date (desc).
206 + ///
207 + /// The painhours score depends on the current time and the resolve-freeze rule,
208 + /// so it can't be expressed in SQL's `ORDER BY`; the band filter and ranking are
209 + /// applied in Rust after the row-level filters run in the query.
177 210 pub fn list_tickets(conn: &Connection, filter: &ListFilter) -> Result<Vec<Ticket>> {
178 211 let mut sql = String::from("SELECT * FROM tickets WHERE 1=1");
179 212 let mut bind_values: Vec<String> = Vec::new();
@@ -182,10 +215,6 @@ pub fn list_tickets(conn: &Connection, filter: &ListFilter) -> Result<Vec<Ticket
182 215 bind_values.push(status.to_string());
183 216 sql.push_str(&format!(" AND status = ?{}", bind_values.len()));
184 217 }
185 - if let Some(priority) = filter.priority {
186 - bind_values.push(priority.to_string());
187 - sql.push_str(&format!(" AND priority = ?{}", bind_values.len()));
188 - }
189 218 if let Some(channel) = filter.channel {
190 219 bind_values.push(channel.to_string());
191 220 sql.push_str(&format!(" AND channel = ?{}", bind_values.len()));
@@ -199,23 +228,24 @@ pub fn list_tickets(conn: &Connection, filter: &ListFilter) -> Result<Vec<Ticket
199 228 sql.push_str(&format!(" AND title LIKE ?{}", bind_values.len()));
200 229 }
201 230
202 - sql.push_str(
203 - " ORDER BY CASE priority
204 - WHEN 'critical' THEN 3
205 - WHEN 'high' THEN 2
206 - WHEN 'medium' THEN 1
207 - WHEN 'low' THEN 0
208 - ELSE 1
209 - END DESC, created_at DESC",
210 - );
211 -
212 231 let mut stmt = conn.prepare(&sql)?;
213 232 let params_refs: Vec<&dyn rusqlite::types::ToSql> =
214 233 bind_values.iter().map(|v| v as &dyn rusqlite::types::ToSql).collect();
215 - let tickets = stmt
234 + let mut tickets = stmt
216 235 .query_map(params_refs.as_slice(), row_to_ticket)?
217 236 .collect::<rusqlite::Result<Vec<_>>>()?;
218 237
238 + if let Some(band) = filter.priority {
239 + tickets.retain(|t| t.band() == band);
240 + }
241 +
242 + // Highest painhours first, ties broken by newest.
243 + tickets.sort_by(|a, b| {
244 + b.painhours()
245 + .cmp(&a.painhours())
246 + .then_with(|| b.created_at.cmp(&a.created_at))
247 + });
248 +
219 249 Ok(tickets)
220 250 }
221 251
@@ -262,6 +292,7 @@ pub fn update_ticket(
262 292 pub struct Stats {
263 293 pub total: usize,
264 294 pub by_status: Vec<(Status, usize)>,
295 + /// Open tickets bucketed by painhours color band.
265 296 pub open_by_priority: Vec<(Priority, usize)>,
266 297 pub open_by_source: Vec<(String, usize)>,
267 298 /// Average seconds between `created_at` and `resolved_at` for tickets that
@@ -285,7 +316,7 @@ pub fn stats(conn: &Connection) -> Result<Stats> {
285 316 for pri in order_pri {
286 317 let count = tickets
287 318 .iter()
288 - .filter(|t| t.status == Status::Open && t.priority == pri)
319 + .filter(|t| t.status == Status::Open && t.band() == pri)
289 320 .count();
290 321 if count > 0 {
291 322 s.open_by_priority.push((pri, count));
@@ -372,12 +403,13 @@ pub fn upsert_synced_ticket(conn: &Connection, ticket: &Ticket) -> Result<bool>
372 403 let resolved_at = ticket.resolved_at.map(|dt| dt.to_rfc3339());
373 404
374 405 conn.execute(
375 - "INSERT INTO tickets (id, title, body, priority, status, channel, node_id, source, source_ref, created_at, updated_at, resolved_at)
376 - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
406 + "INSERT INTO tickets (id, title, body, pain, scale, status, channel, node_id, source, source_ref, created_at, updated_at, resolved_at)
407 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
377 408 ON CONFLICT(id) DO UPDATE SET
378 409 title = excluded.title,
379 410 body = excluded.body,
380 - priority = excluded.priority,
411 + pain = excluded.pain,
412 + scale = excluded.scale,
381 413 status = excluded.status,
382 414 channel = excluded.channel,
383 415 source = excluded.source,
@@ -388,7 +420,8 @@ pub fn upsert_synced_ticket(conn: &Connection, ticket: &Ticket) -> Result<bool>
388 420 ticket.id,
389 421 ticket.title,
390 422 ticket.body,
391 - ticket.priority.to_string(),
423 + ticket.pain.clamp(1, 5),
424 + ticket.scale.clamp(1, 5),
392 425 ticket.status.to_string(),
393 426 ticket.channel.to_string(),
394 427 ticket.node_id,
@@ -433,13 +466,24 @@ mod tests {
433 466 NewTicket {
434 467 title: title.to_string(),
435 468 body: None,
436 - priority: Priority::Medium,
469 + pain: 3,
470 + scale: 3,
437 471 channel: Channel::System,
438 472 source: Some("test".to_string()),
439 473 source_ref: None,
440 474 }
441 475 }
442 476
477 + /// Backdate a ticket's `created_at` so its age-driven painhours climbs.
478 + fn backdate_created(conn: &Connection, id: &str, weeks: i64) {
479 + let ts = (Utc::now() - chrono::Duration::weeks(weeks)).to_rfc3339();
480 + conn.execute(
481 + "UPDATE tickets SET created_at = ?1 WHERE id = ?2",
482 + params![ts, id],
483 + )
484 + .unwrap();
485 + }
486 +
443 487 #[test]
444 488 fn create_and_get() {
445 489 let conn = open_memory().unwrap();
@@ -470,7 +514,8 @@ mod tests {
470 514 create_ticket(&conn, &NewTicket {
471 515 title: "urgent".into(),
472 516 body: None,
473 - priority: Priority::Critical,
517 + pain: 5,
518 + scale: 5,
474 519 channel: Channel::Request,
475 520 source: Some("pom".into()),
476 521 source_ref: None,
@@ -479,6 +524,7 @@ mod tests {
479 524
480 525 let all = list_tickets(&conn, &ListFilter::default()).unwrap();
481 526 assert_eq!(all.len(), 2);
527 + // Higher pain*scale => higher painhours => sorts first.
482 528 assert_eq!(all[0].title, "urgent");
483 529
484 530 let requests = list_tickets(&conn, &ListFilter {
@@ -567,11 +613,13 @@ mod tests {
567 613 let conn = open_memory().unwrap();
568 614 let node = get_or_create_node_id(&conn).unwrap();
569 615 let a = create_ticket(&conn, &NewTicket {
570 - title: "a".into(), body: None, priority: Priority::Critical,
616 + title: "a".into(), body: None, pain: 5, scale: 5,
571 617 channel: Channel::System, source: Some("pom".into()), source_ref: None,
572 618 }, &node).unwrap();
619 + // Age it four weeks so its painhours reaches the Critical band.
620 + backdate_created(&conn, &a.id, 4);
573 621 create_ticket(&conn, &NewTicket {
574 - title: "b".into(), body: None, priority: Priority::Low,
622 + title: "b".into(), body: None, pain: 1, scale: 1,
575 623 channel: Channel::Task, source: Some("manual".into()), source_ref: None,
576 624 }, &node).unwrap();
577 625 let c = create_ticket(&conn, &test_new_ticket("c"), &node).unwrap();
@@ -581,7 +629,7 @@ mod tests {
581 629 assert_eq!(s.total, 3);
582 630 assert!(s.by_status.iter().any(|(st, n)| *st == Status::Open && *n == 2));
583 631 assert!(s.by_status.iter().any(|(st, n)| *st == Status::Resolved && *n == 1));
584 - // Two open tickets: Critical and Low
632 + // Two open tickets: an aged 5x5 (Critical band) and a fresh 1x1 (Low band).
585 633 assert!(s.open_by_priority.iter().any(|(p, n)| *p == Priority::Critical && *n == 1));
586 634 assert!(s.open_by_priority.iter().any(|(p, n)| *p == Priority::Low && *n == 1));
587 635 // Source counts only the open tickets
@@ -630,4 +678,88 @@ mod tests {
630 678 let id2 = get_or_create_node_id(&conn).unwrap();
631 679 assert_eq!(id1, id2);
632 680 }
681 +
682 + #[test]
683 + fn painhours_climbs_with_age() {
684 + let conn = open_memory().unwrap();
685 + let node = get_or_create_node_id(&conn).unwrap();
686 + let t = create_ticket(&conn, &NewTicket {
687 + title: "aging bug".into(), body: None, pain: 4, scale: 4,
688 + channel: Channel::System, source: None, source_ref: None,
689 + }, &node).unwrap();
690 + let fresh_score = get_ticket(&conn, &t.id).unwrap().painhours();
691 +
692 + backdate_created(&conn, &t.id, 6);
693 + let aged_score = get_ticket(&conn, &t.id).unwrap().painhours();
694 +
695 + assert!(aged_score > fresh_score, "aged {aged_score} !> fresh {fresh_score}");
696 + }
697 +
698 + #[test]
699 + fn painhours_freezes_on_resolve() {
700 + let conn = open_memory().unwrap();
701 + let node = get_or_create_node_id(&conn).unwrap();
702 + // Low factors so the two ages stay below the saturation ceiling and
703 + // produce distinguishable scores.
704 + let t = create_ticket(&conn, &NewTicket {
705 + title: "freeze me".into(), body: None, pain: 1, scale: 1,
706 + channel: Channel::System, source: None, source_ref: None,
707 + }, &node).unwrap();
708 +
709 + // Created 10 weeks ago but resolved 4 weeks ago: a resolved ticket's age
710 + // is anchored at resolved_at, so it should read 6 weeks (10 - 4), not the
711 + // 10 weeks an equivalent still-open ticket would show.
712 + let created = (Utc::now() - chrono::Duration::weeks(10)).to_rfc3339();
713 + let resolved = (Utc::now() - chrono::Duration::weeks(4)).to_rfc3339();
714 + conn.execute(
715 + "UPDATE tickets SET created_at = ?1, status = 'resolved', resolved_at = ?2 WHERE id = ?3",
716 + params![created, resolved, t.id],
717 + ).unwrap();
718 +
719 + let frozen = get_ticket(&conn, &t.id).unwrap();
720 + assert_eq!(frozen.age_weeks(), 6, "age is frozen at (resolved_at - created_at)");
721 +
722 + // Reopening un-freezes the anchor: age jumps to the full 10 weeks.
723 + update_status(&conn, &t.id, Status::Open).unwrap();
724 + conn.execute(
725 + "UPDATE tickets SET created_at = ?1 WHERE id = ?2",
726 + params![created, t.id],
727 + ).unwrap();
728 + let reopened = get_ticket(&conn, &t.id).unwrap();
729 + assert_eq!(reopened.age_weeks(), 10, "an open ticket measures age to now");
730 + assert!(reopened.painhours() > frozen.painhours());
731 + }
732 +
733 + #[test]
734 + fn scale_drives_over_pain() {
735 + // At equal age, a widespread-but-mild bug outranks a narrow-but-severe
736 + // one: scale is the driving factor, pain only sub-orders within a tier.
737 + let conn = open_memory().unwrap();
738 + let node = get_or_create_node_id(&conn).unwrap();
739 + create_ticket(&conn, &NewTicket {
740 + title: "widespread mild".into(), body: None, pain: 1, scale: 5,
741 + channel: Channel::System, source: None, source_ref: None,
742 + }, &node).unwrap();
743 + create_ticket(&conn, &NewTicket {
744 + title: "narrow severe".into(), body: None, pain: 5, scale: 1,
745 + channel: Channel::System, source: None, source_ref: None,
746 + }, &node).unwrap();
747 +
748 + let all = list_tickets(&conn, &ListFilter::default()).unwrap();
749 + assert_eq!(all[0].title, "widespread mild");
750 + assert!(all[0].painhours() > all[1].painhours());
751 + }
752 +
753 + #[test]
754 + fn band_buckets_by_score() {
755 + // Pure function: verify the color-band cutoffs.
756 + assert_eq!(Priority::from_painhours(0), Priority::Low);
757 + assert_eq!(Priority::from_painhours(19), Priority::Low);
758 + assert_eq!(Priority::from_painhours(20), Priority::Medium);
759 + assert_eq!(Priority::from_painhours(49), Priority::Medium);
760 + assert_eq!(Priority::from_painhours(50), Priority::High);
761 + assert_eq!(Priority::from_painhours(74), Priority::High);
762 + assert_eq!(Priority::from_painhours(75), Priority::Critical);
763 + assert_eq!(Priority::from_painhours(100), Priority::Critical);
764 + }
633 765 }
M wam/src/main.rs +29 -20
@@ -21,16 +21,20 @@ fn main() -> Result<()> {
21 21 match cli.command {
22 22 None => tui::run(conn, node_id)?,
23 23
24 - Some(Command::Create { title, body, priority, channel, source, source_ref }) => {
24 + Some(Command::Create { title, body, pain, scale, channel, source, source_ref }) => {
25 25 let ticket = db::create_ticket(&conn, &NewTicket {
26 26 title,
27 27 body,
28 - priority,
28 + pain,
29 + scale,
29 30 channel,
30 31 source: Some(source),
31 32 source_ref,
32 33 }, &node_id)?;
33 - println!("created {} [{}] ({})", ticket.short_id(), ticket.channel, ticket.title);
34 + println!(
35 + "created {} [{}] ph:{} ({})",
36 + ticket.short_id(), ticket.channel, ticket.painhours(), ticket.title,
37 + );
34 38 }
35 39
36 40 Some(Command::List { status, priority, channel, source }) => {
@@ -47,14 +51,14 @@ fn main() -> Result<()> {
47 51 return Ok(());
48 52 }
49 53
50 - println!("{:<10} {:<8} {:<5} {:<12} {:<30} {}", "ID", "Channel", "Pri", "Status", "Title", "Node");
54 + println!("{:<10} {:<8} {:<5} {:<12} {:<30} {}", "ID", "Channel", "PH", "Status", "Title", "Node");
51 55 println!("{}", "-".repeat(80));
52 56 for t in &tickets {
53 57 println!(
54 58 "{:<10} {:<8} {:<5} {:<12} {:<30} {}",
55 59 t.short_id(),
56 60 t.channel,
57 - t.priority,
61 + t.painhours(),
58 62 t.status,
59 63 truncate(&t.title, 30),
60 64 t.short_node(),
@@ -65,18 +69,21 @@ fn main() -> Result<()> {
65 69
66 70 Some(Command::Show { id }) => {
67 71 let t = db::get_ticket(&conn, &id)?;
68 - println!("ID: {}", t.id);
69 - println!("Title: {}", t.title);
70 - println!("Channel: {}", t.channel);
71 - println!("Priority: {}", t.priority);
72 - println!("Status: {} {}", t.status.indicator(), t.status);
73 - println!("Node: {}", t.node_id);
74 - println!("Source: {}", t.source.as_deref().unwrap_or("-"));
75 - println!("Ref: {}", t.source_ref.as_deref().unwrap_or("-"));
76 - println!("Created: {}", t.created_at.format("%Y-%m-%d %H:%M UTC"));
77 - println!("Updated: {}", t.updated_at.format("%Y-%m-%d %H:%M UTC"));
72 + println!("ID: {}", t.id);
73 + println!("Title: {}", t.title);
74 + println!("Channel: {}", t.channel);
75 + println!(
76 + "Painhours: {} ({} band) [pain {} x scale {} x {}wk]",
77 + t.painhours(), t.band(), t.pain, t.scale, t.age_weeks(),
78 + );
79 + println!("Status: {} {}", t.status.indicator(), t.status);
80 + println!("Node: {}", t.node_id);
81 + println!("Source: {}", t.source.as_deref().unwrap_or("-"));
82 + println!("Ref: {}", t.source_ref.as_deref().unwrap_or("-"));
83 + println!("Created: {}", t.created_at.format("%Y-%m-%d %H:%M UTC"));
84 + println!("Updated: {}", t.updated_at.format("%Y-%m-%d %H:%M UTC"));
78 85 if let Some(ref resolved) = t.resolved_at {
79 - println!("Resolved: {}", resolved.format("%Y-%m-%d %H:%M UTC"));
86 + println!("Resolved: {}", resolved.format("%Y-%m-%d %H:%M UTC"));
80 87 }
81 88 if let Some(ref body) = t.body {
82 89 println!("\n{body}");
@@ -162,7 +169,7 @@ fn print_stats(conn: &rusqlite::Connection) -> Result<()> {
162 169 }
163 170
164 171 if !s.open_by_priority.is_empty() {
165 - println!("\nOpen by priority:");
172 + println!("\nOpen by painhours band:");
166 173 for (p, n) in &s.open_by_priority {
167 174 println!(" {p:<9} {n}");
168 175 }
@@ -199,16 +206,18 @@ fn format_duration(secs: i64) -> String {
199 206 fn write_csv<W: Write>(w: &mut W, tickets: &[types::Ticket]) -> Result<()> {
200 207 writeln!(
201 208 w,
202 - "id,title,body,priority,status,channel,node_id,source,source_ref,created_at,updated_at,resolved_at"
209 + "id,title,body,pain,scale,painhours,status,channel,node_id,source,source_ref,created_at,updated_at,resolved_at"
203 210 )?;
204 211 for t in tickets {
205 212 writeln!(
206 213 w,
207 - "{},{},{},{},{},{},{},{},{},{},{},{}",
214 + "{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
208 215 csv_escape(&t.id),
209 216 csv_escape(&t.title),
210 217 csv_escape(t.body.as_deref().unwrap_or("")),
211 - t.priority,
218 + t.pain,
219 + t.scale,
220 + t.painhours(),
212 221 t.status,
213 222 t.channel,
214 223 csv_escape(&t.node_id),
M wam/src/tui.rs +14 -7
@@ -223,7 +223,8 @@ impl App {
223 223 &crate::types::NewTicket {
224 224 title,
225 225 body: None,
226 - priority: Priority::Medium,
226 + pain: crate::types::DEFAULT_FACTOR,
227 + scale: crate::types::DEFAULT_FACTOR,
227 228 channel: Channel::Task,
228 229 source: Some("manual".into()),
229 230 source_ref: None,
@@ -465,17 +466,16 @@ fn render_title_bar(app: &App, f: &mut Frame, area: Rect) {
465 466 }
466 467
467 468 fn render_list(app: &mut App, f: &mut Frame, area: Rect) {
468 - let header = Row::new(["Pri", "Title", "Ch", "Source", "Status", "Node", "Age"])
469 + let header = Row::new(["PH", "Title", "Ch", "Source", "Status", "Node", "Age"])
469 470 .style(Style::new().bold().underlined());
470 471
471 472 let rows: Vec<Row> = app
472 473 .tickets
473 474 .iter()
474 475 .map(|t| {
475 - let pri_style = Style::new().fg(t.priority.color());
476 + let pri_style = Style::new().fg(t.band().color());
476 477 Row::new([
477 - Cell::from(format!(" {} ", t.priority.to_string().chars().next().unwrap_or(' ')))
478 - .style(pri_style),
478 + Cell::from(format!("{:>3}", t.painhours())).style(pri_style),
479 479 Cell::from(t.title.as_str()),
480 480 Cell::from(t.channel.to_string()),
481 481 Cell::from(t.source.as_deref().unwrap_or("-")),
@@ -526,8 +526,15 @@ fn render_detail(app: &App, f: &mut Frame, area: Rect) {
526 526 Span::raw(ticket.channel.to_string()),
527 527 ]),
528 528 Line::from(vec![
529 - Span::styled("Priority: ", Style::new().bold()),
530 - Span::styled(ticket.priority.to_string(), Style::new().fg(ticket.priority.color())),
529 + Span::styled("Painhours: ", Style::new().bold()),
530 + Span::styled(
531 + ticket.painhours().to_string(),
532 + Style::new().fg(ticket.band().color()),
533 + ),
534 + Span::raw(format!(
535 + " ({} band, pain {} x scale {} x {}wk)",
536 + ticket.band(), ticket.pain, ticket.scale, ticket.age_weeks(),
537 + )),
531 538 ]),
532 539 Line::from(vec![
533 540 Span::styled("Status: ", Style::new().bold()),
M wam/src/types.rs +115 -6
@@ -1,4 +1,25 @@
1 1 //! Core types: Priority, Status, Channel, Ticket.
2 + //!
3 + //! Ticket urgency is a computed **painhours** score rather than a stored enum.
4 + //! Two 1-5 guesstimates are stored per ticket — `pain` (how much it hurts a hit
5 + //! user) and `scale` (how broadly it hits) — and combined with the bug's age as
6 + //! a power law:
7 + //!
8 + //! ```text
9 + //! raw = pain^PAIN_EXP * scale^SCALE_EXP * age_weeks^AGE_EXP
10 + //! painhours = round(100 * (1 - e^(-raw/K))) (a 0-100 heat score)
11 + //! ```
12 + //!
13 + //! Each exponent tunes one factor's influence independently:
14 + //! - `SCALE_EXP` is the largest, so `scale` is the driving factor — at equal age
15 + //! a widespread bug outranks a narrow one regardless of pain.
16 + //! - `PAIN_EXP` gives severity a secondary pull.
17 + //! - `AGE_EXP` sets how hard age escalates. Because age is unbounded, this is the
18 + //! anti-starvation lever: it decides how fast a low-scale bug climbs rather than
19 + //! languishing forever. It does not affect the ranking of same-age tickets.
20 + //!
21 + //! [`Priority`] survives only as a color *band* derived from the painhours
22 + //! score; tickets no longer store a priority directly.
2 23
3 24 use std::fmt;
4 25 use std::str::FromStr;
@@ -7,7 +28,40 @@ use chrono::{DateTime, Utc};
7 28 use ratatui::style::Color;
8 29 use serde::{Deserialize, Serialize};
9 30
10 - // -- Priority -----------------------------------------------------------------
31 + // -- painhours tuning ---------------------------------------------------------
32 +
33 + /// Exponent on `pain` (severity). Gives severity a secondary pull below scale.
34 + const PAIN_EXP: f64 = 1.5;
35 +
36 + /// Exponent on `scale` (breadth). The largest of the three, which is what makes
37 + /// scale the driving factor in the ranking.
38 + const SCALE_EXP: f64 = 2.0;
39 +
40 + /// Exponent on `age_weeks`. The anti-starvation lever: higher = low-scale bugs
41 + /// climb to Critical faster instead of languishing. Together with `PAINHOURS_K`
42 + /// it sets how long the lowest-possible bug (pain 1, scale 1) takes to force its
43 + /// way to the top: at AGE_EXP=1.5 / K=266 that is ~1 year, while a moderate 3x3
44 + /// bug reaches Critical in ~1 month. Raise this (or lower K) to escalate faster.
45 + /// It has no effect on the ordering of tickets that share an age.
46 + const AGE_EXP: f64 = 1.5;
47 +
48 + /// Saturation constant for the painhours curve. Larger = the score climbs more
49 + /// slowly, so a bigger `raw` is needed to approach 100. Calibrated with the
50 + /// exponents above so a fresh widespread blocker (pain 5, scale 5) reads ~65 on
51 + /// day one — High, with headroom to reach Critical within a couple of weeks —
52 + /// and the 0-100 number keeps a usable gradient across a months-long backlog
53 + /// instead of saturating everything to 100.
54 + const PAINHOURS_K: f64 = 266.0;
55 +
56 + /// Band cutoffs on the 0-100 painhours scale: `>=` each threshold, top-down.
57 + const BAND_CRITICAL: u32 = 75;
58 + const BAND_HIGH: u32 = 50;
59 + const BAND_MEDIUM: u32 = 20;
60 +
61 + /// Default 1-5 guesstimate for both `pain` and `scale` when unspecified.
62 + pub const DEFAULT_FACTOR: u8 = 3;
63 +
64 + // -- Priority (color band) ----------------------------------------------------
11 65
12 66 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13 67 #[serde(rename_all = "lowercase")]
@@ -27,6 +81,19 @@ impl Priority {
27 81 Self::Critical => Color::Red,
28 82 }
29 83 }
84 +
85 + /// Bucket a 0-100 painhours score into a color band.
86 + pub fn from_painhours(score: u32) -> Self {
87 + if score >= BAND_CRITICAL {
88 + Self::Critical
89 + } else if score >= BAND_HIGH {
90 + Self::High
91 + } else if score >= BAND_MEDIUM {
92 + Self::Medium
93 + } else {
94 + Self::Low
95 + }
96 + }
30 97 }
31 98
32 99 impl fmt::Display for Priority {
@@ -144,7 +211,10 @@ pub struct Ticket {
144 211 pub id: String,
145 212 pub title: String,
146 213 pub body: Option<String>,
147 - pub priority: Priority,
214 + /// How much this hurts a hit user, 1 (annoyance) to 5 (blocker).
215 + pub pain: u8,
216 + /// How broadly it hits, 1 (isolated) to 5 (widespread).
217 + pub scale: u8,
148 218 pub status: Status,
149 219 pub channel: Channel,
150 220 pub node_id: String,
@@ -168,6 +238,41 @@ impl Ticket {
168 238 }
169 239 }
170 240
241 + /// The instant against which age is measured: frozen at resolution once the
242 + /// ticket is resolved/closed, so terminal tickets stop climbing the list.
243 + fn age_anchor(&self) -> DateTime<Utc> {
244 + match self.status {
245 + Status::Resolved | Status::Closed => {
246 + self.resolved_at.unwrap_or(self.updated_at)
247 + }
248 + Status::Open | Status::InProgress => Utc::now(),
249 + }
250 + }
251 +
252 + /// Age in whole weeks, rounded up (overestimating is fine), floored at 1 so
253 + /// a brand-new ticket still contributes to its score.
254 + pub fn age_weeks(&self) -> u32 {
255 + let secs = (self.age_anchor() - self.created_at).num_seconds().max(0);
256 + let weeks = (secs as f64 / (7.0 * 86_400.0)).ceil();
257 + (weeks as u32).max(1)
258 + }
259 +
260 + /// The painhours score: a 0-100 urgency number operators sort by.
261 + /// `round(100 * (1 - e^(-raw/K)))` where
262 + /// `raw = pain^PAIN_EXP * scale^SCALE_EXP * age_weeks^AGE_EXP`.
263 + pub fn painhours(&self) -> u32 {
264 + let pain = (self.pain.clamp(1, 5) as f64).powf(PAIN_EXP);
265 + let scale = (self.scale.clamp(1, 5) as f64).powf(SCALE_EXP);
266 + let age = (self.age_weeks() as f64).powf(AGE_EXP);
267 + let raw = pain * scale * age;
268 + (100.0 * (1.0 - (-raw / PAINHOURS_K).exp())).round() as u32
269 + }
270 +
271 + /// Color band derived from the painhours score.
272 + pub fn band(&self) -> Priority {
273 + Priority::from_painhours(self.painhours())
274 + }
275 +
171 276 /// Short ID prefix for display.
172 277 pub fn short_id(&self) -> &str {
173 278 &self.id[..8.min(self.id.len())]
@@ -186,8 +291,12 @@ pub struct NewTicket {
186 291 pub title: String,
187 292 #[serde(default)]
188 293 pub body: Option<String>,
189 - #[serde(default = "default_priority")]
190 - pub priority: Priority,
294 + /// How much this hurts a hit user, 1-5 (defaults to 3).
295 + #[serde(default = "default_factor")]
296 + pub pain: u8,
297 + /// How broadly it hits, 1-5 (defaults to 3).
298 + #[serde(default = "default_factor")]
299 + pub scale: u8,
191 300 #[serde(default = "default_channel")]
192 301 pub channel: Channel,
193 302 #[serde(default)]
@@ -196,8 +305,8 @@ pub struct NewTicket {
196 305 pub source_ref: Option<String>,
197 306 }
198 307
199 - fn default_priority() -> Priority {
200 - Priority::Medium
308 + fn default_factor() -> u8 {
309 + DEFAULT_FACTOR
201 310 }
202 311
203 312 fn default_channel() -> Channel {