Skip to main content

max / goingson

problems: shelved projects stop climbing the painhours ranking Age was the only unbounded input to the score, and the only thing that froze it was the problem's own triage state. So a problem in an Archived or OnHold project kept climbing toward Critical while nobody was working that project, which is how a retired project that never had any code came to own the highest-scoring problem in the queue. Add projects.status_changed_at (migration 064, backfilled to the migration instant: the true transitions were never recorded, and today is the only claim that is true of every row). ProjectStatus::is_dormant is the single definition of not-Active. Problem::age_anchor gains a second freeze on top of the existing settled one, and settled wins when both apply, because a triage decision is the more specific fact and survives the project coming back. The instant is joined at read time rather than mirrored onto every problem row, where it would go stale the moment a status moved. Dormancy demotes as a sort tier rather than discounting the score. Shelving a project does not make its problems hurt less, it makes them not-now, so the score stays honest and the day the project reactivates one bool flips and the ranking is correct again with nothing recomputed. status_changed_at syncs alongside status: two devices that disagreed about when a project went dormant would rank the same queue differently. go-mcp projects a `dormant` flag, without which a frozen 16 is indistinguishable from a problem nobody thinks matters, and now takes its pain/scale default from painhours::DEFAULT_FACTOR instead of a local literal that had already drifted from it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 21:36 UTC
Signed with PGP, not checked
Commit: 51b5631fc01852c4134093217c7d6a91becc838a
Parent: 5b61287
12 files changed, +388 insertions, -36 deletions
@@ -64,3 +64,4 @@
64 64 061 8a37aa99045c49df8e54d36227702e68f570abb870114da14b31760cd0a1433cdd8f0d234808ec0d0da22672bc7fcb11
65 65 062 6a5d413c09ee5c3a115952dbeb1836e140d8170c24f4bab17a751b2d9e67cf583c1fff3639409230c1b7f56c851abaeb
66 66 063 a1471b8f79dd273af634ceb28b253a4ac7dc4e973b1172a25f348f3f81f927bcf4f5f5aaf30cd89d2d6f216ae1ccfdbf
67 + 064 00f60ab309a763fe5b417fd0fddae1b3f4c83a3e99812fa8110cfb34de0e7f908b34dd405e74e09b75085047c0a344b5
@@ -805,6 +805,7 @@
805 805 project_type: crate::models::ProjectType::SideProject,
806 806 status: crate::models::ProjectStatus::Active,
807 807 created_at: Utc::now(),
808 + status_changed_at: Utc::now(),
808 809 group_id: None,
809 810 }
810 811 }
@@ -56,8 +56,11 @@
56 56
57 57 // Parents first — the seed runs with foreign keys on.
58 58 sqlx::query(
59 - "INSERT INTO projects (id, name, description, project_type, status, created_at, user_id, group_id)
60 - VALUES (?, 'Seeded project', 'project description', 'SideProject', 'OnHold', '2026-01-02 03:04:05', ?, ?)",
59 + // status_changed_at is deliberately later than created_at: it is the
60 + // shelving instant a dormant project's problems anchor to, so a restore
61 + // that collapsed it back onto created_at would silently rescore them.
62 + "INSERT INTO projects (id, name, description, project_type, status, created_at, status_changed_at, user_id, group_id)
63 + VALUES (?, 'Seeded project', 'project description', 'SideProject', 'OnHold', '2026-01-02 03:04:05', '2026-03-04 05:06:07', ?, ?)",
61 64 )
62 65 .bind(PROJECT_ID).bind(&u).bind(GROUP_ID)
63 66 .execute(pool).await.expect("seed projects");
@@ -362,3 +362,132 @@
362 362 assert!(repo.get_by_id(problem.id, user_id).await.unwrap().is_none());
363 363 assert!(!repo.delete(problem.id, user_id).await.unwrap());
364 364 }
365 +
366 + /// Insert a project directly, so the test controls its status and the instant
367 + /// it entered it. Returns the id to attribute a problem to.
368 + async fn project_with_status(
369 + pool: &sqlx::SqlitePool,
370 + user_id: UserId,
371 + status: &str,
372 + status_changed_at: chrono::DateTime<Utc>,
373 + ) -> String {
374 + let id = uuid::Uuid::new_v4().to_string();
375 + sqlx::query(
376 + "INSERT INTO projects (id, user_id, name, description, project_type, status, created_at, status_changed_at) \
377 + VALUES (?, ?, 'shelf', '', 'SideProject', ?, ?, ?)",
378 + )
379 + .bind(&id)
380 + .bind(user_id.to_string())
381 + .bind(status)
382 + .bind((Utc::now() - Duration::days(400)).format("%Y-%m-%d %H:%M:%S").to_string())
383 + .bind(status_changed_at.format("%Y-%m-%d %H:%M:%S").to_string())
384 + .execute(pool)
385 + .await
386 + .expect("insert project");
387 + id
388 + }
389 +
390 + /// The dormancy join is what stops a shelved project's backlog climbing the
391 + /// painhours ranking. It is a LEFT JOIN on a non-Active status, so this pins
392 + /// both arms: a shelved project supplies the anchor, an Active one supplies
393 + /// NULL, and an unattributed problem still reads.
394 + #[tokio::test]
395 + async fn a_shelved_project_freezes_its_problems_through_the_join() {
396 + let pool = common::setup_test_db().await;
397 + let user_id = common::create_test_user(&pool).await;
398 +
399 + // Reported 40 days ago, shelved 33 days ago: the shelved one accrues the
400 + // 7 days it spent in a live project and then stops, while its twin keeps
401 + // climbing. Both stay short of the score's saturation point, so the
402 + // comparison measures the freeze rather than two clipped 100s.
403 + let shelved_at = Utc::now() - Duration::days(33);
404 + let on_hold = project_with_status(&pool, user_id, "OnHold", shelved_at).await;
405 + let active = project_with_status(&pool, user_id, "Active", shelved_at).await;
406 +
407 + let repo = SqliteProblemRepository::new(pool);
408 +
409 + // Same age, same factors: the only difference is the project's status.
410 + let reported = Utc::now() - Duration::days(40);
411 + let mut shelved_input = new_problem("shelved");
412 + shelved_input.created_at = reported;
413 + shelved_input.project_id = Some(uuid::Uuid::parse_str(&on_hold).unwrap().into());
414 + let mut live_input = new_problem("live");
415 + live_input.created_at = reported;
416 + live_input.project_id = Some(uuid::Uuid::parse_str(&active).unwrap().into());
417 +
418 + let shelved = repo.ingest(user_id, shelved_input).await.unwrap();
419 + let live = repo.ingest(user_id, live_input).await.unwrap();
420 + let orphan = repo.ingest(user_id, new_problem("orphan")).await.unwrap();
421 +
422 + assert_eq!(
423 + shelved.dormant_since.map(|d| d.date_naive()),
424 + Some(shelved_at.date_naive())
425 + );
426 + assert!(shelved.is_dormant());
427 + assert!(live.dormant_since.is_none(), "Active must not join");
428 + assert!(!live.is_dormant());
429 + assert!(
430 + orphan.dormant_since.is_none(),
431 + "unattributed must still read"
432 + );
433 +
434 + assert!(
435 + shelved.painhours() < live.painhours(),
436 + "shelved {} should rank below live {}",
437 + shelved.painhours(),
438 + live.painhours()
439 + );
440 +
441 + // And it survives the round trip through every read path.
442 + let listed = repo.list(user_id, &ProblemFilter::default()).await.unwrap();
443 + let from_list = listed.iter().find(|p| p.source_ref == "shelved").unwrap();
444 + assert!(from_list.is_dormant(), "list must carry dormancy");
445 + let by_id = repo.get_by_id(shelved.id, user_id).await.unwrap().unwrap();
446 + assert!(by_id.is_dormant(), "get_by_id must carry dormancy");
447 + }
448 +
449 + /// The demotion tier. A shelved problem keeps its honest score and still sorts
450 + /// below every live one, which is what makes the ranking usable again the day
451 + /// the project comes back: nothing is recomputed, one bool flips.
452 + #[tokio::test]
453 + async fn dormant_problems_sort_below_live_ones_whatever_the_score() {
454 + let pool = common::setup_test_db().await;
455 + let user_id = common::create_test_user(&pool).await;
456 +
457 + let shelved_at = Utc::now() - Duration::days(1);
458 + let on_hold = project_with_status(&pool, user_id, "Archived", shelved_at).await;
459 +
460 + let repo = SqliteProblemRepository::new(pool);
461 +
462 + // The shelved one is deliberately the more painful, older problem: it
463 + // outranks the live one on every axis the score knows about.
464 + let mut big = new_problem("shelved-and-severe");
465 + big.pain = 5;
466 + big.scale = 5;
467 + big.created_at = Utc::now() - Duration::days(300);
468 + big.project_id = Some(uuid::Uuid::parse_str(&on_hold).unwrap().into());
469 +
470 + let mut small = new_problem("live-and-minor");
471 + small.pain = 1;
472 + small.scale = 1;
473 + small.created_at = Utc::now() - Duration::days(1);
474 +
475 + let shelved = repo.ingest(user_id, big).await.unwrap();
476 + let live = repo.ingest(user_id, small).await.unwrap();
477 +
478 + assert!(
479 + shelved.painhours() > live.painhours(),
480 + "fixture must have the shelved problem scoring higher, else this proves nothing"
481 + );
482 +
483 + let listed = repo.list(user_id, &ProblemFilter::default()).await.unwrap();
484 + let order: Vec<&str> = listed.iter().map(|p| p.source_ref.as_str()).collect();
485 + assert_eq!(
486 + order,
487 + vec!["live-and-minor", "shelved-and-severe"],
488 + "live work sorts first regardless of score"
489 + );
490 +
491 + // And the demoted problem did not lose its score on the way down.
492 + assert_eq!(listed[1].painhours(), shelved.painhours());
493 + }