max / makenotwork
5 files changed,
+330 insertions,
-70 deletions
| @@ -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 { |
| @@ -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 | } |
| @@ -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), |
| @@ -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()), |
| @@ -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 { |