From c9bc517931171e2536e0f869e6f0269ba90457b1 Mon Sep 17 00:00:00 2001 From: fix2it Date: Sun, 18 Oct 2026 22:34:45 +0300 Subject: [PATCH] fix(theme): round short reset durations instead of truncating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit duration_short truncated to whole units, so 4d23h read "4d" and 59m40s could not be told from 59m. It now rounds: half steps below 10 h and 10 d (1.5ч, 2.5д), whole units above, minutes clamped to 59 so the next step is "1h", and 23h45m rolls over to "1d". Co-Authored-By: Claude Opus 5.5 --- src/theme_engine/tests.rs | 57 +++++++++++++++++++++++++- src/theme_engine/theme_expression.rs | 60 +++++++++++++++++++++------- 2 files changed, 102 insertions(+), 15 deletions(-) diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index f97ba5a1..14df2b7c 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -485,9 +485,64 @@ fn templates_apply_numeric_character_formats() { format_template("Used {claude.session.percentage:0.0}%", &context), "Used 73.5%" ); - assert_eq!(format_template("{reset:duration_short}", &context), "4d"); + assert_eq!(format_template("{reset:duration_short}", &context), "5d"); } +#[test] +fn duration_short_precision_scale_and_half_steps() { + let mut context = DataContext::default(); + context.insert_string("i18n.day_suffix", "д"); + context.insert_string("i18n.hour_suffix", "ч"); + context.insert_string("i18n.minute_suffix", "м"); + context.insert_string("i18n.second_suffix", "с"); + context.insert_string("i18n.now", "сейчас"); + + let check = |secs: f64, expected: &str, ctx: &DataContext| { + let mut c = ctx.clone(); + c.insert("val", secs); + assert_eq!(format_template("{val:duration_short}", &c), expected); + }; + + // Minutes under 1 hour (< 3600s) + check(3580.0, "59м", &context); // 59m 40s must clamp to 59m, never 60m + check(3300.0, "55м", &context); + check(2700.0, "45м", &context); + check(900.0, "15м", &context); + check(60.0, "1м", &context); + + // Hours under 10 hours (1h .. 10h): 0.5 step rounding with dot + check(3600.0, "1ч", &context); + check(3900.0, "1ч", &context); // 1h 05m -> 1ч + check(5400.0, "1.5ч", &context); // 1h 30m -> 1.5ч + check(7800.0, "2ч", &context); // 2h 10m -> 2ч + check(9300.0, "2.5ч", &context); // 2h 35m -> 2.5ч + check(10500.0, "3ч", &context); // 2h 55m -> 3ч + check(34200.0, "9.5ч", &context); // 9h 30m -> 9.5ч + + // Hours >= 10 hours: integer math rounding + check(36000.0, "10ч", &context); + check(43200.0, "12ч", &context); + check(66300.0, "18ч", &context); // 18h 25m -> 18ч + check(85500.0, "1д", &context); // 23h 45m -> rolls over to 1д + + // Days < 10 days: 0.5 step rounding with dot + check(86400.0, "1д", &context); + check(129600.0, "1.5д", &context); // 1d 12h -> 1.5д + check(216000.0, "2.5д", &context); // 2d 12h -> 2.5д + check(532800.0, "6д", &context); // 6d 4h -> strictly 6д + check(565200.0, "6.5д", &context); // 6d 13h -> 6.5д + check(604800.0, "7д", &context); // 7d -> 7д + + // Days >= 10 days: integer math rounding + check(892800.0, "10д", &context); // 10d 8h -> 10д + check(1036800.0, "12д", &context); // 12d -> 12д + + // Seconds and now + check(45.0, "45с", &context); + check(0.0, "сейчас", &context); +} + + #[test] fn text_templates_allow_if_to_return_quoted_strings() { let mut context = DataContext::default(); diff --git a/src/theme_engine/theme_expression.rs b/src/theme_engine/theme_expression.rs index 62bf59ca..1d0ff10d 100644 --- a/src/theme_engine/theme_expression.rs +++ b/src/theme_engine/theme_expression.rs @@ -582,26 +582,58 @@ pub(super) fn localized<'a>(context: &'a DataContext, name: &str, fallback: &'a context.get_string(name).unwrap_or(fallback) } +fn format_half_step(val: f64, suffix: &str) -> String { + let rounded = (val * 2.0).round() / 2.0; + if rounded.fract().abs() < 1e-4 { + format!("{:.0}{suffix}", rounded) + } else { + format!("{:.1}{suffix}", rounded) + } +} + +pub(super) fn format_duration_short(seconds: u64, context: &DataContext) -> String { + let day_suffix = localized(context, "i18n.day_suffix", "d"); + let hour_suffix = localized(context, "i18n.hour_suffix", "h"); + let minute_suffix = localized(context, "i18n.minute_suffix", "m"); + let second_suffix = localized(context, "i18n.second_suffix", "s"); + + if seconds >= 86_400 { + if seconds >= 864_000 { + let days = (seconds + 43_200) / 86_400; + format!("{days}{day_suffix}") + } else { + let days = seconds as f64 / 86_400.0; + format_half_step(days, day_suffix) + } + } else if seconds >= 3_600 { + if seconds >= 36_000 { + let hours = (seconds + 1800) / 3600; + if hours >= 24 { + format!("1{day_suffix}") + } else { + format!("{hours}{hour_suffix}") + } + } else { + let hours = seconds as f64 / 3600.0; + format_half_step(hours, hour_suffix) + } + } else if seconds >= 60 { + let minutes = ((seconds + 30) / 60).min(59); + format!("{minutes}{minute_suffix}") + } else if seconds > 0 { + format!("{seconds}{second_suffix}") + } else { + localized(context, "i18n.now", "now").to_string() + } +} + pub(super) fn format_value(value: f64, format: &str, context: &DataContext) -> String { if let Some(value) = format_timestamp(value, format, context) { return value; } if format.eq_ignore_ascii_case("duration_short") { let seconds = value.max(0.0).round() as u64; - let days = seconds / 86_400; - let hours = seconds / 3_600; - let minutes = seconds / 60; - return if days > 0 { - format!("{days}{}", localized(context, "i18n.day_suffix", "d")) - } else if hours > 0 { - format!("{hours}{}", localized(context, "i18n.hour_suffix", "h")) - } else if minutes > 0 { - format!("{minutes}{}", localized(context, "i18n.minute_suffix", "m")) - } else if seconds > 0 { - format!("{seconds}{}", localized(context, "i18n.second_suffix", "s")) - } else { - localized(context, "i18n.now", "now").to_string() - }; + return format_duration_short(seconds, context); } if format.eq_ignore_ascii_case("duration") { let seconds = value.max(0.0).round() as u64;