Skip to content

Commit bde6718

Browse files
foharaclaude
andcommitted
fix: Use largest-remainder rounding so status percentages sum to 100%
Independent truncation caused categories to sum to less than 100% (e.g. 99% done + 0% waived = 99%). Now uses largest-remainder method to distribute rounding fairly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e2fe216 commit bde6718

1 file changed

Lines changed: 78 additions & 44 deletions

File tree

‎crates/lash-cli/src/commands/status.rs‎

Lines changed: 78 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -422,50 +422,11 @@ fn output_text(
422422
println!();
423423
}
424424

425-
// Summary section
426-
#[allow(
427-
clippy::cast_precision_loss,
428-
clippy::cast_possible_truncation,
429-
clippy::cast_sign_loss
430-
)]
431-
let open_pct = if counts.total > 0 {
432-
(counts.open as f64 / counts.total as f64 * 100.0) as u32
433-
} else {
434-
0
435-
};
436-
437-
#[allow(
438-
clippy::cast_precision_loss,
439-
clippy::cast_possible_truncation,
440-
clippy::cast_sign_loss
441-
)]
442-
let done_pct = if counts.total > 0 {
443-
(counts.done as f64 / counts.total as f64 * 100.0) as u32
444-
} else {
445-
0
446-
};
447-
448-
#[allow(
449-
clippy::cast_precision_loss,
450-
clippy::cast_possible_truncation,
451-
clippy::cast_sign_loss
452-
)]
453-
let waived_pct = if counts.total > 0 {
454-
(counts.waived as f64 / counts.total as f64 * 100.0) as u32
455-
} else {
456-
0
457-
};
458-
459-
#[allow(
460-
clippy::cast_precision_loss,
461-
clippy::cast_possible_truncation,
462-
clippy::cast_sign_loss
463-
)]
464-
let blocked_pct = if counts.total > 0 {
465-
(counts.blocked as f64 / counts.total as f64 * 100.0) as u32
466-
} else {
467-
0
468-
};
425+
// Summary section - use largest-remainder rounding so percentages sum to 100%
426+
let [open_pct, done_pct, waived_pct, blocked_pct] = round_percentages(
427+
counts.total,
428+
[counts.open, counts.done, counts.waived, counts.blocked],
429+
);
469430

470431
if let Some(t) = theme {
471432
println!("{}", t.style_label("Summary"));
@@ -482,6 +443,42 @@ fn output_text(
482443
}
483444
}
484445

446+
/// Round percentages using the largest-remainder method so they always sum to 100%.
447+
///
448+
/// Given a total and an array of counts, returns an array of integer percentages
449+
/// that sum to exactly 100 (or all zeros if total is 0).
450+
#[allow(
451+
clippy::cast_precision_loss,
452+
clippy::cast_possible_truncation,
453+
clippy::cast_sign_loss
454+
)]
455+
fn round_percentages<const N: usize>(total: usize, counts: [usize; N]) -> [u32; N] {
456+
if total == 0 {
457+
return [0; N];
458+
}
459+
460+
let total_f = total as f64;
461+
let exact: Vec<f64> = counts.iter().map(|&c| c as f64 / total_f * 100.0).collect();
462+
let mut floored: Vec<u32> = exact.iter().map(|&v| v as u32).collect();
463+
let remainder: u32 = 100 - floored.iter().sum::<u32>();
464+
465+
// Distribute remaining points to entries with largest fractional parts
466+
let mut indices: Vec<usize> = (0..N).collect();
467+
indices.sort_by(|&a, &b| {
468+
let frac_a = exact[a] - f64::from(floored[a]);
469+
let frac_b = exact[b] - f64::from(floored[b]);
470+
frac_b.partial_cmp(&frac_a).unwrap()
471+
});
472+
473+
for &i in indices.iter().take(remainder as usize) {
474+
floored[i] += 1;
475+
}
476+
477+
let mut result = [0u32; N];
478+
result.copy_from_slice(&floored);
479+
result
480+
}
481+
485482
/// Print a single task summary line
486483
fn print_task_summary(task: &TaskRecord, theme: Option<&CliTheme>) {
487484
let checkbox = match task.status {
@@ -617,4 +614,41 @@ mod tests {
617614
assert_eq!(summary.waived, 15);
618615
assert_eq!(summary.blocked, 5);
619616
}
617+
618+
#[test]
619+
fn test_round_percentages_exact() {
620+
// 25+25+25+25 = 100, no rounding needed
621+
let result = round_percentages(100, [25, 25, 25, 25]);
622+
assert_eq!(result, [25, 25, 25, 25]);
623+
assert_eq!(result.iter().sum::<u32>(), 100);
624+
}
625+
626+
#[test]
627+
fn test_round_percentages_zero_total() {
628+
let result = round_percentages(0, [0, 0, 0, 0]);
629+
assert_eq!(result, [0, 0, 0, 0]);
630+
}
631+
632+
#[test]
633+
fn test_round_percentages_172_of_173() {
634+
// The reported bug: 172/173 = 99.42%, 1/173 = 0.58%
635+
// Should round to 99% + 1% = 100%, not 99% + 0% = 99%
636+
let result = round_percentages(173, [0, 172, 1, 0]);
637+
assert_eq!(result, [0, 99, 1, 0]);
638+
assert_eq!(result.iter().sum::<u32>(), 100);
639+
}
640+
641+
#[test]
642+
fn test_round_percentages_thirds() {
643+
// 1/3 each ≈ 33.33% — two get 33%, one gets 34%
644+
let result = round_percentages(3, [1, 1, 1]);
645+
assert_eq!(result.iter().sum::<u32>(), 100);
646+
assert!(result.iter().all(|&v| v == 33 || v == 34));
647+
}
648+
649+
#[test]
650+
fn test_round_percentages_all_in_one() {
651+
let result = round_percentages(50, [50, 0, 0, 0]);
652+
assert_eq!(result, [100, 0, 0, 0]);
653+
}
620654
}

0 commit comments

Comments
 (0)