Skip to content

Commit 5e9ec0e

Browse files
Olivier Bonnaureclaude
andcommitted
feat(duration): add Duration#humanize with I18n-compatible compound units
Adds Duration.humanize(locale?) instance method that renders a duration as a human-readable compound string ("1 hour 1 minute", "16 minutes 40 seconds"). Selects best unit(s) based on magnitude: minutes+seconds for sub-hour, hours+minutes for sub-day, days+hours for longer. Uses I18n.translate with {count} interpolation for localization; falls back to English plurals when no translation key is found. Accepts optional locale override; defaults to current I18n locale. Also adds duration.seconds/minutes/hours/days fallback keys to template/config/locales/en.yml. Closes tasks/todo/add-duration-humanize-instance-method-i18n-compatible-compound-units.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ab081cb commit 5e9ec0e

4 files changed

Lines changed: 190 additions & 0 deletions

File tree

src/interpreter/builtins/datetime_class.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::rc::Rc;
99

1010
use chrono::{Datelike, Local, Timelike};
1111

12+
use super::i18n::helpers::{get_locale as i18n_get_locale, interpolate, lookup_translation};
1213
use crate::interpreter::environment::Environment;
1314
use crate::interpreter::value::{Class, Instance, NativeFunction, Value};
1415

@@ -64,6 +65,117 @@ fn parse_datetime_string(s: &str) -> Result<i64, String> {
6465
}
6566
}
6667

68+
fn humanize_duration(seconds: f64, locale: &str) -> String {
69+
let total_secs = seconds.abs();
70+
let (primary_unit, primary_count, secondary_count) = if total_secs < 60.0 {
71+
("seconds", total_secs as i64, 0)
72+
} else if total_secs < 3600.0 {
73+
let minutes = (total_secs / 60.0).floor();
74+
let remaining_secs = (total_secs % 60.0).floor();
75+
(
76+
"minutes",
77+
minutes as i64,
78+
if remaining_secs > 0.0 {
79+
remaining_secs as i64
80+
} else {
81+
0
82+
},
83+
)
84+
} else if total_secs < 86400.0 {
85+
let hours = (total_secs / 3600.0).floor();
86+
let remaining_mins = ((total_secs % 3600.0) / 60.0).floor();
87+
(
88+
"hours",
89+
hours as i64,
90+
if remaining_mins > 0.0 {
91+
remaining_mins as i64
92+
} else {
93+
0
94+
},
95+
)
96+
} else {
97+
let days = (total_secs / 86400.0).floor();
98+
let remaining_hrs = ((total_secs % 86400.0) / 3600.0).floor();
99+
(
100+
"days",
101+
days as i64,
102+
if remaining_hrs > 0.0 {
103+
remaining_hrs as i64
104+
} else {
105+
0
106+
},
107+
)
108+
};
109+
let key = format!("duration.{}", primary_unit);
110+
let primary_translated = lookup_translation(locale, &key).unwrap_or_else(|| {
111+
let count_str = primary_count.to_string();
112+
match primary_unit {
113+
"seconds" => format!(
114+
"{} second{}",
115+
count_str,
116+
if primary_count == 1 { "" } else { "s" }
117+
),
118+
"minutes" => format!(
119+
"{} minute{}",
120+
count_str,
121+
if primary_count == 1 { "" } else { "s" }
122+
),
123+
"hours" => format!(
124+
"{} hour{}",
125+
count_str,
126+
if primary_count == 1 { "" } else { "s" }
127+
),
128+
"days" => format!(
129+
"{} day{}",
130+
count_str,
131+
if primary_count == 1 { "" } else { "s" }
132+
),
133+
_ => format!("{} {}", count_str, primary_unit),
134+
}
135+
});
136+
let primary_formatted = interpolate(
137+
&primary_translated,
138+
&[("count".to_string(), primary_count.to_string())],
139+
);
140+
if secondary_count > 0 {
141+
let sec_key = if primary_unit == "minutes" {
142+
"seconds"
143+
} else if primary_unit == "hours" {
144+
"minutes"
145+
} else {
146+
"hours"
147+
};
148+
let secondary_translated = lookup_translation(locale, sec_key).unwrap_or_else(|| {
149+
let count_str = secondary_count.to_string();
150+
match sec_key {
151+
"seconds" => format!(
152+
"{} second{}",
153+
count_str,
154+
if secondary_count == 1 { "" } else { "s" }
155+
),
156+
"minutes" => format!(
157+
"{} minute{}",
158+
count_str,
159+
if secondary_count == 1 { "" } else { "s" }
160+
),
161+
"hours" => format!(
162+
"{} hour{}",
163+
count_str,
164+
if secondary_count == 1 { "" } else { "s" }
165+
),
166+
_ => format!("{} {}", count_str, sec_key),
167+
}
168+
});
169+
let secondary_formatted = interpolate(
170+
&secondary_translated,
171+
&[("count".to_string(), secondary_count.to_string())],
172+
);
173+
format!("{} {}", primary_formatted, secondary_formatted)
174+
} else {
175+
primary_formatted
176+
}
177+
}
178+
67179
pub fn register_datetime_and_duration_classes(env: &mut Environment) {
68180
// Build DateTime instance methods
69181
let mut dt_native_methods: HashMap<String, Rc<NativeFunction>> = HashMap::new();
@@ -408,6 +520,35 @@ pub fn register_datetime_and_duration_classes(env: &mut Environment) {
408520
})),
409521
);
410522

