Skip to main content

ocpi_tariffs/explain/
render.rs

1//! Render an [`Explanation`] into Markdown prose in a chosen [`Language`].
2//!
3//! [`build`](super::tariff::build) has already made every semantic decision, so this module only
4//! chooses words, number formatting and word order. The Markdown skeleton (bold labels, bulleted
5//! tier lists, paragraph spacing) is shared across languages because the supported languages are
6//! all Germanic and phrase these structures alike; only the vocabulary carried by the [`Language`]
7//! methods differs. To add a language, add a variant and fill in each method's match arm.
8
9use chrono::{DateTime, NaiveDate, NaiveTime, TimeDelta, Utc};
10use rust_decimal::Decimal;
11
12use crate::{
13    currency, money::VatOrigin, tariff::v2x::DimensionType, Ampere, Kw, Kwh, Money, Price, Weekday,
14};
15
16use super::ir::{
17    Body, Bounds, Condition, ConditionPart, Dimension, Explanation, Fallback, Flat, FlatFee, Rate,
18    Scope, Section, TimeWindow, Validity,
19};
20
21/// A language an explanation can be rendered in. All supported languages are Germanic.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Language {
24    /// English (`en-US`).
25    EnUS,
26
27    /// Dutch (`nl-NL`).
28    NlNl,
29}
30
31/// Render a built explanation as Markdown in the given language.
32pub(super) fn render(explanation: &Explanation, language: Language) -> String {
33    let currency = explanation.currency;
34
35    match &explanation.body {
36        Body::Fallback(reason) => language.fallback(reason),
37        Body::Sections(sections) => sections
38            .iter()
39            .map(|section| render_section(section, currency, language))
40            .collect::<Vec<_>>()
41            .join("\n\n"),
42    }
43}
44
45/// Render one top-level section as its own paragraph (or bulleted block).
46fn render_section(section: &Section, currency: currency::Code, language: Language) -> String {
47    match section {
48        Section::Dimension(dimension) => render_dimension(dimension, currency, language),
49        Section::Flat(flat) => render_flat(flat, currency, language),
50        Section::Bounds(bounds) => language.bounds(bounds, currency),
51        Section::Validity(validity) => language.validity(validity),
52    }
53}
54
55/// Render a metered dimension: a single tier reads as one line, several tiers as a bulleted list,
56/// followed by any shared billing-step note and unreachable-tier note.
57fn render_dimension(dimension: &Dimension, currency: currency::Code, language: Language) -> String {
58    let subject = language.dimension_subject(dimension.kind);
59
60    // Each tier becomes (condition, rate body). A per-tier billing-step note is folded onto the
61    // body; it is only ever present when the dimension has several tiers, so it never shows on the
62    // single-line form.
63    let entries: Vec<(String, String)> = dimension
64        .tiers
65        .iter()
66        .map(|tier| {
67            let condition = render_condition(&tier.condition, dimension.kind, language);
68            let body = language.rate_body(&tier.rate, dimension.kind, currency);
69            let step = tier
70                .step
71                .map(|step| language.inline_step_note(step, dimension.kind))
72                .unwrap_or_default();
73            (condition, format!("{body}{step}"))
74        })
75        .collect();
76
77    let section = render_tier_list(subject, &entries);
78
79    let section = match dimension.uniform_step {
80        Some(step) => format!(
81            "{section}\n\n_{}_",
82            language.dimension_step_note(step, dimension.kind)
83        ),
84        None => section,
85    };
86
87    let section = if dimension.dropped_unreachable {
88        format!("{section}\n\n_{}_", language.dropped_tiers_note())
89    } else {
90        section
91    };
92
93    if dimension.dropped_shadowed {
94        format!("{section}\n\n_{}_", language.shadowed_tiers_note())
95    } else {
96        section
97    }
98}
99
100/// Render the flat fee: a single tier reads as one line, several as a bulleted list.
101fn render_flat(flat: &Flat, currency: currency::Code, language: Language) -> String {
102    let label = language.flat_fee_label();
103
104    let entries: Vec<(String, String)> = flat
105        .tiers
106        .iter()
107        .map(|tier| {
108            let condition = render_condition(&tier.condition, DimensionType::Flat, language);
109            let fee = language.flat_fee_body(&tier.fee, currency);
110            (condition, fee)
111        })
112        .collect();
113
114    let section = render_tier_list(label, &entries);
115
116    if flat.dropped_shadowed {
117        format!("{section}\n\n_{}_", language.shadowed_tiers_note())
118    } else {
119        section
120    }
121}
122
123/// Render a labelled list of `(condition, body)` tiers as a Markdown section.
124///
125/// A single unconditional tier reads as `**Label:** body.`; a single conditional tier as
126/// `**Label:** condition, body.`; and several tiers as a bulleted list, one `- Condition: body` per
127/// tier. Shared by the metered dimensions and the flat fee so they lay out identically.
128fn render_tier_list(label: &str, entries: &[(String, String)]) -> String {
129    match entries {
130        [(condition, body)] if condition.is_empty() => format!("**{label}:** {body}."),
131        [(condition, body)] => format!("**{label}:** {condition}, {body}."),
132        _ => {
133            let bullets: Vec<String> = entries
134                .iter()
135                .map(|(condition, body)| format!("- {}: {body}", capitalize_first(condition)))
136                .collect();
137            format!("**{label}:**\n\n{}", bullets.join("\n"))
138        }
139    }
140}
141
142/// Render a tier's condition. `kind` is only used by the "remaining ..." catch-all, which names the
143/// dimension's own quantity.
144fn render_condition(condition: &Condition, kind: DimensionType, language: Language) -> String {
145    match condition {
146        Condition::Always => String::new(),
147        Condition::Otherwise => language.otherwise().to_owned(),
148        Condition::Remaining => language.remaining(kind),
149        Condition::When(parts) => parts
150            .iter()
151            .map(|part| language.condition_part(part))
152            .collect::<Vec<_>>()
153            .join(", "),
154    }
155}
156
157/// Capitalize the first character of a string, leaving the rest untouched.
158fn capitalize_first(text: &str) -> String {
159    let mut chars = text.chars();
160    match chars.next() {
161        Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
162        None => String::new(),
163    }
164}
165
166impl Language {
167    /// The sentence subject / label for a metered dimension, e.g. "Charging time".
168    fn dimension_subject(self, kind: DimensionType) -> &'static str {
169        match kind {
170            DimensionType::Energy => match self {
171                Language::EnUS => "Energy",
172                Language::NlNl => "Energietarief",
173            },
174            DimensionType::Time => match self {
175                Language::EnUS => "Charging time",
176                Language::NlNl => "Tarief tijdens het laden",
177            },
178            DimensionType::ParkingTime => match self {
179                Language::EnUS => "Idle time (connected but not charging)",
180                Language::NlNl => "Parkeertijd (aangesloten maar niet aan het laden)",
181            },
182            // The flat fee is not a metered dimension; it has its own label.
183            DimensionType::Flat => "",
184        }
185    }
186
187    /// The per-unit rate phrase for a metered dimension, e.g. "per hour".
188    fn dimension_unit(self, kind: DimensionType) -> &'static str {
189        match kind {
190            DimensionType::Energy => "per kWh",
191            DimensionType::Time | DimensionType::ParkingTime => match self {
192                Language::EnUS => "per hour",
193                Language::NlNl => "per uur",
194            },
195            DimensionType::Flat => "",
196        }
197    }
198
199    /// The catch-all phrase naming the dimension's own remaining quantity, e.g. "for the remaining
200    /// charging time".
201    fn remaining(self, kind: DimensionType) -> String {
202        let phrase = match kind {
203            DimensionType::Energy => match self {
204                Language::EnUS => "for the remaining energy",
205                Language::NlNl => "de resterende energie",
206            },
207            DimensionType::Time => match self {
208                Language::EnUS => "for the remaining charging time",
209                Language::NlNl => "de resterende laadtijd",
210            },
211            DimensionType::ParkingTime => match self {
212                Language::EnUS => "for the remaining idle time",
213                Language::NlNl => "de resterende parkeertijd",
214            },
215            DimensionType::Flat => "",
216        };
217        phrase.to_owned()
218    }
219
220    /// The catch-all word used when an earlier tier was gated by a non-consumption qualifier.
221    fn otherwise(self) -> &'static str {
222        match self {
223            Language::EnUS => "otherwise",
224            Language::NlNl => "anders",
225        }
226    }
227
228    /// The label for the flat-fee section.
229    fn flat_fee_label(self) -> &'static str {
230        match self {
231            Language::EnUS => "Flat fee",
232            Language::NlNl => "Vast tarief",
233        }
234    }
235
236    /// The rate body of a metered tier: "free" or an amount with its per-unit phrase and VAT.
237    fn rate_body(self, rate: &Rate, kind: DimensionType, currency: currency::Code) -> String {
238        match rate {
239            Rate::Free => match self {
240                Language::EnUS => "free".to_owned(),
241                Language::NlNl => "gratis".to_owned(),
242            },
243            Rate::Priced { amount, vat } => format!(
244                "{} {}{}",
245                self.money(*amount, currency),
246                self.dimension_unit(kind),
247                self.vat_clause(*vat)
248            ),
249        }
250    }
251
252    /// The fee body of a flat tier: "no fee" or an amount charged per session, with VAT.
253    fn flat_fee_body(self, fee: &FlatFee, currency: currency::Code) -> String {
254        match fee {
255            FlatFee::NoFee => match self {
256                Language::EnUS => "no fee".to_owned(),
257                Language::NlNl => "geen kosten".to_owned(),
258            },
259            FlatFee::Charged { amount, vat } => {
260                let per_session = match self {
261                    Language::EnUS => "per session",
262                    Language::NlNl => "per sessie",
263                };
264                format!(
265                    "{} {per_session}{}",
266                    self.money(*amount, currency),
267                    self.vat_clause(*vat)
268                )
269            }
270        }
271    }
272
273    /// Render one clause of a tier's condition as a lowercase phrase.
274    fn condition_part(self, part: &ConditionPart) -> String {
275        match part {
276            ConditionPart::TimeWindow(window) => self.time_window(window),
277            ConditionPart::Weekdays(days) => {
278                let names: Vec<&str> = days.iter().copied().map(|day| self.weekday(day)).collect();
279                let on = match self {
280                    Language::EnUS => "on",
281                    Language::NlNl => "op",
282                };
283                format!("{on} {}", names.join(", "))
284            }
285            ConditionPart::DateRange { start, end } => self.date_range(*start, *end),
286            ConditionPart::MinPower(power) => match self {
287                Language::EnUS => format!("while charging at {} or more", self.kw(*power)),
288                Language::NlNl => format!("bij laadsnelheid van {} of meer", self.kw(*power)),
289            },
290            ConditionPart::MaxPower(power) => match self {
291                Language::EnUS => format!("while charging below {}", self.kw(*power)),
292                Language::NlNl => format!("bij laadsnelheid onder {}", self.kw(*power)),
293            },
294            ConditionPart::MinCurrent(current) => match self {
295                Language::EnUS => format!("at {} or more", self.ampere(*current)),
296                Language::NlNl => format!("bij {} of meer", self.ampere(*current)),
297            },
298            ConditionPart::MaxCurrent(current) => match self {
299                Language::EnUS => format!("below {}", self.ampere(*current)),
300                Language::NlNl => format!("onder {}", self.ampere(*current)),
301            },
302            ConditionPart::DurationScope(scope) => self.duration_scope(scope),
303            ConditionPart::EnergyScope(scope) => self.energy_scope(scope),
304        }
305    }
306
307    /// Render a time-of-day window clause.
308    fn time_window(self, window: &TimeWindow) -> String {
309        match (self, window) {
310            (Language::EnUS, TimeWindow::Empty { start, end }) => {
311                format!("never (its window {} to {} is empty)", hm(*start), hm(*end))
312            }
313            (
314                Language::EnUS,
315                TimeWindow::Wrapping { start, end } | TimeWindow::Between { start, end },
316            ) => {
317                format!("between {} and {}", hm(*start), hm(*end))
318            }
319            (Language::EnUS, TimeWindow::From { start }) => {
320                format!("from {} onwards", hm(*start))
321            }
322            (Language::EnUS, TimeWindow::Before { end }) => format!("before {}", hm(*end)),
323            (Language::NlNl, TimeWindow::Empty { start, end }) => {
324                format!(
325                    "nooit (het venster {} tot {} is leeg)",
326                    hm(*start),
327                    hm(*end)
328                )
329            }
330            (
331                Language::NlNl,
332                TimeWindow::Wrapping { start, end } | TimeWindow::Between { start, end },
333            ) => {
334                format!("tussen {} en {}", hm(*start), hm(*end))
335            }
336            (Language::NlNl, TimeWindow::From { start }) => format!("vanaf {}", hm(*start)),
337            (Language::NlNl, TimeWindow::Before { end }) => format!("voor {}", hm(*end)),
338        }
339    }
340
341    /// Render a calendar-date range clause. At least one of `start`/`end` is present.
342    fn date_range(self, start: Option<NaiveDate>, end: Option<NaiveDate>) -> String {
343        match (start, end) {
344            (Some(start), Some(end)) => match self {
345                Language::EnUS => format!("from {} until {}", date(start), date(end)),
346                Language::NlNl => format!("van {} tot {}", date(start), date(end)),
347            },
348            (Some(start), None) => match self {
349                Language::EnUS => format!("from {} onwards", date(start)),
350                Language::NlNl => format!("vanaf {}", date(start)),
351            },
352            (None, Some(end)) => match self {
353                Language::EnUS => format!("until {}", date(end)),
354                Language::NlNl => format!("tot {}", date(end)),
355            },
356            // Never built with both ends absent.
357            (None, None) => String::new(),
358        }
359    }
360
361    /// Render an elapsed-session-duration scope clause.
362    fn duration_scope(self, scope: &Scope<TimeDelta>) -> String {
363        match (self, scope) {
364            (Language::EnUS, Scope::UpTo(max)) => {
365                format!("for the first {}", self.duration(*max))
366            }
367            (Language::EnUS, Scope::After(min)) => {
368                format!("after the first {}", self.duration(*min))
369            }
370            (Language::EnUS, Scope::Between(min, max)) => format!(
371                "between {} and {} into the session",
372                self.duration(*min),
373                self.duration(*max)
374            ),
375            (Language::NlNl, Scope::UpTo(max)) => {
376                format!("het eerste {}", self.duration(*max))
377            }
378            (Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.duration(*min)),
379            (Language::NlNl, Scope::Between(min, max)) => format!(
380                "tussen {} en {} tijdens de sessie",
381                self.duration(*min),
382                self.duration(*max)
383            ),
384        }
385    }
386
387    /// Render a consumed-energy scope clause.
388    fn energy_scope(self, scope: &Scope<Kwh>) -> String {
389        match (self, scope) {
390            (Language::EnUS, Scope::UpTo(max)) => format!("for the first {}", self.kwh(*max)),
391            (Language::EnUS, Scope::After(min)) => format!("after the first {}", self.kwh(*min)),
392            (Language::EnUS, Scope::Between(min, max)) => {
393                format!("from {} to {}", self.kwh(*min), self.kwh(*max))
394            }
395            (Language::NlNl, Scope::UpTo(max)) => format!("het eerste {}", self.kwh(*max)),
396            (Language::NlNl, Scope::After(min)) => format!("na de eerste {}", self.kwh(*min)),
397            (Language::NlNl, Scope::Between(min, max)) => {
398                format!("van {} tot {}", self.kwh(*min), self.kwh(*max))
399            }
400        }
401    }
402
403    /// The overall `min_price`/`max_price` bounds, as one or two sentences.
404    fn bounds(self, bounds: &Bounds, currency: currency::Code) -> String {
405        let mut sentences = Vec::new();
406
407        if let Some(min) = bounds.min {
408            sentences.push(match self {
409                Language::EnUS => {
410                    format!(
411                        "A session always costs at least {}.",
412                        self.price(min, currency)
413                    )
414                }
415                Language::NlNl => {
416                    format!(
417                        "Een sessie kost altijd minstens {}.",
418                        self.price(min, currency)
419                    )
420                }
421            });
422        }
423        if let Some(max) = bounds.max {
424            sentences.push(match self {
425                Language::EnUS => {
426                    format!(
427                        "A session never costs more than {}.",
428                        self.price(max, currency)
429                    )
430                }
431                Language::NlNl => {
432                    format!(
433                        "Een sessie kost nooit meer dan {}.",
434                        self.price(max, currency)
435                    )
436                }
437            });
438        }
439
440        sentences.join(" ")
441    }
442
443    /// The validity window of the tariff itself.
444    fn validity(self, validity: &Validity) -> String {
445        match (self, validity) {
446            (Language::EnUS, Validity::Between { start, end }) => format!(
447                "This tariff is only valid from {} until {} (UTC).",
448                datetime(*start),
449                datetime(*end)
450            ),
451            (Language::EnUS, Validity::From { start }) => format!(
452                "This tariff only becomes active on {} (UTC).",
453                datetime(*start)
454            ),
455            (Language::EnUS, Validity::Until { end }) => format!(
456                "This tariff is no longer valid from {} (UTC).",
457                datetime(*end)
458            ),
459            (Language::NlNl, Validity::Between { start, end }) => format!(
460                "Dit tarief is alleen geldig van {} tot {} (UTC).",
461                datetime(*start),
462                datetime(*end)
463            ),
464            (Language::NlNl, Validity::From { start }) => {
465                format!("Dit tarief wordt pas actief op {} (UTC).", datetime(*start))
466            }
467            (Language::NlNl, Validity::Until { end }) => {
468                format!(
469                    "Dit tarief is niet langer geldig vanaf {} (UTC).",
470                    datetime(*end)
471                )
472            }
473        }
474    }
475
476    /// The reason a tariff produces no charging narrative.
477    fn fallback(self, reason: &Fallback) -> String {
478        match (self, reason) {
479            (Language::EnUS, Fallback::ReservationOnly) => {
480                "This tariff never charges a regular charging session: every element applies only \
481                 to reservation sessions."
482                    .to_owned()
483            }
484            (Language::EnUS, Fallback::NoPriceComponents) => {
485                "This tariff charges nothing: none of its applicable elements define a price \
486                 component."
487                    .to_owned()
488            }
489            (Language::EnUS, Fallback::FreeFlatOnly) => {
490                "This tariff is free: its only charge is a flat fee of zero.".to_owned()
491            }
492            (Language::NlNl, Fallback::ReservationOnly) => {
493                "Dit tarief brengt nooit kosten in rekening voor een gewone laadsessie: elk element \
494                 geldt alleen voor reserveringssessies."
495                    .to_owned()
496            }
497            (Language::NlNl, Fallback::NoPriceComponents) => {
498                "Dit tarief brengt niets in rekening: geen van de toepasselijke elementen \
499                 definieert een prijscomponent."
500                    .to_owned()
501            }
502            (Language::NlNl, Fallback::FreeFlatOnly) => {
503                "Dit tarief is gratis: er zijn geen kosten.".to_owned()
504            }
505        }
506    }
507
508    /// The note describing a billing step shared by every tier of a dimension (without the
509    /// surrounding italics).
510    fn dimension_step_note(self, step_size: u64, kind: DimensionType) -> String {
511        let magnitude = self.step_magnitude(step_size, kind);
512        match self {
513            Language::EnUS => format!("Billed in steps of {magnitude}, rounded up."),
514            Language::NlNl => format!("Berekend in stappen van {magnitude}, naar boven afgerond."),
515        }
516    }
517
518    /// The inline note describing a single tier's own billing step, as a trailing clause.
519    fn inline_step_note(self, step_size: u64, kind: DimensionType) -> String {
520        let magnitude = self.step_magnitude(step_size, kind);
521        match self {
522            Language::EnUS => format!(" (billed in steps of {magnitude}, rounded up)"),
523            Language::NlNl => {
524                format!(" (berekend in stappen van {magnitude}, naar boven afgerond)")
525            }
526        }
527    }
528
529    /// The note that unreachable tiers were dropped (without the surrounding italics).
530    fn dropped_tiers_note(self) -> &'static str {
531        match self {
532            Language::EnUS => {
533                "Any later tiers never apply, because an earlier rate already matches every session."
534            }
535            Language::NlNl => {
536                "Latere niveaus gelden nooit, omdat een eerder tarief al op elke sessie van toepassing is."
537            }
538        }
539    }
540
541    /// Add a note dropping shadowed tiers. Where a previous tier's date range already covered the current tiers range.
542    fn shadowed_tiers_note(self) -> &'static str {
543        match self {
544            Language::EnUS => {
545                "Tiers that never apply are not listed, because an earlier rate already covers the same dates."
546            }
547            Language::NlNl => {
548                "Niveaus die nooit gelden zijn niet vermeld, omdat een eerder tarief dezelfde periode al dekt."
549            }
550        }
551    }
552
553    /// The magnitude of a billing step in the unit appropriate to the dimension, e.g. "0.1 kWh" or
554    /// "1 minute".
555    ///
556    /// The energy `step_size` is given in Wh by the spec, but it is shown in kWh because readers
557    /// expect energy in kWh and tend to read "Wh" as a typo. Time steps are humanized (60 -> "1
558    /// minute", 900 -> "15 minutes") rather than shown as a raw second count.
559    fn step_magnitude(self, step_size: u64, kind: DimensionType) -> String {
560        match kind {
561            DimensionType::Energy => {
562                let kwh = Kwh::from_watt_hours(Decimal::from(step_size));
563                format!("{} kWh", self.decimal(Decimal::from(kwh).normalize()))
564            }
565            DimensionType::Time | DimensionType::ParkingTime => {
566                self.duration_seconds(i64::try_from(step_size).unwrap_or(i64::MAX))
567            }
568            DimensionType::Flat => String::new(),
569        }
570    }
571
572    /// Format a `Money` amount with its currency symbol.
573    ///
574    /// Two decimals reads best for ordinary prices, but a small nonzero rate must never collapse to
575    /// a "0.00" string and read as free; in that case the value's own precision is used instead.
576    fn money(self, money: Money, currency: currency::Code) -> String {
577        let amount = Decimal::from(money);
578
579        let symbol =
580            if let currency::Code::Eur | currency::Code::Gbp | currency::Code::Usd = currency {
581                currency.into_symbol()
582            } else {
583                currency.into_str()
584            };
585
586        let digits = if amount != Decimal::ZERO && amount.round_dp(2) == Decimal::ZERO {
587            self.decimal(amount.normalize())
588        } else {
589            self.decimal_fixed(amount)
590        };
591
592        match self {
593            Language::EnUS => format!("{symbol}{digits}"),
594            Language::NlNl => format!("{symbol} {digits}"),
595        }
596    }
597
598    /// Format a `Price` (which may carry a VAT-inclusive value) with its currency symbol.
599    fn price(self, price: Price, currency: currency::Code) -> String {
600        let incl_vat = match self {
601            Language::EnUS => "incl. VAT",
602            Language::NlNl => "incl. btw",
603        };
604        match price.incl_vat {
605            Some(incl) => format!(
606                "{} ({} {incl_vat})",
607                self.money(price.excl_vat, currency),
608                self.money(incl, currency)
609            ),
610            None => self.money(price.excl_vat, currency),
611        }
612    }
613
614    /// A trailing clause describing the VAT applied to a rate, or an empty string when none applies.
615    fn vat_clause(self, vat: VatOrigin) -> String {
616        match vat {
617            // `v2.1.1` tariffs carry no VAT information, so saying nothing is the honest choice.
618            VatOrigin::Unknown | VatOrigin::NotProvided => String::new(),
619            VatOrigin::Provided(vat) => {
620                let percent = self.decimal(Decimal::from(vat).normalize());
621                match self {
622                    Language::EnUS => format!(" (excl. {percent} VAT)"),
623                    Language::NlNl => format!(" (excl. {percent} btw)"),
624                }
625            }
626        }
627    }
628
629    /// Format a `Kwh` value without trailing zeros, e.g. "20 kWh".
630    fn kwh(self, value: Kwh) -> String {
631        format!("{} kWh", self.decimal(Decimal::from(value).normalize()))
632    }
633
634    /// Format a `Kw` value without trailing zeros, e.g. "11 kW".
635    fn kw(self, value: Kw) -> String {
636        format!("{} kW", self.decimal(Decimal::from(value).normalize()))
637    }
638
639    /// Format an `Ampere` value without trailing zeros, e.g. "16 A".
640    fn ampere(self, value: Ampere) -> String {
641        format!("{} A", self.decimal(Decimal::from(value).normalize()))
642    }
643
644    /// Render a duration as a friendly phrase such as "3 hours" or "1 hour 30 minutes".
645    fn duration(self, duration: TimeDelta) -> String {
646        self.duration_seconds(duration.num_seconds().max(0))
647    }
648
649    /// Render a whole number of seconds as a friendly phrase such as "3 hours" or "1 minute".
650    fn duration_seconds(self, total_seconds: i64) -> String {
651        let total_seconds = total_seconds.max(0);
652        let hours = total_seconds / 3600;
653        let minutes = (total_seconds % 3600) / 60;
654        let seconds = total_seconds % 60;
655
656        let mut parts = Vec::new();
657
658        if hours > 0 {
659            parts.push(self.time_unit(hours, TimeUnit::Hour));
660        }
661        if minutes > 0 {
662            parts.push(self.time_unit(minutes, TimeUnit::Minute));
663        }
664        if seconds > 0 {
665            parts.push(self.time_unit(seconds, TimeUnit::Second));
666        }
667
668        if parts.is_empty() {
669            self.time_unit(0, TimeUnit::Second)
670        } else {
671            parts.join(" ")
672        }
673    }
674
675    /// Format a count with a singular/plural time unit, e.g. "1 hour" / "3 hours".
676    fn time_unit(self, count: i64, unit: TimeUnit) -> String {
677        let noun = match (self, unit) {
678            (Language::EnUS, TimeUnit::Hour) if count == 1 => "hour",
679            (Language::EnUS, TimeUnit::Hour) => "hours",
680            (Language::EnUS, TimeUnit::Minute) if count == 1 => "minute",
681            (Language::EnUS, TimeUnit::Minute) => "minutes",
682            (Language::EnUS, TimeUnit::Second) if count == 1 => "second",
683            (Language::EnUS, TimeUnit::Second) => "seconds",
684            // Dutch "uur" does not inflect for number: "1 uur", "2 uur".
685            (Language::NlNl, TimeUnit::Hour) => "uur",
686            (Language::NlNl, TimeUnit::Minute) if count == 1 => "minuut",
687            (Language::NlNl, TimeUnit::Minute) => "minuten",
688            (Language::NlNl, TimeUnit::Second) if count == 1 => "seconde",
689            (Language::NlNl, TimeUnit::Second) => "seconden",
690        };
691        format!("{count} {noun}")
692    }
693
694    /// The English name of a weekday; Dutch names are lowercase, as Dutch does not capitalize them.
695    fn weekday(self, day: Weekday) -> &'static str {
696        match (self, day) {
697            (Language::EnUS, Weekday::Monday) => "Monday",
698            (Language::EnUS, Weekday::Tuesday) => "Tuesday",
699            (Language::EnUS, Weekday::Wednesday) => "Wednesday",
700            (Language::EnUS, Weekday::Thursday) => "Thursday",
701            (Language::EnUS, Weekday::Friday) => "Friday",
702            (Language::EnUS, Weekday::Saturday) => "Saturday",
703            (Language::EnUS, Weekday::Sunday) => "Sunday",
704            (Language::NlNl, Weekday::Monday) => "maandag",
705            (Language::NlNl, Weekday::Tuesday) => "dinsdag",
706            (Language::NlNl, Weekday::Wednesday) => "woensdag",
707            (Language::NlNl, Weekday::Thursday) => "donderdag",
708            (Language::NlNl, Weekday::Friday) => "vrijdag",
709            (Language::NlNl, Weekday::Saturday) => "zaterdag",
710            (Language::NlNl, Weekday::Sunday) => "zondag",
711        }
712    }
713
714    /// Render a normalized decimal, using the language's decimal separator.
715    fn decimal(self, value: Decimal) -> String {
716        let text = value.to_string();
717        match self {
718            Language::EnUS => text,
719            Language::NlNl => text.replace('.', ","),
720        }
721    }
722
723    /// Render a decimal fixed to two places, using the language's decimal separator.
724    fn decimal_fixed(self, value: Decimal) -> String {
725        let text = format!("{value:.2}");
726        match self {
727            Language::EnUS => text,
728            Language::NlNl => text.replace('.', ","),
729        }
730    }
731}
732
733/// A unit of elapsed time, used to select the right singular/plural noun.
734#[derive(Clone, Copy)]
735enum TimeUnit {
736    Hour,
737    Minute,
738    Second,
739}
740
741/// Format a `NaiveTime` as `HH:MM`. Shared across languages.
742fn hm(time: NaiveTime) -> String {
743    time.format("%H:%M").to_string()
744}
745
746/// Format a `NaiveDate` as an ISO `YYYY-MM-DD` date. Shared across languages for unambiguity.
747fn date(value: NaiveDate) -> String {
748    value.to_string()
749}
750
751/// Format a UTC instant as `YYYY-MM-DD HH:MM`. Shared across languages.
752fn datetime(value: DateTime<Utc>) -> String {
753    value.format("%Y-%m-%d %H:%M").to_string()
754}