max / makenotwork
8 files changed,
+755 insertions,
-136 deletions
| @@ -0,0 +1,364 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "android_system_properties" | |
| 7 | + | version = "0.1.5" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 10 | + | dependencies = [ | |
| 11 | + | "libc", | |
| 12 | + | ] | |
| 13 | + | ||
| 14 | + | [[package]] | |
| 15 | + | name = "autocfg" | |
| 16 | + | version = "1.5.1" | |
| 17 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 18 | + | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 19 | + | ||
| 20 | + | [[package]] | |
| 21 | + | name = "bumpalo" | |
| 22 | + | version = "3.20.3" | |
| 23 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 24 | + | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 25 | + | ||
| 26 | + | [[package]] | |
| 27 | + | name = "cc" | |
| 28 | + | version = "1.4.0" | |
| 29 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 30 | + | checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" | |
| 31 | + | dependencies = [ | |
| 32 | + | "find-msvc-tools", | |
| 33 | + | "shlex", | |
| 34 | + | ] | |
| 35 | + | ||
| 36 | + | [[package]] | |
| 37 | + | name = "cfg-if" | |
| 38 | + | version = "1.0.4" | |
| 39 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 40 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 41 | + | ||
| 42 | + | [[package]] | |
| 43 | + | name = "chrono" | |
| 44 | + | version = "0.4.45" | |
| 45 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 46 | + | checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" | |
| 47 | + | dependencies = [ | |
| 48 | + | "iana-time-zone", | |
| 49 | + | "js-sys", | |
| 50 | + | "num-traits", | |
| 51 | + | "serde", | |
| 52 | + | "wasm-bindgen", | |
| 53 | + | "windows-link", | |
| 54 | + | ] | |
| 55 | + | ||
| 56 | + | [[package]] | |
| 57 | + | name = "core-foundation-sys" | |
| 58 | + | version = "0.8.7" | |
| 59 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 60 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 61 | + | ||
| 62 | + | [[package]] | |
| 63 | + | name = "find-msvc-tools" | |
| 64 | + | version = "0.1.9" | |
| 65 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 66 | + | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 67 | + | ||
| 68 | + | [[package]] | |
| 69 | + | name = "futures-core" | |
| 70 | + | version = "0.3.33" | |
| 71 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 72 | + | checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" | |
| 73 | + | ||
| 74 | + | [[package]] | |
| 75 | + | name = "futures-task" | |
| 76 | + | version = "0.3.33" | |
| 77 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 78 | + | checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" | |
| 79 | + | ||
| 80 | + | [[package]] | |
| 81 | + | name = "futures-util" | |
| 82 | + | version = "0.3.33" | |
| 83 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 84 | + | checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" | |
| 85 | + | dependencies = [ | |
| 86 | + | "futures-core", | |
| 87 | + | "futures-task", | |
| 88 | + | "pin-project-lite", | |
| 89 | + | "slab", | |
| 90 | + | ] | |
| 91 | + | ||
| 92 | + | [[package]] | |
| 93 | + | name = "iana-time-zone" | |
| 94 | + | version = "0.1.65" | |
| 95 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 96 | + | checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" | |
| 97 | + | dependencies = [ | |
| 98 | + | "android_system_properties", | |
| 99 | + | "core-foundation-sys", | |
| 100 | + | "iana-time-zone-haiku", | |
| 101 | + | "js-sys", | |
| 102 | + | "log", | |
| 103 | + | "wasm-bindgen", | |
| 104 | + | "windows-core", | |
| 105 | + | ] | |
| 106 | + | ||
| 107 | + | [[package]] | |
| 108 | + | name = "iana-time-zone-haiku" | |
| 109 | + | version = "0.1.2" | |
| 110 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 111 | + | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" | |
| 112 | + | dependencies = [ | |
| 113 | + | "cc", | |
| 114 | + | ] | |
| 115 | + | ||
| 116 | + | [[package]] | |
| 117 | + | name = "js-sys" | |
| 118 | + | version = "0.3.103" | |
| 119 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 120 | + | checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" | |
| 121 | + | dependencies = [ | |
| 122 | + | "cfg-if", | |
| 123 | + | "futures-util", | |
| 124 | + | "wasm-bindgen", | |
| 125 | + | ] | |
| 126 | + | ||
| 127 | + | [[package]] | |
| 128 | + | name = "libc" | |
| 129 | + | version = "0.2.189" | |
| 130 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 131 | + | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | |
| 132 | + | ||
| 133 | + | [[package]] | |
| 134 | + | name = "log" | |
| 135 | + | version = "0.4.33" | |
| 136 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 137 | + | checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" | |
| 138 | + | ||
| 139 | + | [[package]] | |
| 140 | + | name = "num-traits" | |
| 141 | + | version = "0.2.19" | |
| 142 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 143 | + | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | |
| 144 | + | dependencies = [ | |
| 145 | + | "autocfg", | |
| 146 | + | ] | |
| 147 | + | ||
| 148 | + | [[package]] | |
| 149 | + | name = "once_cell" | |
| 150 | + | version = "1.21.4" | |
| 151 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 152 | + | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 153 | + | ||
| 154 | + | [[package]] | |
| 155 | + | name = "painhours" | |
| 156 | + | version = "0.1.0" | |
| 157 | + | dependencies = [ | |
| 158 | + | "chrono", | |
| 159 | + | "serde", | |
| 160 | + | ] | |
| 161 | + | ||
| 162 | + | [[package]] | |
| 163 | + | name = "pin-project-lite" | |
| 164 | + | version = "0.2.17" | |
| 165 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 166 | + | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 167 | + | ||
| 168 | + | [[package]] | |
| 169 | + | name = "proc-macro2" | |
| 170 | + | version = "1.0.107" | |
| 171 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 172 | + | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 173 | + | dependencies = [ | |
| 174 | + | "unicode-ident", | |
| 175 | + | ] | |
| 176 | + | ||
| 177 | + | [[package]] | |
| 178 | + | name = "quote" | |
| 179 | + | version = "1.0.47" | |
| 180 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 181 | + | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 182 | + | dependencies = [ | |
| 183 | + | "proc-macro2", | |
| 184 | + | ] | |
| 185 | + | ||
| 186 | + | [[package]] | |
| 187 | + | name = "rustversion" | |
| 188 | + | version = "1.0.23" | |
| 189 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 190 | + | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 191 | + | ||
| 192 | + | [[package]] | |
| 193 | + | name = "serde" | |
| 194 | + | version = "1.0.229" | |
| 195 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 196 | + | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" | |
| 197 | + | dependencies = [ | |
| 198 | + | "serde_core", | |
| 199 | + | "serde_derive", | |
| 200 | + | ] | |
| 201 | + | ||
| 202 | + | [[package]] | |
| 203 | + | name = "serde_core" | |
| 204 | + | version = "1.0.229" | |
| 205 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 206 | + | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" | |
| 207 | + | dependencies = [ | |
| 208 | + | "serde_derive", | |
| 209 | + | ] | |
| 210 | + | ||
| 211 | + | [[package]] | |
| 212 | + | name = "serde_derive" | |
| 213 | + | version = "1.0.229" | |
| 214 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 215 | + | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" | |
| 216 | + | dependencies = [ | |
| 217 | + | "proc-macro2", | |
| 218 | + | "quote", | |
| 219 | + | "syn 3.0.3", | |
| 220 | + | ] | |
| 221 | + | ||
| 222 | + | [[package]] | |
| 223 | + | name = "shlex" | |
| 224 | + | version = "2.0.1" | |
| 225 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 226 | + | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 227 | + | ||
| 228 | + | [[package]] | |
| 229 | + | name = "slab" | |
| 230 | + | version = "0.4.12" | |
| 231 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 232 | + | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | |
| 233 | + | ||
| 234 | + | [[package]] | |
| 235 | + | name = "syn" | |
| 236 | + | version = "2.0.119" | |
| 237 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 238 | + | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 239 | + | dependencies = [ | |
| 240 | + | "proc-macro2", | |
| 241 | + | "quote", | |
| 242 | + | "unicode-ident", | |
| 243 | + | ] | |
| 244 | + | ||
| 245 | + | [[package]] | |
| 246 | + | name = "syn" | |
| 247 | + | version = "3.0.3" | |
| 248 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 249 | + | checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" | |
| 250 | + | dependencies = [ | |
| 251 | + | "proc-macro2", | |
| 252 | + | "quote", | |
| 253 | + | "unicode-ident", | |
| 254 | + | ] | |
| 255 | + | ||
| 256 | + | [[package]] | |
| 257 | + | name = "unicode-ident" | |
| 258 | + | version = "1.0.24" | |
| 259 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 260 | + | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 261 | + | ||
| 262 | + | [[package]] | |
| 263 | + | name = "wasm-bindgen" | |
| 264 | + | version = "0.2.126" | |
| 265 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 266 | + | checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" | |
| 267 | + | dependencies = [ | |
| 268 | + | "cfg-if", | |
| 269 | + | "once_cell", | |
| 270 | + | "rustversion", | |
| 271 | + | "wasm-bindgen-macro", | |
| 272 | + | "wasm-bindgen-shared", | |
| 273 | + | ] | |
| 274 | + | ||
| 275 | + | [[package]] | |
| 276 | + | name = "wasm-bindgen-macro" | |
| 277 | + | version = "0.2.126" | |
| 278 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 279 | + | checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" | |
| 280 | + | dependencies = [ | |
| 281 | + | "quote", | |
| 282 | + | "wasm-bindgen-macro-support", | |
| 283 | + | ] | |
| 284 | + | ||
| 285 | + | [[package]] | |
| 286 | + | name = "wasm-bindgen-macro-support" | |
| 287 | + | version = "0.2.126" | |
| 288 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 289 | + | checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" | |
| 290 | + | dependencies = [ | |
| 291 | + | "bumpalo", | |
| 292 | + | "proc-macro2", | |
| 293 | + | "quote", | |
| 294 | + | "syn 2.0.119", | |
| 295 | + | "wasm-bindgen-shared", | |
| 296 | + | ] | |
| 297 | + | ||
| 298 | + | [[package]] | |
| 299 | + | name = "wasm-bindgen-shared" | |
| 300 | + | version = "0.2.126" | |
| 301 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 302 | + | checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" | |
| 303 | + | dependencies = [ | |
| 304 | + | "unicode-ident", | |
| 305 | + | ] | |
| 306 | + | ||
| 307 | + | [[package]] | |
| 308 | + | name = "windows-core" | |
| 309 | + | version = "0.62.2" | |
| 310 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 311 | + | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | |
| 312 | + | dependencies = [ | |
| 313 | + | "windows-implement", | |
| 314 | + | "windows-interface", | |
| 315 | + | "windows-link", | |
| 316 | + | "windows-result", | |
| 317 | + | "windows-strings", | |
| 318 | + | ] | |
| 319 | + | ||
| 320 | + | [[package]] | |
| 321 | + | name = "windows-implement" | |
| 322 | + | version = "0.60.2" | |
| 323 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 324 | + | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | |
| 325 | + | dependencies = [ | |
| 326 | + | "proc-macro2", | |
| 327 | + | "quote", | |
| 328 | + | "syn 2.0.119", | |
| 329 | + | ] | |
| 330 | + | ||
| 331 | + | [[package]] | |
| 332 | + | name = "windows-interface" | |
| 333 | + | version = "0.59.3" | |
| 334 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 335 | + | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | |
| 336 | + | dependencies = [ | |
| 337 | + | "proc-macro2", | |
| 338 | + | "quote", | |
| 339 | + | "syn 2.0.119", | |
| 340 | + | ] | |
| 341 | + | ||
| 342 | + | [[package]] | |
| 343 | + | name = "windows-link" | |
| 344 | + | version = "0.2.1" | |
| 345 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 346 | + | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 347 | + | ||
| 348 | + | [[package]] | |
| 349 | + | name = "windows-result" | |
| 350 | + | version = "0.4.1" | |
| 351 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 352 | + | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | |
| 353 | + | dependencies = [ | |
| 354 | + | "windows-link", | |
| 355 | + | ] | |
| 356 | + | ||
| 357 | + | [[package]] | |
| 358 | + | name = "windows-strings" | |
| 359 | + | version = "0.5.1" | |
| 360 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 361 | + | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | |
| 362 | + | dependencies = [ | |
| 363 | + | "windows-link", | |
| 364 | + | ] |
| @@ -0,0 +1,42 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "painhours" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | license = "MIT" | |
| 6 | + | description = "The painhours urgency model: a 0-100 heat score from pain, scale, and age, plus the color band it buckets into. Shared by wam and GoingsOn." | |
| 7 | + | ||
| 8 | + | [dependencies] | |
| 9 | + | chrono = { version = "0.4", features = ["serde"] } | |
| 10 | + | serde = { version = "1", features = ["derive"] } | |
| 11 | + | ||
| 12 | + | [lints.rust] | |
| 13 | + | unused = "warn" | |
| 14 | + | unreachable_pub = "warn" | |
| 15 | + | ||
| 16 | + | [lints.clippy] | |
| 17 | + | pedantic = { level = "warn", priority = -1 } | |
| 18 | + | # Allow-list tuned from a measured breakdown across server/multithreaded/pter | |
| 19 | + | # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything | |
| 20 | + | # else in `pedantic` stays a warning. Keep this block identical across repos. | |
| 21 | + | module_name_repetitions = "allow" | |
| 22 | + | # Doc lints. No docs-completeness push is underway. | |
| 23 | + | missing_errors_doc = "allow" | |
| 24 | + | missing_panics_doc = "allow" | |
| 25 | + | doc_markdown = "allow" | |
| 26 | + | # Numeric casts. Endemic and mostly intentional in size and byte math. | |
| 27 | + | cast_possible_truncation = "allow" | |
| 28 | + | cast_sign_loss = "allow" | |
| 29 | + | cast_precision_loss = "allow" | |
| 30 | + | cast_possible_wrap = "allow" | |
| 31 | + | cast_lossless = "allow" | |
| 32 | + | # Subjective structure and style nags. High churn, low signal. | |
| 33 | + | must_use_candidate = "allow" | |
| 34 | + | too_many_lines = "allow" | |
| 35 | + | struct_excessive_bools = "allow" | |
| 36 | + | similar_names = "allow" | |
| 37 | + | items_after_statements = "allow" | |
| 38 | + | single_match_else = "allow" | |
| 39 | + | # Frequent false-positives in TUI and router-heavy code. | |
| 40 | + | match_same_arms = "allow" | |
| 41 | + | unnecessary_wraps = "allow" | |
| 42 | + | type_complexity = "allow" |
| @@ -0,0 +1,43 @@ | |||
| 1 | + | # painhours | |
| 2 | + | ||
| 3 | + | The painhours urgency model for Rust: a 0-100 heat score computed from how much | |
| 4 | + | something hurts, how broadly it hits, and how long it has been sitting there. | |
| 5 | + | ||
| 6 | + | Nothing stores a priority. Rank by the score; use the band only for color. | |
| 7 | + | ||
| 8 | + | ## The model | |
| 9 | + | ||
| 10 | + | ```text | |
| 11 | + | raw = pain^1.5 * scale^2.0 * age_weeks^1.5 | |
| 12 | + | painhours = round(100 * (1 - e^(-raw/266))) | |
| 13 | + | ``` | |
| 14 | + | ||
| 15 | + | `pain` and `scale` are 1-5 guesstimates. Each exponent tunes one factor | |
| 16 | + | independently: `scale` drives the ranking, `pain` gives severity a secondary | |
| 17 | + | pull, and the exponent on age is the anti-starvation lever that decides how fast | |
| 18 | + | a narrow problem climbs rather than languishing forever. | |
| 19 | + | ||
| 20 | + | Band cutoffs on the score: Critical at 75, High at 50, Medium at 20, Low below. | |
| 21 | + | ||
| 22 | + | ## Use | |
| 23 | + | ||
| 24 | + | ```rust | |
| 25 | + | use chrono::Utc; | |
| 26 | + | use painhours::{Priority, age_weeks, painhours}; | |
| 27 | + | ||
| 28 | + | // Age is measured to an anchor you choose. Pass `Utc::now()` while the item is | |
| 29 | + | // live; pass the resolution instant once it is terminal, so closed items stop | |
| 30 | + | // climbing the list. | |
| 31 | + | let weeks = age_weeks(created_at, Utc::now()); | |
| 32 | + | let score = painhours(pain, scale, weeks); | |
| 33 | + | let band = Priority::from_painhours(score); | |
| 34 | + | ``` | |
| 35 | + | ||
| 36 | + | The crate is the scoring model and nothing else. It has no opinion about what | |
| 37 | + | your record type is, what statuses it has, or where you store it. | |
| 38 | + | ||
| 39 | + | ## Consumers | |
| 40 | + | ||
| 41 | + | `MNW/wam` (tickets) and GoingsOn (problems), so one ranked list can span both. | |
| 42 | + | Changing a constant here reranks both, which is the point: duplicating the tuning | |
| 43 | + | would let the rankings drift. |
| @@ -0,0 +1,278 @@ | |||
| 1 | + | //! The painhours urgency model. | |
| 2 | + | //! | |
| 3 | + | //! An item's urgency is a computed score rather than a stored enum. Two 1-5 | |
| 4 | + | //! guesstimates are supplied per item, `pain` (how much it hurts a hit user) and | |
| 5 | + | //! `scale` (how broadly it hits), and combined with the item's age as a power | |
| 6 | + | //! 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 problem 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 problem climbs rather | |
| 19 | + | //! than languishing forever. It does not affect the ranking of same-age items. | |
| 20 | + | //! | |
| 21 | + | //! [`Priority`] survives only as a color *band* derived from the score. Nothing | |
| 22 | + | //! stores a priority directly. | |
| 23 | + | //! | |
| 24 | + | //! # Scope | |
| 25 | + | //! | |
| 26 | + | //! This crate is the scoring model and nothing else. It has no opinion about | |
| 27 | + | //! what an item is, what statuses it has, or where it is stored. Callers keep | |
| 28 | + | //! their own record type and hand its three inputs to [`painhours`]. | |
| 29 | + | //! | |
| 30 | + | //! Age is the one input a caller cannot compute naively, because a resolved item | |
| 31 | + | //! must stop climbing. [`age_weeks`] therefore takes an explicit anchor: the | |
| 32 | + | //! caller decides whether that is "now" (still open) or the instant the item was | |
| 33 | + | //! resolved (terminal). See its docs. | |
| 34 | + | //! | |
| 35 | + | //! # Consumers | |
| 36 | + | //! | |
| 37 | + | //! `MNW/wam` (tickets) and GoingsOn (problems), so one ranked list can span | |
| 38 | + | //! both. Changing a constant here reranks every consumer, which is the point: | |
| 39 | + | //! duplicating the tuning would let the two rankings drift. | |
| 40 | + | //! | |
| 41 | + | //! <!-- wiki: wam-overview --> | |
| 42 | + | ||
| 43 | + | use std::fmt; | |
| 44 | + | use std::str::FromStr; | |
| 45 | + | ||
| 46 | + | use chrono::{DateTime, Utc}; | |
| 47 | + | use serde::{Deserialize, Serialize}; | |
| 48 | + | ||
| 49 | + | // -- painhours tuning --------------------------------------------------------- | |
| 50 | + | ||
| 51 | + | /// Exponent on `pain` (severity). Gives severity a secondary pull below scale. | |
| 52 | + | const PAIN_EXP: f64 = 1.5; | |
| 53 | + | ||
| 54 | + | /// Exponent on `scale` (breadth). The largest of the three, which is what makes | |
| 55 | + | /// scale the driving factor in the ranking. | |
| 56 | + | const SCALE_EXP: f64 = 2.0; | |
| 57 | + | ||
| 58 | + | /// Exponent on `age_weeks`. The anti-starvation lever: higher = low-scale items | |
| 59 | + | /// climb to Critical faster instead of languishing. Together with `PAINHOURS_K` | |
| 60 | + | /// it sets how long the lowest-possible item (pain 1, scale 1) takes to force its | |
| 61 | + | /// way to the top: at AGE_EXP=1.5 / K=266 that is ~1 year, while a moderate 3x3 | |
| 62 | + | /// item reaches Critical in ~1 month. Raise this (or lower K) to escalate faster. | |
| 63 | + | /// It has no effect on the ordering of items that share an age. | |
| 64 | + | const AGE_EXP: f64 = 1.5; | |
| 65 | + | ||
| 66 | + | /// Saturation constant for the painhours curve. Larger = the score climbs more | |
| 67 | + | /// slowly, so a bigger `raw` is needed to approach 100. Calibrated with the | |
| 68 | + | /// exponents above so a fresh widespread blocker (pain 5, scale 5) reads ~65 on | |
| 69 | + | /// day one: High, with headroom to reach Critical within a couple of weeks, and | |
| 70 | + | /// the 0-100 number keeps a usable gradient across a months-long backlog instead | |
| 71 | + | /// of saturating everything to 100. | |
| 72 | + | const PAINHOURS_K: f64 = 266.0; | |
| 73 | + | ||
| 74 | + | /// Band cutoffs on the 0-100 painhours scale: `>=` each threshold, top-down. | |
| 75 | + | const BAND_CRITICAL: u32 = 75; | |
| 76 | + | const BAND_HIGH: u32 = 50; | |
| 77 | + | const BAND_MEDIUM: u32 = 20; | |
| 78 | + | ||
| 79 | + | /// Default 1-5 guesstimate for both `pain` and `scale` when unspecified. | |
| 80 | + | pub const DEFAULT_FACTOR: u8 = 3; | |
| 81 | + | ||
| 82 | + | /// Seconds in a week, the unit age is measured in. | |
| 83 | + | const SECS_PER_WEEK: f64 = 7.0 * 86_400.0; | |
| 84 | + | ||
| 85 | + | // -- Priority (color band) ---------------------------------------------------- | |
| 86 | + | ||
| 87 | + | /// The color band a painhours score falls into. | |
| 88 | + | /// | |
| 89 | + | /// Derived, never stored. Use it to color a row or to filter a list; sort by the | |
| 90 | + | /// score itself. | |
| 91 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] | |
| 92 | + | #[serde(rename_all = "lowercase")] | |
| 93 | + | pub enum Priority { | |
| 94 | + | Low, | |
| 95 | + | Medium, | |
| 96 | + | High, | |
| 97 | + | Critical, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl Priority { | |
| 101 | + | /// Bucket a 0-100 painhours score into a color band. | |
| 102 | + | pub fn from_painhours(score: u32) -> Self { | |
| 103 | + | if score >= BAND_CRITICAL { | |
| 104 | + | Self::Critical | |
| 105 | + | } else if score >= BAND_HIGH { | |
| 106 | + | Self::High | |
| 107 | + | } else if score >= BAND_MEDIUM { | |
| 108 | + | Self::Medium | |
| 109 | + | } else { | |
| 110 | + | Self::Low | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | impl fmt::Display for Priority { | |
| 116 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 117 | + | f.write_str(match self { | |
| 118 | + | Self::Low => "low", | |
| 119 | + | Self::Medium => "medium", | |
| 120 | + | Self::High => "high", | |
| 121 | + | Self::Critical => "critical", | |
| 122 | + | }) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | impl FromStr for Priority { | |
| 127 | + | type Err = String; | |
| 128 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 129 | + | match s.to_lowercase().as_str() { | |
| 130 | + | "low" => Ok(Self::Low), | |
| 131 | + | "medium" => Ok(Self::Medium), | |
| 132 | + | "high" => Ok(Self::High), | |
| 133 | + | "critical" => Ok(Self::Critical), | |
| 134 | + | _ => Err(format!("unknown priority: {s}")), | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // -- scoring ------------------------------------------------------------------ | |
| 140 | + | ||
| 141 | + | /// The painhours score: a 0-100 urgency number operators sort by. | |
| 142 | + | /// | |
| 143 | + | /// `round(100 * (1 - e^(-raw/K)))` where | |
| 144 | + | /// `raw = pain^PAIN_EXP * scale^SCALE_EXP * age_weeks^AGE_EXP`. | |
| 145 | + | /// | |
| 146 | + | /// `pain` and `scale` are clamped to 1-5, so out-of-range guesstimates degrade | |
| 147 | + | /// to the nearest valid factor instead of producing a wild score. `age_weeks` | |
| 148 | + | /// comes from [`age_weeks`]. | |
| 149 | + | pub fn painhours(pain: u8, scale: u8, age_weeks: u32) -> u32 { | |
| 150 | + | let pain = (pain.clamp(1, 5) as f64).powf(PAIN_EXP); | |
| 151 | + | let scale = (scale.clamp(1, 5) as f64).powf(SCALE_EXP); | |
| 152 | + | let age = (age_weeks.max(1) as f64).powf(AGE_EXP); | |
| 153 | + | let raw = pain * scale * age; | |
| 154 | + | (100.0 * (1.0 - (-raw / PAINHOURS_K).exp())).round() as u32 | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// The color band for a set of inputs, for callers that want the band without | |
| 158 | + | /// holding on to the score. | |
| 159 | + | pub fn band(pain: u8, scale: u8, age_weeks: u32) -> Priority { | |
| 160 | + | Priority::from_painhours(painhours(pain, scale, age_weeks)) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Age in whole weeks, rounded up (overestimating is fine), floored at 1 so a | |
| 164 | + | /// brand-new item still contributes to its score. | |
| 165 | + | /// | |
| 166 | + | /// `anchor` is the instant age is measured *to*, and it is the caller's decision: | |
| 167 | + | /// pass `Utc::now()` while the item is still live, and the instant it was | |
| 168 | + | /// resolved once it is terminal. Freezing the anchor on resolution is what stops | |
| 169 | + | /// closed items from climbing the list forever. | |
| 170 | + | pub fn age_weeks(created_at: DateTime<Utc>, anchor: DateTime<Utc>) -> u32 { | |
| 171 | + | let secs = (anchor - created_at).num_seconds().max(0); | |
| 172 | + | let weeks = (secs as f64 / SECS_PER_WEEK).ceil(); | |
| 173 | + | (weeks as u32).max(1) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// Human-readable age string for display, e.g. `3m`, `2h`, `5d`. | |
| 177 | + | /// | |
| 178 | + | /// Coarsest unit that is non-zero, floored at `1m` so a just-created item does | |
| 179 | + | /// not render as `0m`. | |
| 180 | + | pub fn age_label(created_at: DateTime<Utc>, now: DateTime<Utc>) -> String { | |
| 181 | + | let dur = now - created_at; | |
| 182 | + | if dur.num_days() > 0 { | |
| 183 | + | format!("{}d", dur.num_days()) | |
| 184 | + | } else if dur.num_hours() > 0 { | |
| 185 | + | format!("{}h", dur.num_hours()) | |
| 186 | + | } else { | |
| 187 | + | format!("{}m", dur.num_minutes().max(1)) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | #[cfg(test)] | |
| 192 | + | mod tests { | |
| 193 | + | use super::*; | |
| 194 | + | use chrono::Duration; | |
| 195 | + | ||
| 196 | + | #[test] | |
| 197 | + | fn priority_bands() { | |
| 198 | + | assert_eq!(Priority::from_painhours(0), Priority::Low); | |
| 199 | + | assert_eq!(Priority::from_painhours(19), Priority::Low); | |
| 200 | + | assert_eq!(Priority::from_painhours(20), Priority::Medium); | |
| 201 | + | assert_eq!(Priority::from_painhours(49), Priority::Medium); | |
| 202 | + | assert_eq!(Priority::from_painhours(50), Priority::High); | |
| 203 | + | assert_eq!(Priority::from_painhours(74), Priority::High); | |
| 204 | + | assert_eq!(Priority::from_painhours(75), Priority::Critical); | |
| 205 | + | assert_eq!(Priority::from_painhours(100), Priority::Critical); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[test] | |
| 209 | + | fn priority_roundtrips_through_string() { | |
| 210 | + | for p in [ | |
| 211 | + | Priority::Low, | |
| 212 | + | Priority::Medium, | |
| 213 | + | Priority::High, | |
| 214 | + | Priority::Critical, | |
| 215 | + | ] { | |
| 216 | + | assert_eq!(p.to_string().parse::<Priority>().unwrap(), p); | |
| 217 | + | } | |
| 218 | + | assert!("nonsense".parse::<Priority>().is_err()); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | #[test] | |
| 222 | + | fn score_stays_in_range() { | |
| 223 | + | // Every corner of the input space, plus a very old item, stays 0-100. | |
| 224 | + | for pain in 1..=5 { | |
| 225 | + | for scale in 1..=5 { | |
| 226 | + | for weeks in [1, 4, 52, 5_000] { | |
| 227 | + | let s = painhours(pain, scale, weeks); | |
| 228 | + | assert!(s <= 100, "pain {pain} scale {scale} weeks {weeks} = {s}"); | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | #[test] | |
| 235 | + | fn scale_outranks_pain_at_equal_age() { | |
| 236 | + | // SCALE_EXP > PAIN_EXP, so breadth drives the ranking. | |
| 237 | + | assert!(painhours(1, 5, 1) > painhours(5, 1, 1)); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | #[test] | |
| 241 | + | fn score_climbs_with_age() { | |
| 242 | + | assert!(painhours(3, 3, 4) > painhours(3, 3, 1)); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[test] | |
| 246 | + | fn fresh_blocker_reads_high() { | |
| 247 | + | // The calibration claim in PAINHOURS_K's docs: pain 5, scale 5, week one | |
| 248 | + | // lands in High with headroom below Critical. | |
| 249 | + | assert_eq!(band(5, 5, 1), Priority::High); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | #[test] | |
| 253 | + | fn factors_clamp_instead_of_exploding() { | |
| 254 | + | assert_eq!(painhours(0, 3, 1), painhours(1, 3, 1)); | |
| 255 | + | assert_eq!(painhours(9, 3, 1), painhours(5, 3, 1)); | |
| 256 | + | assert_eq!(painhours(3, 3, 0), painhours(3, 3, 1)); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | #[test] | |
| 260 | + | fn age_weeks_rounds_up_and_floors_at_one() { | |
| 261 | + | let created = DateTime::<Utc>::from_timestamp(0, 0).unwrap(); | |
| 262 | + | assert_eq!(age_weeks(created, created), 1); | |
| 263 | + | assert_eq!(age_weeks(created, created + Duration::days(1)), 1); | |
| 264 | + | assert_eq!(age_weeks(created, created + Duration::days(7)), 1); | |
| 265 | + | assert_eq!(age_weeks(created, created + Duration::days(8)), 2); | |
| 266 | + | // A clock skewed backwards must not underflow. | |
| 267 | + | assert_eq!(age_weeks(created, created - Duration::days(30)), 1); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | #[test] | |
| 271 | + | fn age_label_picks_the_coarsest_unit() { | |
| 272 | + | let created = DateTime::<Utc>::from_timestamp(0, 0).unwrap(); | |
| 273 | + | assert_eq!(age_label(created, created), "1m"); | |
| 274 | + | assert_eq!(age_label(created, created + Duration::minutes(45)), "45m"); | |
| 275 | + | assert_eq!(age_label(created, created + Duration::hours(5)), "5h"); | |
| 276 | + | assert_eq!(age_label(created, created + Duration::days(3)), "3d"); | |
| 277 | + | } | |
| 278 | + | } |
| @@ -1068,6 +1068,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1068 | 1068 | checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" | |
| 1069 | 1069 | ||
| 1070 | 1070 | [[package]] | |
| 1071 | + | name = "painhours" | |
| 1072 | + | version = "0.1.0" | |
| 1073 | + | dependencies = [ | |
| 1074 | + | "chrono", | |
| 1075 | + | "serde", | |
| 1076 | + | ] | |
| 1077 | + | ||
| 1078 | + | [[package]] | |
| 1071 | 1079 | name = "percent-encoding" | |
| 1072 | 1080 | version = "2.3.2" | |
| 1073 | 1081 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1924,6 +1932,7 @@ dependencies = [ | |||
| 1924 | 1932 | "clap", | |
| 1925 | 1933 | "color-eyre", | |
| 1926 | 1934 | "directories", | |
| 1935 | + | "painhours", | |
| 1927 | 1936 | "reqwest", | |
| 1928 | 1937 | "rusqlite", | |
| 1929 | 1938 | "serde", |
| @@ -14,6 +14,7 @@ chrono = { version = "0.4", features = ["serde"] } | |||
| 14 | 14 | clap = { version = "4", features = ["derive"] } | |
| 15 | 15 | color-eyre = "0.6" | |
| 16 | 16 | directories = "6" | |
| 17 | + | painhours = { path = "../shared/painhours" } | |
| 17 | 18 | rusqlite = { version = "0.39", features = ["bundled"] } | |
| 18 | 19 | serde = { version = "1", features = ["derive"] } | |
| 19 | 20 | serde_json = "1" |
| @@ -831,16 +831,6 @@ mod tests { | |||
| 831 | 831 | assert!(all[0].painhours() > all[1].painhours()); | |
| 832 | 832 | } | |
| 833 | 833 | ||
| 834 | - | #[test] | |
| 835 | - | fn band_buckets_by_score() { | |
| 836 | - | // Pure function: verify the color-band cutoffs. | |
| 837 | - | assert_eq!(Priority::from_painhours(0), Priority::Low); | |
| 838 | - | assert_eq!(Priority::from_painhours(19), Priority::Low); | |
| 839 | - | assert_eq!(Priority::from_painhours(20), Priority::Medium); | |
| 840 | - | assert_eq!(Priority::from_painhours(49), Priority::Medium); | |
| 841 | - | assert_eq!(Priority::from_painhours(50), Priority::High); | |
| 842 | - | assert_eq!(Priority::from_painhours(74), Priority::High); | |
| 843 | - | assert_eq!(Priority::from_painhours(75), Priority::Critical); | |
| 844 | - | assert_eq!(Priority::from_painhours(100), Priority::Critical); | |
| 845 | - | } | |
| 834 | + | // The color-band cutoffs are a pure function of the score and are tested in | |
| 835 | + | // the `painhours` crate, alongside the constants that set them. | |
| 846 | 836 | } |
| @@ -1,114 +1,22 @@ | |||
| 1 | - | //! Core types: Priority, Status, Channel, Ticket. | |
| 1 | + | //! Core types: Status, Channel, Ticket. | |
| 2 | 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: | |
| 3 | + | //! Ticket urgency is a computed **painhours** score rather than a stored enum: | |
| 4 | + | //! two 1-5 guesstimates, `pain` (how much it hurts a hit user) and `scale` (how | |
| 5 | + | //! broadly it hits), combined with the ticket's age as a power law. The model, | |
| 6 | + | //! its tuning constants, and the [`Priority`] color band all live in the shared | |
| 7 | + | //! `painhours` crate so wam and GoingsOn rank on one scale; see its crate docs. | |
| 7 | 8 | //! | |
| 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. | |
| 23 | - | ||
| 24 | - | use std::fmt; | |
| 25 | - | use std::str::FromStr; | |
| 9 | + | //! What stays here is the part that is wam's own: which instant age is measured | |
| 10 | + | //! to. [`Ticket::age_anchor`] freezes it at resolution so terminal tickets stop | |
| 11 | + | //! climbing the list. | |
| 26 | 12 | ||
| 27 | 13 | use chrono::{DateTime, Utc}; | |
| 28 | 14 | use serde::{Deserialize, Serialize}; | |
| 29 | 15 | ||
| 30 | - | // -- painhours tuning --------------------------------------------------------- | |
| 31 | - | ||
| 32 | - | /// Exponent on `pain` (severity). Gives severity a secondary pull below scale. | |
| 33 | - | const PAIN_EXP: f64 = 1.5; | |
| 34 | - | ||
| 35 | - | /// Exponent on `scale` (breadth). The largest of the three, which is what makes | |
| 36 | - | /// scale the driving factor in the ranking. | |
| 37 | - | const SCALE_EXP: f64 = 2.0; | |
| 38 | - | ||
| 39 | - | /// Exponent on `age_weeks`. The anti-starvation lever: higher = low-scale bugs | |
| 40 | - | /// climb to Critical faster instead of languishing. Together with `PAINHOURS_K` | |
| 41 | - | /// it sets how long the lowest-possible bug (pain 1, scale 1) takes to force its | |
| 42 | - | /// way to the top: at AGE_EXP=1.5 / K=266 that is ~1 year, while a moderate 3x3 | |
| 43 | - | /// bug reaches Critical in ~1 month. Raise this (or lower K) to escalate faster. | |
| 44 | - | /// It has no effect on the ordering of tickets that share an age. | |
| 45 | - | const AGE_EXP: f64 = 1.5; | |
| 46 | - | ||
| 47 | - | /// Saturation constant for the painhours curve. Larger = the score climbs more | |
| 48 | - | /// slowly, so a bigger `raw` is needed to approach 100. Calibrated with the | |
| 49 | - | /// exponents above so a fresh widespread blocker (pain 5, scale 5) reads ~65 on | |
| 50 | - | /// day one — High, with headroom to reach Critical within a couple of weeks — | |
| 51 | - | /// and the 0-100 number keeps a usable gradient across a months-long backlog | |
| 52 | - | /// instead of saturating everything to 100. | |
| 53 | - | const PAINHOURS_K: f64 = 266.0; | |
| 16 | + | pub(crate) use painhours::{DEFAULT_FACTOR, Priority}; | |
| 54 | 17 | ||
| 55 | - | /// Band cutoffs on the 0-100 painhours scale: `>=` each threshold, top-down. | |
| 56 | - | const BAND_CRITICAL: u32 = 75; | |
| 57 | - | const BAND_HIGH: u32 = 50; | |
| 58 | - | const BAND_MEDIUM: u32 = 20; | |
| 59 | - | ||
| 60 | - | /// Default 1-5 guesstimate for both `pain` and `scale` when unspecified. | |
| 61 | - | pub(crate) const DEFAULT_FACTOR: u8 = 3; | |
| 62 | - | ||
| 63 | - | // -- Priority (color band) ---------------------------------------------------- | |
| 64 | - | ||
| 65 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 66 | - | #[serde(rename_all = "lowercase")] | |
| 67 | - | pub(crate) enum Priority { | |
| 68 | - | Low, | |
| 69 | - | Medium, | |
| 70 | - | High, | |
| 71 | - | Critical, | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | impl Priority { | |
| 75 | - | /// Bucket a 0-100 painhours score into a color band. | |
| 76 | - | pub(crate) fn from_painhours(score: u32) -> Self { | |
| 77 | - | if score >= BAND_CRITICAL { | |
| 78 | - | Self::Critical | |
| 79 | - | } else if score >= BAND_HIGH { | |
| 80 | - | Self::High | |
| 81 | - | } else if score >= BAND_MEDIUM { | |
| 82 | - | Self::Medium | |
| 83 | - | } else { | |
| 84 | - | Self::Low | |
| 85 | - | } | |
| 86 | - | } | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | impl fmt::Display for Priority { | |
| 90 | - | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 91 | - | f.write_str(match self { | |
| 92 | - | Self::Low => "low", | |
| 93 | - | Self::Medium => "medium", | |
| 94 | - | Self::High => "high", | |
| 95 | - | Self::Critical => "critical", | |
| 96 | - | }) | |
| 97 | - | } | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | impl FromStr for Priority { | |
| 101 | - | type Err = String; | |
| 102 | - | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 103 | - | match s.to_lowercase().as_str() { | |
| 104 | - | "low" => Ok(Self::Low), | |
| 105 | - | "medium" => Ok(Self::Medium), | |
| 106 | - | "high" => Ok(Self::High), | |
| 107 | - | "critical" => Ok(Self::Critical), | |
| 108 | - | _ => Err(format!("unknown priority: {s}")), | |
| 109 | - | } | |
| 110 | - | } | |
| 111 | - | } | |
| 18 | + | use std::fmt; | |
| 19 | + | use std::str::FromStr; | |
| 112 | 20 | ||
| 113 | 21 | // -- Status ------------------------------------------------------------------- | |
| 114 | 22 | ||
| @@ -218,14 +126,7 @@ pub(crate) struct Ticket { | |||
| 218 | 126 | impl Ticket { | |
| 219 | 127 | /// Human-readable age string (e.g. "3m", "2h", "5d"). | |
| 220 | 128 | pub(crate) fn age(&self) -> String { | |
| 221 | - | let dur = Utc::now() - self.created_at; | |
| 222 | - | if dur.num_days() > 0 { | |
| 223 | - | format!("{}d", dur.num_days()) | |
| 224 | - | } else if dur.num_hours() > 0 { | |
| 225 | - | format!("{}h", dur.num_hours()) | |
| 226 | - | } else { | |
| 227 | - | format!("{}m", dur.num_minutes().max(1)) | |
| 228 | - | } | |
| 129 | + | painhours::age_label(self.created_at, Utc::now()) | |
| 229 | 130 | } | |
| 230 | 131 | ||
| 231 | 132 | /// The instant against which age is measured: frozen at resolution once the | |
| @@ -237,23 +138,14 @@ impl Ticket { | |||
| 237 | 138 | } | |
| 238 | 139 | } | |
| 239 | 140 | ||
| 240 | - | /// Age in whole weeks, rounded up (overestimating is fine), floored at 1 so | |
| 241 | - | /// a brand-new ticket still contributes to its score. | |
| 141 | + | /// Age in whole weeks, measured to [`Self::age_anchor`]. | |
| 242 | 142 | pub(crate) fn age_weeks(&self) -> u32 { | |
| 243 | - | let secs = (self.age_anchor() - self.created_at).num_seconds().max(0); | |
| 244 | - | let weeks = (secs as f64 / (7.0 * 86_400.0)).ceil(); | |
| 245 | - | (weeks as u32).max(1) | |
| 143 | + | painhours::age_weeks(self.created_at, self.age_anchor()) | |
| 246 | 144 | } | |
| 247 | 145 | ||
| 248 | 146 | /// The painhours score: a 0-100 urgency number operators sort by. | |
| 249 | - | /// `round(100 * (1 - e^(-raw/K)))` where | |
| 250 | - | /// `raw = pain^PAIN_EXP * scale^SCALE_EXP * age_weeks^AGE_EXP`. | |
| 251 | 147 | pub(crate) fn painhours(&self) -> u32 { | |
| 252 | - | let pain = (self.pain.clamp(1, 5) as f64).powf(PAIN_EXP); | |
| 253 | - | let scale = (self.scale.clamp(1, 5) as f64).powf(SCALE_EXP); | |
| 254 | - | let age = (self.age_weeks() as f64).powf(AGE_EXP); | |
| 255 | - | let raw = pain * scale * age; | |
| 256 | - | (100.0 * (1.0 - (-raw / PAINHOURS_K).exp())).round() as u32 | |
| 148 | + | painhours::painhours(self.pain, self.scale, self.age_weeks()) | |
| 257 | 149 | } | |
| 258 | 150 | ||
| 259 | 151 | /// Color band derived from the painhours score. |