523+
dur_native_methods.insert(
524+
String::from("humanize"),
525+
Rc::new(NativeFunction::new(
526+
"Duration.humanize",
527+
None,
528+
move |args| {
529+
let this = match args.first() {
530+
Some(Value::Instance(inst)) => inst,
531+
_ => return Err("Duration.humanize() called on non-Duration".to_string()),
532+
};
533+
let locale = if args.len() > 1 {
534+
match &args[1] {
535+
Value::String(s) => s.clone(),
536+
Value::Null => i18n_get_locale(),
537+
_ => return Err("Duration.humanize() locale must be a string".to_string()),
538+
}
539+
} else {
540+
i18n_get_locale()
541+
};
542+
let seconds = match this.borrow().fields.get("seconds").cloned() {
543+
Some(Value::Float(s)) => s,
544+
Some(Value::Int(s)) => s as f64,
545+
_ => return Err("Duration missing seconds".to_string()),
546+
};
547+
Ok(Value::String(humanize_duration(seconds, &locale)))
548+
},
549+
)),
550+
);
551+
411552
// Clone for use in instance methods that create new DateTime instances
412553
let dt_methods_for_add_days = dt_native_methods.clone();
413554
let dt_methods_for_add_hours = dt_native_methods.clone();

template/config/locales/en.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@ en:
1313
app:
1414
welcome: Welcome
1515
greeting: "Hello, {name}!"
16+
duration:
17+
seconds: "{count} second{count}"
18+
minutes: "{count} minute{count}"
19+
hours: "{count} hour{count}"
20+
days: "{count} day{count}"

www/app/views/docs/builtins/duration.html.slv

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,31 @@ println(duration.total_days()) # 7</code></pre>
217217
println(duration.to_string()) # "3661s"</code></pre>
218218
</div>
219219
</section>
220+
221+
<section id="def-humanize" class="scroll-mt-20 mb-6">
222+
<div class="rounded-xl bg-white/5 border border-white/10 p-5">
223+
<a href="#def-humanize" class="group flex items-center gap-2 mb-3">
224+
<code class="text-lg font-mono text-indigo-400">.humanize(locale?)</code>
225+
<svg class="w-4 h-4 opacity-0 group-hover:opacity-100 text-gray-500 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
226+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
227+
</svg>
228+
</a>
229+
<p class="text-gray-400 mb-3">Get the duration as a human-readable compound string (e.g., "1 hour 1 minute"). Selects the most appropriate unit(s) based on the duration length.</p>
230+
<h4 class="text-sm font-semibold text-gray-300 mt-4 mb-2">Parameters</h4>
231+
<div class="text-sm text-gray-400">
232+
<code class="text-amber-400">locale</code> : <code class="text-purple-400">String?</code> - Optional locale code (defaults to current I18n locale)
233+
</div>
234+
<h4 class="text-sm font-semibold text-gray-300 mt-4 mb-2">Returns</h4>
235+
<div class="text-sm text-gray-400">
236+
<code class="text-purple-400">String</code> - Humanized duration string
237+
</div>
238+
<pre data-filename="Example"><code class="language-soli text-sm">Duration.seconds(3661).humanize() # "1 hour 1 minute"
239+
Duration.seconds(1000).humanize() # "16 minutes 40 seconds"
240+
Duration.seconds(7200).humanize() # "2 hours"
241+
Duration.seconds(90).humanize() # "1 minute 30 seconds"
242+
Duration.minutes(5).humanize() # "5 minutes"</code></pre>
243+
</div>
244+
</section>
220245
</section>
221246

222247
<!-- Complete Example -->

www/docs/builtins.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2191,6 +2191,25 @@ duration = Duration.of_seconds(3661)
21912191
println(duration.to_string()) # "3661s"
21922192
```
21932193

2194+
#### .humanize(locale?)
2195+
2196+
Gets the duration as a human-readable compound string (e.g., "1 hour 1 minute"). Selects the most appropriate unit(s) based on the duration length — for sub-hour durations it combines minutes + seconds; for sub-day it combines hours + minutes; for longer durations it combines days + hours. The optional locale parameter overrides the current I18n locale for translation.
2197+
2198+
**Parameters:**
2199+
- `locale` (String, optional) - Locale code for translation (defaults to current I18n locale)
2200+
2201+
**Returns:** String
2202+
2203+
**Example:**
2204+
```soli
2205+
Duration.seconds(3661).humanize() # "1 hour 1 minute"
2206+
Duration.seconds(1000).humanize() # "16 minutes 40 seconds"
2207+
Duration.seconds(7200).humanize() # "2 hours"
2208+
Duration.seconds(90).humanize() # "1 minute 30 seconds"
2209+
Duration.minutes(5).humanize() # "5 minutes"
2210+
Duration.humanize("fr") # respects fr locale if translations exist
2211+
```
2212+
21942213
### Complete Example
21952214

21962215
```soli

0 commit comments

Comments
 (0)