Skip to main content

renox_core/
chart.rs

1//! Dashboards: numbers over time from the database ([`Trend`] over a
2//! [`Period`], giving a [`Series`]), and the `chart(…)` template function
3//! that draws them (line, area, bar, pie, doughnut, and scatter or bubble
4//! charts of points) as plain HTML and SVG, with the UI kit's `stat`,
5//! `widget` and `period_filter` around them.
6//!
7//! ```
8//! # use renox::prelude::*;
9//! use renox::chart::{Period, Trend};
10//!
11//! #[derive(Model, serde::Serialize, Default)]
12//! struct Order { id: i64, total: i64, status: String, created_at: Option<renox::db::DateTime> }
13//!
14//! // `?period=30d` (7d, 30d, 90d, 12w, 12m, mtd, ytd, or
15//! // `?period=custom&from=2026-09-01&to=2026-09-30`; 30 days without one).
16//! async fn dashboard(State(state): State<AppState>, period: Period) -> Result<View> {
17//!     let paid = || Order::where_eq("status", "paid");
18//!     let sales = Trend::of(paid(), "created_at").over(period).sum(&state, "total").await?;
19//!     let before = Trend::of(paid(), "created_at").over(period.previous()).sum(&state, "total").await?;
20//!     let orders = Trend::of(Order::query(), "created_at").over(period).count(&state).await?;
21//!     Ok(view("dashboard.html", context! {
22//!         period,
23//!         revenue => sales.total(),
24//!         change => sales.change_from(&before), // percent, None without a base
25//!         sales => sales.named("Sales"),
26//!         orders,
27//!     }))
28//! }
29//! ```
30//!
31//! ```text
32//! {% from "renox/ui.html" import stats, stat, dashboard, widget, period_filter %}
33//! {{ period_filter(period) }}
34//! {% call stats(3) %}
35//!   {{ stat("Revenue", revenue | money, delta=change, trend=sales.values) }}
36//! {% endcall %}
37//! {% call dashboard(2) %}
38//!   {% call widget("Sales", span=2) %}{{ chart("area", sales, format="money") }}{% endcall %}
39//! {% endcall %}
40//! ```
41
42use std::fmt::Write as _;
43
44use axum::extract::FromRequestParts;
45use axum::http::request::Parts;
46use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
47use minijinja::value::{Kwargs, Value, ValueKind};
48use minijinja::{Error, ErrorKind, State};
49use serde::{Deserialize, Serialize, Serializer};
50
51use crate::db::{DateTime, Dialect, Model, Query};
52use crate::timezone::Zone;
53use crate::toast::escape;
54use crate::view_filters::{format_money, format_number, money_divisor};
55use crate::{AppState, Result};
56
57/// How long a dashboard looks back: `7d`, `30d`, `90d` (any number of days
58/// up to 366), `12w` (weeks up to 104), `12m` (months up to 36), `mtd` (this
59/// month so far), `ytd` (this year so far), or a custom range of dates
60/// (`2026-09-01..2026-09-30`, both days included, at most 1,096 days), in
61/// `APP_TIMEZONE`.
62///
63/// As an extractor it reads `?period=`, or `?period=custom&from=2026-09-01&to=2026-09-30`
64/// (what the kit's `period_filter` sends for a custom range); anything it
65/// can't read (an unknown key, a date that isn't one, `from` after `to`, a
66/// range over three years) gives the default, 30 days. It serializes as its
67/// key (`"30d"`, `"2026-09-01..2026-09-30"`), which `period_filter` takes.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct Period {
70    span: Span,
71    back: u32,
72    per: Option<Bucket>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum Span {
77    Days(u32),
78    Weeks(u32),
79    Months(u32),
80    MonthToDate,
81    YearToDate,
82    /// The first day and the day after the last.
83    Between(NaiveDate, NaiveDate),
84}
85
86/// The longest custom range, in days (three years and a leap day).
87const MAX_RANGE_DAYS: i64 = 1096;
88
89/// The step of a [`Series`]: one value per day, week or month.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum Bucket {
93    /// Labels `2026-10-02`.
94    Day,
95    /// Labels `2026-09-28`: the Monday each week starts on (ISO 8601
96    /// weeks, Monday to Sunday, in `APP_TIMEZONE`). The first and last
97    /// weeks of a period may be cut short by it.
98    Week,
99    /// Labels `2026-10`.
100    Month,
101}
102
103impl Default for Period {
104    fn default() -> Self {
105        Period::days(30)
106    }
107}
108
109impl Period {
110    fn of(span: Span) -> Self {
111        Self {
112            span,
113            back: 0,
114            per: None,
115        }
116    }
117
118    /// The last `n` days, today included.
119    pub fn days(n: u32) -> Self {
120        Self::of(Span::Days(n.clamp(1, 366)))
121    }
122
123    /// The last `n` weeks, this one so far included: from the Monday `n - 1`
124    /// weeks before this week's Monday, to today. Per week.
125    pub fn weeks(n: u32) -> Self {
126        Self::of(Span::Weeks(n.clamp(1, 104)))
127    }
128
129    /// The last `n` months, this one included.
130    pub fn months(n: u32) -> Self {
131        Self::of(Span::Months(n.clamp(1, 36)))
132    }
133
134    /// This month so far.
135    pub fn month_to_date() -> Self {
136        Self::of(Span::MonthToDate)
137    }
138
139    /// This year so far.
140    pub fn year_to_date() -> Self {
141        Self::of(Span::YearToDate)
142    }
143
144    /// From `from` to `to`, both days included (local days in
145    /// `APP_TIMEZONE`). `None` when `from` is after `to` or the range is
146    /// longer than 1,096 days (three years).
147    pub fn between(from: NaiveDate, to: NaiveDate) -> Option<Self> {
148        let end = to.succ_opt()?;
149        let days = (end - from).num_days();
150        (1..=MAX_RANGE_DAYS)
151            .contains(&days)
152            .then(|| Self::of(Span::Between(from, end)))
153    }
154
155    /// The same period, but per `bucket` instead of the step it picks
156    /// itself ([`bucket`](Period::bucket)): `Period::days(90).per(Bucket::Week)`.
157    pub fn per(mut self, bucket: Bucket) -> Self {
158        self.per = Some(bucket);
159        self
160    }
161
162    /// `"7d"`, `"12w"`, `"12m"`, `"mtd"`, `"ytd"` or `"2026-09-01..2026-09-30"`;
163    /// `None` for anything else.
164    pub fn parse(key: &str) -> Option<Self> {
165        let key = key.trim().to_ascii_lowercase();
166        match key.as_str() {
167            "mtd" => return Some(Self::month_to_date()),
168            "ytd" => return Some(Self::year_to_date()),
169            _ => {}
170        }
171        if let Some((from, to)) = key.split_once("..") {
172            return Self::between(date(from)?, date(to)?);
173        }
174        let (number, unit) = key.split_at(key.len().checked_sub(1)?);
175        let n: u32 = number.parse().ok()?;
176        match unit {
177            "d" if (1..=366).contains(&n) => Some(Self::days(n)),
178            "w" if (1..=104).contains(&n) => Some(Self::weeks(n)),
179            "m" if (1..=36).contains(&n) => Some(Self::months(n)),
180            _ => None,
181        }
182    }
183
184    /// The period a query string asks for: `period=7d`, or
185    /// `period=custom&from=…&to=…`.
186    fn from_query(query: &str) -> Option<Self> {
187        let mut period = None;
188        let (mut from, mut to) = (None, None);
189        for (key, value) in form_urlencoded::parse(query.as_bytes()) {
190            match &*key {
191                "period" if period.is_none() => period = Some(value.into_owned()),
192                "from" if from.is_none() => from = Some(value.into_owned()),
193                "to" if to.is_none() => to = Some(value.into_owned()),
194                _ => {}
195            }
196        }
197        let period = period?;
198        if period.trim().eq_ignore_ascii_case("custom") {
199            Self::between(date(from.as_deref()?)?, date(to.as_deref()?)?)
200        } else {
201            Self::parse(&period)
202        }
203    }
204
205    /// The key it parses from: `"30d"`, or `"2026-09-01..2026-09-30"` for a
206    /// custom range.
207    pub fn key(&self) -> String {
208        match self.span {
209            Span::Days(n) => format!("{n}d"),
210            Span::Weeks(n) => format!("{n}w"),
211            Span::Months(n) => format!("{n}m"),
212            Span::MonthToDate => "mtd".into(),
213            Span::YearToDate => "ytd".into(),
214            Span::Between(start, end) => format!(
215                "{}..{}",
216                start.format("%Y-%m-%d"),
217                (end - Duration::days(1)).format("%Y-%m-%d")
218            ),
219        }
220    }
221
222    /// Whether it is a custom range of dates ([`between`](Period::between)).
223    pub fn is_custom(&self) -> bool {
224        matches!(self.span, Span::Between(..))
225    }
226
227    /// The period just before, as long: the 30 days before the last 30, last
228    /// month to the same day, last year to the same day, as many days just
229    /// before a custom range. For comparisons ([`Series::change_from`]).
230    pub fn previous(self) -> Self {
231        Self {
232            back: self.back + 1,
233            ..self
234        }
235    }
236
237    /// Per day up to 92 days (and month to date), per week for `12w`-style
238    /// periods and custom ranges up to 26 weeks, else per month; or what
239    /// [`per`](Period::per) set.
240    pub fn bucket(&self) -> Bucket {
241        if let Some(bucket) = self.per {
242            return bucket;
243        }
244        match self.span {
245            Span::Days(n) if n <= 92 => Bucket::Day,
246            Span::MonthToDate => Bucket::Day,
247            Span::Weeks(_) => Bucket::Week,
248            Span::Between(start, end) => match (end - start).num_days() {
249                ..=92 => Bucket::Day,
250                93..=182 => Bucket::Week,
251                _ => Bucket::Month,
252            },
253            _ => Bucket::Month,
254        }
255    }
256
257    /// The first local day and the day after the last, in `zone`'s calendar.
258    fn days_in(&self, zone: Zone) -> (NaiveDate, NaiveDate) {
259        let today = zone.local(crate::clock::unix_secs()).date();
260        let back = self.back;
261        match self.span {
262            Span::Days(n) => {
263                let end = today + Duration::days(1) - Duration::days(i64::from(n * back));
264                (end - Duration::days(i64::from(n)), end)
265            }
266            Span::Weeks(n) => {
267                let shift = Duration::days(7 * i64::from(n * back));
268                let start = monday_of(today) - Duration::days(7 * i64::from(n - 1));
269                (start - shift, today + Duration::days(1) - shift)
270            }
271            Span::Months(n) => {
272                let this_month = first_of_month(today);
273                let end = add_months(this_month, 1 - (n * back) as i32);
274                (add_months(end, -(n as i32)), end)
275            }
276            Span::MonthToDate => {
277                let start = add_months(first_of_month(today), -(back as i32));
278                let length = i64::from(today.day());
279                let next = add_months(start, 1);
280                (start, (start + Duration::days(length)).min(next))
281            }
282            Span::YearToDate => {
283                let year = today.year() - back as i32;
284                let start = NaiveDate::from_ymd_opt(year, 1, 1).unwrap_or(today);
285                let length = i64::from(today.ordinal());
286                let next = NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap_or(today);
287                (start, (start + Duration::days(length)).min(next))
288            }
289            Span::Between(start, end) => {
290                let shift = (end - start) * back as i32;
291                (start - shift, end - shift)
292            }
293        }
294    }
295
296    /// The moments the period starts at and ends before, in `zone`.
297    pub fn range(&self, zone: Zone) -> (DateTime, DateTime) {
298        let (start, end) = self.days_in(zone);
299        (moment(zone, start), moment(zone, end))
300    }
301
302    /// The labels of its buckets, in order: `2026-10-02` per day, the
303    /// week's Monday (`2026-09-28`) per week, `2026-10` per month.
304    pub fn labels(&self, zone: Zone) -> Vec<String> {
305        let (start, end) = self.days_in(zone);
306        let mut labels = Vec::new();
307        match self.bucket() {
308            Bucket::Day => {
309                let mut day = start;
310                while day < end && labels.len() < 400 {
311                    labels.push(day.format("%Y-%m-%d").to_string());
312                    day += Duration::days(1);
313                }
314            }
315            Bucket::Week => {
316                let mut week = monday_of(start);
317                while week < end && labels.len() < 400 {
318                    labels.push(week.format("%Y-%m-%d").to_string());
319                    week += Duration::days(7);
320                }
321            }
322            Bucket::Month => {
323                let mut month = first_of_month(start);
324                while month < end && labels.len() < 400 {
325                    labels.push(month.format("%Y-%m").to_string());
326                    month = add_months(month, 1);
327                }
328            }
329        }
330        labels
331    }
332}
333
334impl Serialize for Period {
335    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
336        serializer.serialize_str(&self.key())
337    }
338}
339
340impl<S: Send + Sync> FromRequestParts<S> for Period {
341    type Rejection = std::convert::Infallible;
342
343    async fn from_request_parts(
344        parts: &mut Parts,
345        _: &S,
346    ) -> std::result::Result<Self, Self::Rejection> {
347        let query = parts.uri.query().unwrap_or_default();
348        Ok(Period::from_query(query).unwrap_or_default())
349    }
350}
351
352/// `2026-09-01`, strictly.
353fn date(text: &str) -> Option<NaiveDate> {
354    let text = text.trim();
355    (text.len() == 10)
356        .then(|| NaiveDate::parse_from_str(text, "%Y-%m-%d").ok())
357        .flatten()
358}
359
360fn first_of_month(day: NaiveDate) -> NaiveDate {
361    day.with_day(1).unwrap_or(day)
362}
363
364/// The Monday of `day`'s ISO week.
365fn monday_of(day: NaiveDate) -> NaiveDate {
366    day - Duration::days(i64::from(day.weekday().num_days_from_monday()))
367}
368
369fn add_months(day: NaiveDate, months: i32) -> NaiveDate {
370    let total = day.year() * 12 + day.month0() as i32 + months;
371    NaiveDate::from_ymd_opt(total.div_euclid(12), total.rem_euclid(12) as u32 + 1, 1).unwrap_or(day)
372}
373
374/// Local midnight of `day` in `zone`, as a moment.
375fn moment(zone: Zone, day: NaiveDate) -> DateTime {
376    let local = day.and_time(NaiveTime::MIN);
377    let unix = zone
378        .resolve(local)
379        .unwrap_or_else(|| local.and_utc().timestamp());
380    DateTime::from_timestamp(unix, 0).unwrap_or_default()
381}
382
383/// Values over time: one per label (`2026-10-02` or `2026-10`), buckets
384/// without rows at 0. Serialized as `{name, labels, values}` for the
385/// `chart(…)` template function and `stat(trend=…)`.
386#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
387#[non_exhaustive]
388pub struct Series {
389    /// The series' name in a legend (`named`).
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub name: Option<String>,
392    /// One label per value.
393    pub labels: Vec<String>,
394    /// The values.
395    pub values: Vec<f64>,
396}
397
398impl Series {
399    /// A series from labels and values (as many of each).
400    pub fn new(labels: Vec<String>, values: Vec<f64>) -> Self {
401        Self {
402            name: None,
403            labels,
404            values,
405        }
406    }
407
408    /// Names it, for a chart's legend.
409    pub fn named(mut self, name: impl Into<String>) -> Self {
410        self.name = Some(name.into());
411        self
412    }
413
414    /// The sum of the values.
415    pub fn total(&self) -> f64 {
416        self.values.iter().sum()
417    }
418
419    /// The change from `before`'s total, in percent (`12.5` for +12.5%);
420    /// `None` when `before` adds up to 0.
421    pub fn change_from(&self, before: &Series) -> Option<f64> {
422        let base = before.total();
423        (base != 0.0).then(|| (self.total() - base) / base.abs() * 100.0)
424    }
425}
426
427/// A [`Series`] from a model's rows: how many (`count`), or the `sum` or
428/// `average` of a column, per day, week or month of a [`Period`] (its
429/// [`bucket`](Period::bucket)), by the moment in `column` (a `DateTime`
430/// column such as `created_at`). The query's conditions apply
431/// (`Order::where_eq("status", "paid")`); days are cut in `APP_TIMEZONE`, at
432/// its offset at the end of the period, and weeks run Monday to Sunday in
433/// those local days (labelled by their Monday).
434pub struct Trend<M> {
435    query: Query<M>,
436    column: String,
437    period: Period,
438}
439
440impl<M: Model> Trend<M> {
441    /// The rows of `query`, placed in time by `column`; 30 days unless
442    /// [`over`](Trend::over) says otherwise.
443    pub fn of(query: Query<M>, column: &str) -> Self {
444        Self {
445            query,
446            column: column.to_owned(),
447            period: Period::default(),
448        }
449    }
450
451    /// Over `period`.
452    pub fn over(mut self, period: Period) -> Self {
453        self.period = period;
454        self
455    }
456
457    /// How many rows per bucket.
458    pub async fn count(self, state: &AppState) -> Result<Series> {
459        self.run(state, "COUNT(*)".to_owned()).await
460    }
461
462    /// The sum of `column` per bucket.
463    pub async fn sum(self, state: &AppState, column: &str) -> Result<Series> {
464        let column = checked::<M>(column)?;
465        self.run(state, format!("SUM({column})")).await
466    }
467
468    /// The average of `column` per bucket (0 where there are no rows).
469    pub async fn average(self, state: &AppState, column: &str) -> Result<Series> {
470        let column = checked::<M>(column)?;
471        self.run(state, format!("AVG({column})")).await
472    }
473
474    async fn run(self, state: &AppState, aggregate: String) -> Result<Series> {
475        let zone: Zone = state.config.timezone;
476        let column = checked::<M>(&self.column)?;
477        let column = column.as_str();
478        let (start, end) = self.period.range(zone);
479        // Days are cut at the zone's offset at the end of the period.
480        let minutes = zone.offset_at(end.timestamp()) / 60;
481        let step = self.period.bucket();
482        // The moment moved to local time, then cut to its day, the Monday of
483        // its week (SQLite: on to the next Sunday, back six days), or month.
484        let bucket = move |dialect: Dialect| match (dialect, step) {
485            (Dialect::Sqlite, Bucket::Week) => {
486                format!("date({column}, '{minutes:+} minutes', 'weekday 0', '-6 days')")
487            }
488            (Dialect::Sqlite, step) => format!(
489                "strftime('{}', {column}, '{minutes:+} minutes')",
490                if step == Bucket::Month {
491                    "%Y-%m"
492                } else {
493                    "%Y-%m-%d"
494                }
495            ),
496            (Dialect::Postgres, Bucket::Week) => format!(
497                "to_char(date_trunc('week', ({column} AT TIME ZONE 'UTC') + interval '{minutes} minutes'), 'YYYY-MM-DD')"
498            ),
499            (Dialect::Postgres, step) => format!(
500                "to_char(({column} AT TIME ZONE 'UTC') + interval '{minutes} minutes', '{}')",
501                if step == Bucket::Month {
502                    "YYYY-MM"
503                } else {
504                    "YYYY-MM-DD"
505                }
506            ),
507        };
508        let condition = format!("{column} >= ? AND {column} < ?");
509        let rows = self
510            .query
511            .where_raw(&condition, [start, end])
512            .buckets(&state.db, &bucket, &aggregate)
513            .await?;
514        let found: std::collections::HashMap<String, f64> = rows
515            .into_iter()
516            .map(|(label, value)| (label, value.unwrap_or(0.0)))
517            .collect();
518        let labels = self.period.labels(zone);
519        let values = labels
520            .iter()
521            .map(|label| found.get(label).copied().unwrap_or(0.0))
522            .collect();
523        Ok(Series::new(labels, values))
524    }
525}
526
527/// `column`, quoted, if the model has it.
528fn checked<M: Model>(column: &str) -> Result<String> {
529    if M::COLUMNS.contains(&column) {
530        Ok(format!("\"{column}\""))
531    } else {
532        Err(anyhow::anyhow!("`{}` has no column `{column}`", M::TABLE).into())
533    }
534}
535
536// ---------------------------------------------------------------------------
537// The `chart(…)` template function.
538
539/// One line, area or set of bars.
540struct Line {
541    name: String,
542    values: Vec<Option<f64>>,
543}
544
545/// What a chart draws.
546struct Data {
547    labels: Vec<String>,
548    series: Vec<Line>,
549}
550
551fn number_of(value: &Value) -> Option<f64> {
552    if value.is_none() || value.is_undefined() {
553        return None;
554    }
555    if let Ok(i) = i64::try_from(value.clone()) {
556        return Some(i as f64);
557    }
558    if let Some(text) = value.as_str() {
559        return text.trim().parse().ok();
560    }
561    f64::try_from(value.clone()).ok()
562}
563
564fn strings(value: &Value) -> Vec<String> {
565    match value.try_iter() {
566        Ok(items) if value.kind() == ValueKind::Seq => items
567            .map(|v| {
568                v.as_str()
569                    .map(str::to_owned)
570                    .unwrap_or_else(|| v.to_string())
571            })
572            .collect(),
573        _ => Vec::new(),
574    }
575}
576
577fn numbers(value: &Value) -> Vec<Option<f64>> {
578    match value.try_iter() {
579        Ok(items) if value.kind() == ValueKind::Seq => items.map(|v| number_of(&v)).collect(),
580        _ => Vec::new(),
581    }
582}
583
584fn attr(value: &Value, key: &str) -> Option<Value> {
585    value
586        .get_attr(key)
587        .ok()
588        .filter(|v| !v.is_undefined() && !v.is_none())
589}
590
591/// A series from `{name, values}` (or a [`Series`]), or a list of numbers.
592fn line_of(value: &Value, fallback: &str) -> Line {
593    if value.kind() == ValueKind::Map {
594        Line {
595            name: attr(value, "name")
596                .and_then(|n| n.as_str().map(str::to_owned))
597                .unwrap_or_else(|| fallback.to_owned()),
598            values: attr(value, "values")
599                .map(|v| numbers(&v))
600                .unwrap_or_default(),
601        }
602    } else {
603        Line {
604            name: fallback.to_owned(),
605            values: numbers(value),
606        }
607    }
608}
609
610fn read_data(
611    data: Option<Value>,
612    kwargs: &Kwargs,
613    title: &str,
614) -> std::result::Result<Data, Error> {
615    let labels: Option<Value> = kwargs.get("labels")?;
616    let series: Option<Value> = kwargs.get("series")?;
617    let values: Option<Value> = kwargs.get("values")?;
618    let name: Option<String> = kwargs.get("name")?;
619    let fallback = name.clone().unwrap_or_else(|| title.to_owned());
620    let mut out = Data {
621        labels: labels.as_ref().map(strings).unwrap_or_default(),
622        series: Vec::new(),
623    };
624    let list_of_series = |value: &Value, out: &mut Data| {
625        if let Ok(items) = value.try_iter() {
626            for (i, item) in items.enumerate() {
627                out.series
628                    .push(line_of(&item, &format!("{} {}", fallback, i + 1)));
629            }
630        }
631    };
632    match data {
633        Some(data) if data.kind() == ValueKind::Map => {
634            if out.labels.is_empty()
635                && let Some(labels) = attr(&data, "labels")
636            {
637                out.labels = strings(&labels);
638            }
639            match attr(&data, "series") {
640                Some(series) => list_of_series(&series, &mut out),
641                None => out.series.push(line_of(&data, &fallback)),
642            }
643        }
644        Some(data) if data.kind() == ValueKind::Seq => {
645            let first_is_map = data
646                .try_iter()
647                .ok()
648                .and_then(|mut items| items.next())
649                .is_some_and(|v| v.kind() == ValueKind::Map);
650            if first_is_map {
651                list_of_series(&data, &mut out);
652            } else {
653                out.series.push(line_of(&data, &fallback));
654            }
655        }
656        _ => {}
657    }
658    if let Some(series) = series {
659        list_of_series(&series, &mut out);
660    }
661    if let Some(values) = values {
662        out.series.push(Line {
663            name: fallback.clone(),
664            values: numbers(&values),
665        });
666    }
667    if let Some(name) = name
668        && out.series.len() == 1
669    {
670        out.series[0].name = name;
671    }
672    let longest = out.series.iter().map(|s| s.values.len()).max().unwrap_or(0);
673    if out.labels.len() < longest {
674        for i in out.labels.len()..longest {
675            out.labels.push((i + 1).to_string());
676        }
677    }
678    let n = out.labels.len();
679    for line in &mut out.series {
680        line.values.resize(n, None);
681    }
682    Ok(out)
683}
684
685/// The page's language, for separators and month names.
686fn locale(state: &State) -> String {
687    state
688        .lookup("app")
689        .and_then(|app| app.get_attr("locale").ok())
690        .and_then(|l| l.as_str().map(str::to_owned))
691        .unwrap_or_else(|| "en".into())
692}
693
694fn text(state: &State, key: &str, fallback_locale: &str) -> String {
695    state
696        .lookup("t")
697        .and_then(|t| t.call(state, &[Value::from(key)]).ok())
698        .and_then(|v| v.as_str().map(str::to_owned))
699        .unwrap_or_else(|| crate::i18n::builtin_text(fallback_locale, key))
700}
701
702const MONTHS: [&str; 12] = [
703    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
704];
705
706/// `2026-10-02` → `Oct 2`, `2026-10` → `Oct 2026`, or with `format`
707/// (chrono's codes, e.g. `%d/%m`); other labels as they are.
708fn display_label(label: &str, format: Option<&str>) -> String {
709    if let Ok(day) = NaiveDate::parse_from_str(label, "%Y-%m-%d") {
710        return match format {
711            Some(format) => day.format(format).to_string(),
712            None => format!("{} {}", MONTHS[day.month0() as usize], day.day()),
713        };
714    }
715    if let Ok(month) = NaiveDate::parse_from_str(&format!("{label}-01"), "%Y-%m-%d") {
716        return match format {
717            Some(format) => month.format(format).to_string(),
718            None => format!("{} {}", MONTHS[month.month0() as usize], month.year()),
719        };
720    }
721    label.to_owned()
722}
723
724/// How values read in tooltips and the table.
725struct Formatter {
726    format: String,
727    decimals: Option<u32>,
728    currency: String,
729    locale: String,
730    /// What money is divided by: data is in the currency's smallest unit,
731    /// as for the `money` filter (100 for `USD`, `divide_by` to change it).
732    divisor: f64,
733}
734
735impl Formatter {
736    /// `value` in the unit it is shown in (money in whole units).
737    fn shown(&self, value: f64) -> f64 {
738        if self.format == "money" {
739            value / self.divisor
740        } else {
741            value
742        }
743    }
744
745    fn full(&self, value: f64) -> String {
746        let value = self.shown(value);
747        match self.format.as_str() {
748            "money" => format_money(value, &self.currency, self.decimals, &self.locale),
749            "percent" => format!(
750                "{}%",
751                format_number(value, self.decimals.unwrap_or(1), &self.locale)
752            ),
753            _ => format_number(value, self.decimals.unwrap_or(0), &self.locale),
754        }
755    }
756
757    /// Axis ticks: `12.5K` (`12,5K` where the locale writes a decimal
758    /// comma), plain under 10,000.
759    fn tick(&self, value: f64) -> String {
760        let value = self.shown(value);
761        let units: [(f64, &str); 4] = [(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")];
762        let suffix = if self.format == "percent" { "%" } else { "" };
763        if value.abs() >= 10_000.0 {
764            for (size, unit) in units {
765                if value.abs() >= size {
766                    let scaled = value / size;
767                    let decimals = if scaled.fract().abs() < 1e-9 { 0 } else { 1 };
768                    return format!(
769                        "{}{unit}{suffix}",
770                        format_number(scaled, decimals, &self.locale)
771                    );
772                }
773            }
774        }
775        let decimals = if value.fract().abs() < 1e-9 { 0 } else { 1 };
776        format!("{}{suffix}", format_number(value, decimals, &self.locale))
777    }
778}
779
780/// Clean ticks from `lo` to `hi` (0 always included): about four steps of
781/// 1, 2, 2.5 or 5 × 10ⁿ.
782fn scale(lo: f64, hi: f64) -> (f64, f64, f64) {
783    nice(lo.min(0.0), hi.max(0.0))
784}
785
786/// Clean ticks covering `lo` to `hi`, without forcing 0 in.
787fn nice(mut lo: f64, mut hi: f64) -> (f64, f64, f64) {
788    if (hi - lo).abs() < f64::EPSILON {
789        if lo == 0.0 {
790            hi = lo + 1.0;
791        } else {
792            let pad = lo.abs() / 10.0;
793            (lo, hi) = (lo - pad, hi + pad);
794        }
795    }
796    let raw = (hi - lo) / 4.0;
797    let magnitude = 10f64.powf(raw.log10().floor());
798    let normal = raw / magnitude;
799    let nice = if normal <= 1.0 {
800        1.0
801    } else if normal <= 2.0 {
802        2.0
803    } else if normal <= 2.5 {
804        2.5
805    } else if normal <= 5.0 {
806        5.0
807    } else {
808        10.0
809    };
810    let step = nice * magnitude;
811    lo = (lo / step).floor() * step;
812    hi = (hi / step).ceil() * step;
813    (lo, hi, step)
814}
815
816fn slot(i: usize) -> String {
817    if i < 6 {
818        format!("rx-series-{}", i + 1)
819    } else {
820        "rx-series-other".into()
821    }
822}
823
824fn pct(value: f64) -> String {
825    let rounded = (value * 100.0).round() / 100.0;
826    format!("{rounded}")
827}
828
829/// `chart(kind, data, …)`: see docs/ui.md "Charts".
830pub(crate) fn chart(
831    currency: String,
832) -> impl Fn(&State, String, Option<Value>, Kwargs) -> std::result::Result<Value, Error>
833+ Send
834+ Sync
835+ 'static {
836    move |state: &State, kind: String, data: Option<Value>, kwargs: Kwargs| {
837        let title: Option<String> = kwargs.get("title")?;
838        let height: Option<u32> = kwargs.get("height")?;
839        let format: Option<String> = kwargs.get("format")?;
840        let decimals: Option<u32> = kwargs.get("decimals")?;
841        let currency_kw: Option<String> = kwargs.get("currency")?;
842        let divide_by: Option<f64> = kwargs.get("divide_by")?;
843        let stacked: Option<bool> = kwargs.get("stacked")?;
844        let legend: Option<bool> = kwargs.get("legend")?;
845        let table: Option<bool> = kwargs.get("table")?;
846        let x_format: Option<String> = kwargs.get("x_format")?;
847        let x_title: Option<String> = kwargs.get("x_title")?;
848        let y_title: Option<String> = kwargs.get("y_title")?;
849        let id: Option<String> = kwargs.get("id")?;
850        let locale = locale(state);
851        let title = title.unwrap_or_default();
852        let currency = currency_kw
853            .map(|c| c.trim().to_ascii_uppercase())
854            .unwrap_or_else(|| currency.clone());
855        let divisor = money_divisor(&currency, divide_by);
856        // Money keeps its currency's decimals unless `decimals` says
857        // otherwise; other formats take what the data needs.
858        let formatter = |format: Option<String>, given: Option<u32>, auto: Option<u32>| {
859            let format = format.unwrap_or_else(|| "number".into());
860            Formatter {
861                decimals: if format == "money" {
862                    given
863                } else {
864                    given.or(auto)
865                },
866                format,
867                currency: currency.clone(),
868                locale: locale.clone(),
869                divisor,
870            }
871        };
872        let mut options = Options {
873            kind: kind.clone(),
874            title: title.clone(),
875            height: height.unwrap_or(240).clamp(80, 800),
876            stacked: stacked.unwrap_or(false),
877            legend: legend.unwrap_or(true),
878            table: table.unwrap_or(true),
879            labels: Vec::new(),
880            id,
881            show_data: text(state, "ui.chart.show_data", &locale),
882            other: text(state, "ui.chart.other", &locale),
883            series_head: text(state, "ui.chart.series", &locale),
884            axes: text(state, "ui.chart.axes", &locale),
885            x_title,
886            y_title,
887        };
888        let html = match kind.as_str() {
889            "scatter" | "bubble" => {
890                let size_format: Option<String> = kwargs.get("size_format")?;
891                let size_title: Option<String> = kwargs.get("size_title")?;
892                let clouds = read_points(data, &kwargs, &title)?;
893                kwargs.assert_all_used()?;
894                let all = || clouds.iter().flat_map(|c| c.points.iter());
895                let formats = Formats {
896                    x: formatter(x_format, None, auto_decimals(all().map(|p| p.x))),
897                    y: formatter(format, decimals, auto_decimals(all().map(|p| p.y))),
898                    size: formatter(
899                        size_format,
900                        None,
901                        auto_decimals(all().filter_map(|p| p.size)),
902                    ),
903                    size_title: size_title.unwrap_or_else(|| text(state, "ui.chart.size", &locale)),
904                };
905                render_points(&clouds, &options, &formats)
906            }
907            "line" | "area" | "bar" | "pie" | "doughnut" => {
908                let data = read_data(data, &kwargs, &title)?;
909                kwargs.assert_all_used()?;
910                options.labels = data
911                    .labels
912                    .iter()
913                    .map(|l| display_label(l, x_format.as_deref()))
914                    .collect();
915                // Plain numbers get as many decimals as the data needs, as
916                // on scatter charts; money and percent keep their own.
917                let auto = match format.as_deref() {
918                    None | Some("number") => auto_decimals(
919                        data.series
920                            .iter()
921                            .flat_map(|s| s.values.iter().flatten().copied()),
922                    ),
923                    _ => None,
924                };
925                let formatter = formatter(format, decimals, auto);
926                if kind == "pie" || kind == "doughnut" {
927                    render_pie(&data, &options, &formatter)
928                } else {
929                    render_xy(&data, &options, &formatter)
930                }
931            }
932            other => {
933                return Err(Error::new(
934                    ErrorKind::InvalidOperation,
935                    format!(
936                        "chart: unknown kind `{other}` (line, area, bar, pie, doughnut, scatter or bubble)"
937                    ),
938                ));
939            }
940        };
941        Ok(Value::from_safe_string(html))
942    }
943}
944
945struct Options {
946    kind: String,
947    title: String,
948    height: u32,
949    stacked: bool,
950    legend: bool,
951    table: bool,
952    labels: Vec<String>,
953    id: Option<String>,
954    show_data: String,
955    other: String,
956    /// The data table's heading over series names.
957    series_head: String,
958    /// `:y by :x`, for a scatter chart's accessible name.
959    axes: String,
960    /// The axes' titles, shown along them.
961    x_title: Option<String>,
962    y_title: Option<String>,
963}
964
965/// The data the page's script reads for tooltips (and a table reads too).
966#[derive(Serialize)]
967struct Hover<'a> {
968    kind: &'a str,
969    labels: &'a [String],
970    series: Vec<HoverSeries>,
971}
972
973#[derive(Serialize)]
974struct HoverSeries {
975    name: String,
976    slot: String,
977    values: Vec<Option<String>>,
978}
979
980fn open_figure(out: &mut String, options: &Options, hover: &Hover) {
981    let json = serde_json::to_string(hover).unwrap_or_default();
982    let _ = write!(
983        out,
984        r#"<figure class="rx-chart rx-chart--{kind}"{id} data-rx-chart="{json}">"#,
985        kind = escape(&options.kind),
986        id = options
987            .id
988            .as_deref()
989            .map(|id| format!(r#" id="{}""#, escape(id)))
990            .unwrap_or_default(),
991        json = escape(&json),
992    );
993    if !options.title.is_empty() {
994        let _ = write!(
995            out,
996            r#"<figcaption class="rx-visually-hidden">{}</figcaption>"#,
997            escape(&options.title)
998        );
999    }
1000}
1001
1002fn legend(out: &mut String, names: &[(usize, &str)], key: &str) {
1003    out.push_str(r#"<ul class="rx-chart__legend" role="list">"#);
1004    for (i, name) in names {
1005        let _ = write!(
1006            out,
1007            r#"<li><span class="rx-chart__key rx-chart__key--{key} {slot}" aria-hidden="true"></span>{name}</li>"#,
1008            slot = slot(*i),
1009            name = escape(name),
1010        );
1011    }
1012    out.push_str("</ul>");
1013}
1014
1015fn table(out: &mut String, options: &Options, heads: &[String], rows: &[(String, Vec<String>)]) {
1016    if !options.table {
1017        return;
1018    }
1019    let _ = write!(
1020        out,
1021        r#"<details class="rx-chart__table"><summary>{}</summary><div class="rx-table-wrap"><table class="rx-table"><thead><tr><th scope="col"></th>"#,
1022        escape(&options.show_data)
1023    );
1024    for head in heads {
1025        let _ = write!(
1026            out,
1027            r#"<th scope="col" class="rx-num">{}</th>"#,
1028            escape(head)
1029        );
1030    }
1031    out.push_str("</tr></thead><tbody>");
1032    for (label, cells) in rows {
1033        let _ = write!(out, r#"<tr><th scope="row">{}</th>"#, escape(label));
1034        for cell in cells {
1035            let _ = write!(out, r#"<td class="rx-num">{}</td>"#, escape(cell));
1036        }
1037        out.push_str("</tr>");
1038    }
1039    out.push_str("</tbody></table></div></details>");
1040}
1041
1042fn render_xy(data: &Data, options: &Options, formatter: &Formatter) -> String {
1043    let n = data.labels.len();
1044    let bar = options.kind == "bar";
1045    let stacked = bar && options.stacked;
1046    // The value range: per label sums for stacked bars.
1047    let (mut lo, mut hi) = (0.0f64, 0.0f64);
1048    for i in 0..n {
1049        let (mut up, mut down) = (0.0, 0.0);
1050        for line in &data.series {
1051            if let Some(v) = line.values[i] {
1052                if stacked {
1053                    if v >= 0.0 { up += v } else { down += v }
1054                } else {
1055                    hi = hi.max(v);
1056                    lo = lo.min(v);
1057                }
1058            }
1059        }
1060        hi = hi.max(up);
1061        lo = lo.min(down);
1062    }
1063    let (lo, hi, step) = scale(lo, hi);
1064    let y = |v: f64| (v - lo) / (hi - lo) * 100.0;
1065    let zero = y(0.0);
1066
1067    let hover = Hover {
1068        kind: &options.kind,
1069        labels: &options.labels,
1070        series: data
1071            .series
1072            .iter()
1073            .enumerate()
1074            .map(|(i, line)| HoverSeries {
1075                name: line.name.clone(),
1076                slot: slot(i),
1077                values: line
1078                    .values
1079                    .iter()
1080                    .map(|v| v.map(|v| formatter.full(v)))
1081                    .collect(),
1082            })
1083            .collect(),
1084    };
1085    let mut out = String::new();
1086    open_figure(&mut out, options, &hover);
1087    if options.legend && data.series.len() > 1 {
1088        let names: Vec<(usize, &str)> = data
1089            .series
1090            .iter()
1091            .enumerate()
1092            .map(|(i, s)| (i, s.name.as_str()))
1093            .collect();
1094        legend(&mut out, &names, if bar { "box" } else { "line" });
1095    }
1096    y_title(&mut out, options);
1097    let _ = write!(
1098        out,
1099        r#"<div class="rx-chart__frame" style="--rx-chart-h: {}px"><div class="rx-chart__y" aria-hidden="true">"#,
1100        options.height
1101    );
1102    let mut ticks = Vec::new();
1103    let mut tick = lo;
1104    while tick <= hi + step / 2.0 && ticks.len() < 12 {
1105        ticks.push(tick);
1106        tick += step;
1107    }
1108    for t in &ticks {
1109        let _ = write!(
1110            out,
1111            r#"<span style="bottom: {}%">{}</span>"#,
1112            pct(y(*t)),
1113            escape(&formatter.tick(*t))
1114        );
1115    }
1116    let label = if options.title.is_empty() {
1117        data.series
1118            .iter()
1119            .map(|s| s.name.as_str())
1120            .collect::<Vec<_>>()
1121            .join(", ")
1122    } else {
1123        options.title.clone()
1124    };
1125    let _ = write!(
1126        out,
1127        r#"</div><div class="rx-chart__plot" tabindex="0" role="img" aria-label="{}"><div class="rx-chart__grid" aria-hidden="true">"#,
1128        escape(&label)
1129    );
1130    for t in &ticks {
1131        let base = if t.abs() < step / 1e6 {
1132            " rx-chart__rule--base"
1133        } else {
1134            ""
1135        };
1136        let _ = write!(
1137            out,
1138            r#"<span class="rx-chart__rule{base}" style="bottom: {}%"></span>"#,
1139            pct(y(*t))
1140        );
1141    }
1142    out.push_str("</div>");
1143
1144    if bar {
1145        out.push_str(r#"<div class="rx-chart__bars" aria-hidden="true">"#);
1146        for i in 0..n {
1147            let _ = write!(out, r#"<div class="rx-chart__band" data-index="{i}">"#);
1148            if stacked {
1149                out.push_str(r#"<div class="rx-chart__column">"#);
1150                let (mut up, mut down) = (0.0, 0.0);
1151                let tops: Vec<usize> = {
1152                    // The outermost segment on each side gets the rounded end.
1153                    let mut last_up = None;
1154                    let mut last_down = None;
1155                    for (s, line) in data.series.iter().enumerate() {
1156                        match line.values[i] {
1157                            Some(v) if v > 0.0 => last_up = Some(s),
1158                            Some(v) if v < 0.0 => last_down = Some(s),
1159                            _ => {}
1160                        }
1161                    }
1162                    last_up.into_iter().chain(last_down).collect()
1163                };
1164                for (s, line) in data.series.iter().enumerate() {
1165                    let Some(v) = line.values[i].filter(|v| *v != 0.0) else {
1166                        continue;
1167                    };
1168                    let (from, to) = if v > 0.0 {
1169                        let from = up;
1170                        up += v;
1171                        (from, up)
1172                    } else {
1173                        let from = down;
1174                        down += v;
1175                        (down, from)
1176                    };
1177                    let classes = format!(
1178                        "rx-chart__bar {}{}{}",
1179                        slot(s),
1180                        if v < 0.0 { " rx-chart__bar--neg" } else { "" },
1181                        if tops.contains(&s) {
1182                            " rx-chart__bar--end"
1183                        } else {
1184                            ""
1185                        },
1186                    );
1187                    let _ = write!(
1188                        out,
1189                        r#"<span class="{classes}" style="bottom: {}%; height: {}%"></span>"#,
1190                        pct(y(from)),
1191                        pct(y(to) - y(from)),
1192                    );
1193                }
1194                out.push_str("</div>");
1195            } else {
1196                for (s, line) in data.series.iter().enumerate() {
1197                    out.push_str(r#"<div class="rx-chart__column">"#);
1198                    if let Some(v) = line.values[i].filter(|v| *v != 0.0) {
1199                        let (from, to) = if v > 0.0 { (0.0, v) } else { (v, 0.0) };
1200                        let _ = write!(
1201                            out,
1202                            r#"<span class="rx-chart__bar rx-chart__bar--end {}{}" style="bottom: {}%; height: {}%"></span>"#,
1203                            slot(s),
1204                            if v < 0.0 { " rx-chart__bar--neg" } else { "" },
1205                            pct(y(from)),
1206                            pct(y(to) - y(from)),
1207                        );
1208                    }
1209                    out.push_str("</div>");
1210                }
1211            }
1212            out.push_str("</div>");
1213        }
1214        out.push_str("</div>");
1215    } else {
1216        let x = |i: usize| {
1217            if n > 1 {
1218                i as f64 / (n - 1) as f64 * 1000.0
1219            } else {
1220                500.0
1221            }
1222        };
1223        let sy = |v: f64| 1000.0 - y(v) * 10.0;
1224        out.push_str(r#"<svg class="rx-chart__svg" viewBox="0 0 1000 1000" preserveAspectRatio="none" aria-hidden="true" focusable="false">"#);
1225        for (s, line) in data.series.iter().enumerate() {
1226            // Runs of values; a missing one breaks the line.
1227            let mut runs: Vec<Vec<(f64, f64)>> = vec![Vec::new()];
1228            for (i, value) in line.values.iter().enumerate() {
1229                match (value, runs.last_mut()) {
1230                    (Some(v), Some(run)) => run.push((x(i), sy(*v))),
1231                    _ => runs.push(Vec::new()),
1232                }
1233            }
1234            for run in runs.iter().filter(|r| !r.is_empty()) {
1235                let path: String = run
1236                    .iter()
1237                    .enumerate()
1238                    .map(|(k, (px, py))| {
1239                        format!("{}{:.1},{:.1}", if k == 0 { "M" } else { "L" }, px, py)
1240                    })
1241                    .collect();
1242                if options.kind == "area" {
1243                    let base = 1000.0 - zero * 10.0;
1244                    let _ = write!(
1245                        out,
1246                        r#"<path class="rx-chart__area {}" d="{path}L{:.1},{base:.1}L{:.1},{base:.1}Z"/>"#,
1247                        slot(s),
1248                        run.last().map(|p| p.0).unwrap_or(0.0),
1249                        run.first().map(|p| p.0).unwrap_or(0.0),
1250                    );
1251                }
1252                let _ = write!(
1253                    out,
1254                    r#"<path class="rx-chart__line {}" d="{path}" vector-effect="non-scaling-stroke"/>"#,
1255                    slot(s)
1256                );
1257            }
1258        }
1259        out.push_str("</svg>");
1260        // The last point of each line, as a dot that stays round.
1261        for (s, line) in data.series.iter().enumerate() {
1262            if let Some((i, v)) = line
1263                .values
1264                .iter()
1265                .enumerate()
1266                .rev()
1267                .find_map(|(i, v)| v.map(|v| (i, v)))
1268            {
1269                let _ = write!(
1270                    out,
1271                    r#"<span class="rx-chart__dot {}" style="left: {}%; bottom: {}%" aria-hidden="true"></span>"#,
1272                    slot(s),
1273                    pct(x(i) / 10.0),
1274                    pct(y(v)),
1275                );
1276            }
1277        }
1278    }
1279    out.push_str(r#"<span class="rx-chart__cross" hidden></span><div class="rx-chart__tip" hidden></div></div>"#);
1280
1281    // X labels: at most 8, the first and the last always.
1282    out.push_str(r#"<div class="rx-chart__x" aria-hidden="true">"#);
1283    let every = n.div_ceil(8).max(1);
1284    let mut shown_at: Vec<usize> = (0..n).step_by(every).collect();
1285    if let Some(&last) = shown_at.last()
1286        && last + 1 < n
1287    {
1288        // The last label always shows; one too close before it gives way.
1289        if n - 1 - last < every.div_ceil(2) && shown_at.len() > 1 {
1290            shown_at.pop();
1291        }
1292        shown_at.push(n - 1);
1293    }
1294    for (shown, &i) in shown_at.iter().enumerate() {
1295        let label = &options.labels[i];
1296        let left = if bar {
1297            (i as f64 + 0.5) / n as f64 * 100.0
1298        } else if n > 1 {
1299            i as f64 / (n - 1) as f64 * 100.0
1300        } else {
1301            50.0
1302        };
1303        // On phones every other label hides; the first and the last stay.
1304        let narrow = if shown % 2 == 1 && shown + 1 < shown_at.len() {
1305            " rx-chart__x--odd"
1306        } else {
1307            ""
1308        };
1309        let _ = write!(
1310            out,
1311            r#"<span class="{narrow}" style="left: {}%">{}</span>"#,
1312            pct(left),
1313            escape(label)
1314        );
1315    }
1316    out.push_str("</div></div>");
1317    x_title(&mut out, options);
1318
1319    let heads: Vec<String> = data.series.iter().map(|s| s.name.clone()).collect();
1320    let rows: Vec<(String, Vec<String>)> = (0..n)
1321        .map(|i| {
1322            (
1323                options.labels[i].clone(),
1324                data.series
1325                    .iter()
1326                    .map(|s| {
1327                        s.values[i]
1328                            .map(|v| formatter.full(v))
1329                            .unwrap_or_else(|| "—".into())
1330                    })
1331                    .collect(),
1332            )
1333        })
1334        .collect();
1335    table(&mut out, options, &heads, &rows);
1336    out.push_str("</figure>");
1337    out
1338}
1339
1340fn render_pie(data: &Data, options: &Options, formatter: &Formatter) -> String {
1341    let values = data
1342        .series
1343        .first()
1344        .map(|s| s.values.clone())
1345        .unwrap_or_default();
1346    // Six slices at most: the rest is "Other".
1347    let mut slices: Vec<(String, f64)> = data
1348        .labels
1349        .iter()
1350        .zip(values)
1351        .map(|(label, v)| (label.clone(), v.unwrap_or(0.0).max(0.0)))
1352        .collect();
1353    let display: Vec<String> = options.labels.clone();
1354    for (slice, label) in slices.iter_mut().zip(&display) {
1355        slice.0 = label.clone();
1356    }
1357    if slices.len() > 6 {
1358        let rest: f64 = slices[5..].iter().map(|s| s.1).sum();
1359        slices.truncate(5);
1360        slices.push((options.other.clone(), rest));
1361    }
1362    let total: f64 = slices.iter().map(|s| s.1).sum();
1363    let share = |v: f64| if total > 0.0 { v / total * 100.0 } else { 0.0 };
1364    let percent = |v: f64| format!("{}%", format_number(share(v), 1, &formatter.locale));
1365    let labels: Vec<String> = slices.iter().map(|s| s.0.clone()).collect();
1366    let hover = Hover {
1367        kind: &options.kind,
1368        labels: &labels,
1369        series: vec![HoverSeries {
1370            name: options.title.clone(),
1371            slot: String::new(),
1372            values: slices
1373                .iter()
1374                .map(|s| Some(format!("{} ({})", formatter.full(s.1), percent(s.1))))
1375                .collect(),
1376        }],
1377    };
1378    let mut out = String::new();
1379    open_figure(&mut out, options, &hover);
1380    let label = if options.title.is_empty() {
1381        labels.join(", ")
1382    } else {
1383        options.title.clone()
1384    };
1385    let _ = write!(
1386        out,
1387        r#"<div class="rx-chart__pie"><div class="rx-chart__plot rx-chart__plot--pie" tabindex="0" role="img" aria-label="{}"><svg viewBox="-50 -50 100 100" aria-hidden="true" focusable="false">"#,
1388        escape(&label)
1389    );
1390    let inner = if options.kind == "doughnut" {
1391        30.0
1392    } else {
1393        0.0
1394    };
1395    let outer = 48.0;
1396    let mut angle = -std::f64::consts::FRAC_PI_2;
1397    for (i, (_, v)) in slices.iter().enumerate() {
1398        if *v <= 0.0 || total <= 0.0 {
1399            continue;
1400        }
1401        let sweep = v / total * std::f64::consts::TAU;
1402        let class = format!("rx-chart__slice {}", slot(i));
1403        if sweep >= std::f64::consts::TAU - 1e-9 {
1404            let _ = write!(
1405                out,
1406                r#"<circle class="{class}" data-index="{i}" r="{}" fill-rule="evenodd"/>"#,
1407                outer
1408            );
1409            if inner > 0.0 {
1410                let _ = write!(out, r#"<circle class="rx-chart__hole" r="{inner}"/>"#);
1411            }
1412        } else {
1413            let (a0, a1) = (angle, angle + sweep);
1414            let large = if sweep > std::f64::consts::PI { 1 } else { 0 };
1415            let p = |r: f64, a: f64| (r * a.cos(), r * a.sin());
1416            let (x0, y0) = p(outer, a0);
1417            let (x1, y1) = p(outer, a1);
1418            let d = if inner > 0.0 {
1419                let (x2, y2) = p(inner, a1);
1420                let (x3, y3) = p(inner, a0);
1421                format!(
1422                    "M{x0:.3},{y0:.3}A{outer},{outer} 0 {large} 1 {x1:.3},{y1:.3}L{x2:.3},{y2:.3}A{inner},{inner} 0 {large} 0 {x3:.3},{y3:.3}Z"
1423                )
1424            } else {
1425                format!("M0,0L{x0:.3},{y0:.3}A{outer},{outer} 0 {large} 1 {x1:.3},{y1:.3}Z")
1426            };
1427            let _ = write!(out, r#"<path class="{class}" data-index="{i}" d="{d}"/>"#);
1428        }
1429        angle += sweep;
1430    }
1431    out.push_str(r#"</svg><div class="rx-chart__tip" hidden></div></div>"#);
1432    if options.legend {
1433        out.push_str(r#"<ul class="rx-chart__legend rx-chart__legend--pie" role="list">"#);
1434        for (i, (name, v)) in slices.iter().enumerate() {
1435            let _ = write!(
1436                out,
1437                r#"<li><span class="rx-chart__key rx-chart__key--box {}" aria-hidden="true"></span><span class="rx-chart__name">{}</span><span class="rx-chart__value">{} <span class="rx-chart__share">{}</span></span></li>"#,
1438                slot(i),
1439                escape(name),
1440                escape(&formatter.full(*v)),
1441                escape(&percent(*v)),
1442            );
1443        }
1444        out.push_str("</ul>");
1445    }
1446    out.push_str("</div>");
1447    let heads = vec![
1448        if options.title.is_empty() {
1449            "".into()
1450        } else {
1451            options.title.clone()
1452        },
1453        "%".into(),
1454    ];
1455    let rows: Vec<(String, Vec<String>)> = slices
1456        .iter()
1457        .map(|(name, v)| (name.clone(), vec![formatter.full(*v), percent(*v)]))
1458        .collect();
1459    table(&mut out, options, &heads, &rows);
1460    out.push_str("</figure>");
1461    out
1462}
1463
1464/// The y axis' title, over the axis.
1465fn y_title(out: &mut String, options: &Options) {
1466    if let Some(title) = options.y_title.as_deref().filter(|t| !t.is_empty()) {
1467        let _ = write!(
1468            out,
1469            r#"<p class="rx-chart__axis-title rx-chart__axis-title--y" aria-hidden="true">{}</p>"#,
1470            escape(title)
1471        );
1472    }
1473}
1474
1475/// The x axis' title, under its labels.
1476fn x_title(out: &mut String, options: &Options) {
1477    if let Some(title) = options.x_title.as_deref().filter(|t| !t.is_empty()) {
1478        let _ = write!(
1479            out,
1480            r#"<p class="rx-chart__axis-title rx-chart__axis-title--x" aria-hidden="true">{}</p>"#,
1481            escape(title)
1482        );
1483    }
1484}
1485
1486// ---------------------------------------------------------------------------
1487// Scatter and bubble charts.
1488
1489/// One point of a scatter (x, y) or bubble (x, y, size) chart.
1490struct Point {
1491    x: f64,
1492    y: f64,
1493    size: Option<f64>,
1494    label: Option<String>,
1495}
1496
1497/// The points of one series.
1498struct Cloud {
1499    name: String,
1500    points: Vec<Point>,
1501}
1502
1503/// `[x, y]`, `[x, y, size]` or `{x, y, size, label}`; `None` without both
1504/// numbers.
1505fn point_of(value: &Value) -> Option<Point> {
1506    if value.kind() == ValueKind::Map {
1507        return Some(Point {
1508            x: attr(value, "x").as_ref().and_then(number_of)?,
1509            y: attr(value, "y").as_ref().and_then(number_of)?,
1510            size: attr(value, "size").as_ref().and_then(number_of),
1511            label: attr(value, "label").map(|l| {
1512                l.as_str()
1513                    .map(str::to_owned)
1514                    .unwrap_or_else(|| l.to_string())
1515            }),
1516        });
1517    }
1518    let items: Vec<Value> = value.try_iter().ok()?.collect();
1519    Some(Point {
1520        x: number_of(items.first()?)?,
1521        y: number_of(items.get(1)?)?,
1522        size: items.get(2).and_then(number_of),
1523        label: None,
1524    })
1525}
1526
1527fn points_of(value: &Value) -> Vec<Point> {
1528    match value.try_iter() {
1529        Ok(items) if value.kind() == ValueKind::Seq => {
1530            items.filter_map(|item| point_of(&item)).collect()
1531        }
1532        _ => Vec::new(),
1533    }
1534}
1535
1536/// A series `{name, points}`, or a list of points.
1537fn cloud_of(value: &Value, fallback: &str) -> Cloud {
1538    if value.kind() == ValueKind::Map {
1539        Cloud {
1540            name: attr(value, "name")
1541                .and_then(|n| n.as_str().map(str::to_owned))
1542                .unwrap_or_else(|| fallback.to_owned()),
1543            points: attr(value, "points")
1544                .map(|p| points_of(&p))
1545                .unwrap_or_default(),
1546        }
1547    } else {
1548        Cloud {
1549            name: fallback.to_owned(),
1550            points: points_of(value),
1551        }
1552    }
1553}
1554
1555/// The points of `chart("scatter" | "bubble", …)`: `data` (a series with
1556/// `points`, a list of them, or a list of points), `series=[…]`,
1557/// `points=[…]`.
1558fn read_points(
1559    data: Option<Value>,
1560    kwargs: &Kwargs,
1561    title: &str,
1562) -> std::result::Result<Vec<Cloud>, Error> {
1563    let series: Option<Value> = kwargs.get("series")?;
1564    let points: Option<Value> = kwargs.get("points")?;
1565    let name: Option<String> = kwargs.get("name")?;
1566    let fallback = name.clone().unwrap_or_else(|| title.to_owned());
1567    let mut clouds = Vec::new();
1568    let list_of_series = |value: &Value, clouds: &mut Vec<Cloud>| {
1569        if let Ok(items) = value.try_iter() {
1570            for (i, item) in items.enumerate() {
1571                clouds.push(cloud_of(&item, &format!("{} {}", fallback, i + 1)));
1572            }
1573        }
1574    };
1575    match data {
1576        Some(data) if data.kind() == ValueKind::Map => match attr(&data, "series") {
1577            Some(series) => list_of_series(&series, &mut clouds),
1578            None => clouds.push(cloud_of(&data, &fallback)),
1579        },
1580        Some(data) if data.kind() == ValueKind::Seq => {
1581            // A list of series has maps with `points`; a list of points has
1582            // pairs, triples or maps with `x`.
1583            let first = data.try_iter().ok().and_then(|mut items| items.next());
1584            if first.is_some_and(|v| v.kind() == ValueKind::Map && attr(&v, "points").is_some()) {
1585                list_of_series(&data, &mut clouds);
1586            } else {
1587                clouds.push(cloud_of(&data, &fallback));
1588            }
1589        }
1590        _ => {}
1591    }
1592    if let Some(series) = series {
1593        list_of_series(&series, &mut clouds);
1594    }
1595    if let Some(points) = points {
1596        clouds.push(Cloud {
1597            name: fallback.clone(),
1598            points: points_of(&points),
1599        });
1600    }
1601    if let Some(name) = name
1602        && clouds.len() == 1
1603    {
1604        clouds[0].name = name;
1605    }
1606    Ok(clouds)
1607}
1608
1609/// As many decimals as the values need, up to 2.
1610fn auto_decimals(values: impl Iterator<Item = f64>) -> Option<u32> {
1611    let mut decimals = 0;
1612    for value in values {
1613        let fract = (value.abs() * 100.0).round() as i64 % 100;
1614        if fract % 10 != 0 {
1615            return Some(2);
1616        }
1617        if fract != 0 {
1618            decimals = 1;
1619        }
1620    }
1621    Some(decimals)
1622}
1623
1624/// How a scatter or bubble chart's values read.
1625struct Formats {
1626    x: Formatter,
1627    y: Formatter,
1628    size: Formatter,
1629    size_title: String,
1630}
1631
1632/// The tooltips' data for points.
1633#[derive(Serialize)]
1634struct HoverPoints<'a> {
1635    kind: &'a str,
1636    points: Vec<HoverPoint>,
1637}
1638
1639#[derive(Serialize)]
1640struct HoverPoint {
1641    /// The point's label, or "".
1642    title: String,
1643    /// Its series' name with more than one series, or "".
1644    name: String,
1645    slot: String,
1646    /// `[axis title, value]`.
1647    rows: Vec<(String, String)>,
1648    /// Where it is, in percent of the plot.
1649    left: f64,
1650    bottom: f64,
1651}
1652
1653/// The value range of points, padded for bubbles so they stay inside, with
1654/// 0 in when the data starts near it.
1655fn point_scale(values: impl Iterator<Item = f64>, pad: bool) -> (f64, f64, f64) {
1656    let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
1657    for v in values {
1658        lo = lo.min(v);
1659        hi = hi.max(v);
1660    }
1661    if !lo.is_finite() {
1662        return scale(0.0, 0.0);
1663    }
1664    if pad {
1665        // Room around the data, but not across 0 (no "-20 units").
1666        let room = ((hi - lo) * 0.08).max(hi.abs().max(lo.abs()) * 0.02);
1667        lo = if lo >= 0.0 {
1668            (lo - room).max(0.0)
1669        } else {
1670            lo - room
1671        };
1672        hi = if hi <= 0.0 {
1673            (hi + room).min(0.0)
1674        } else {
1675            hi + room
1676        };
1677    }
1678    if lo >= 0.0 && lo <= hi * 0.25 {
1679        lo = 0.0;
1680    }
1681    if hi <= 0.0 && hi >= lo * 0.25 {
1682        hi = 0.0;
1683    }
1684    nice(lo, hi)
1685}
1686
1687fn ticks(lo: f64, hi: f64, step: f64) -> Vec<f64> {
1688    let mut ticks = Vec::new();
1689    let mut tick = lo;
1690    while tick <= hi + step / 2.0 && ticks.len() < 12 {
1691        ticks.push(tick);
1692        tick += step;
1693    }
1694    ticks
1695}
1696
1697fn render_points(clouds: &[Cloud], options: &Options, formats: &Formats) -> String {
1698    let bubble = options.kind == "bubble";
1699    let all = || clouds.iter().flat_map(|c| c.points.iter());
1700    let (x_lo, x_hi, x_step) = point_scale(all().map(|p| p.x), bubble);
1701    let (y_lo, y_hi, y_step) = point_scale(all().map(|p| p.y), bubble);
1702    let px = |v: f64| (v - x_lo) / (x_hi - x_lo) * 100.0;
1703    let py = |v: f64| (v - y_lo) / (y_hi - y_lo) * 100.0;
1704    // Bubbles: the area follows the size, up to 40 px across, 6 px at least.
1705    let biggest = all()
1706        .filter_map(|p| p.size)
1707        .fold(0.0f64, |a, b| a.max(b.abs()));
1708    let diameter = |size: Option<f64>| {
1709        let share = match (size, biggest > 0.0) {
1710            (Some(size), true) => (size.abs() / biggest).sqrt(),
1711            _ => 0.0,
1712        };
1713        (6.0 + share * 34.0).round()
1714    };
1715    let x_name = options.x_title.clone().unwrap_or_else(|| "x".into());
1716    let y_name = options.y_title.clone().unwrap_or_else(|| "y".into());
1717    let several = clouds.len() > 1;
1718
1719    // Every point, left to right, as the arrow keys visit them.
1720    let mut order: Vec<(usize, &Point)> = clouds
1721        .iter()
1722        .enumerate()
1723        .flat_map(|(s, c)| c.points.iter().map(move |p| (s, p)))
1724        .collect();
1725    order.sort_by(|a, b| a.1.x.total_cmp(&b.1.x).then(a.1.y.total_cmp(&b.1.y)));
1726
1727    let rows_of = |point: &Point| {
1728        let mut rows = vec![
1729            (x_name.clone(), formats.x.full(point.x)),
1730            (y_name.clone(), formats.y.full(point.y)),
1731        ];
1732        if bubble && let Some(size) = point.size {
1733            rows.push((formats.size_title.clone(), formats.size.full(size)));
1734        }
1735        rows
1736    };
1737    let hover = HoverPoints {
1738        kind: &options.kind,
1739        points: order
1740            .iter()
1741            .map(|(s, point)| HoverPoint {
1742                title: point.label.clone().unwrap_or_default(),
1743                name: if several {
1744                    clouds[*s].name.clone()
1745                } else {
1746                    String::new()
1747                },
1748                slot: slot(*s),
1749                rows: rows_of(point),
1750                left: (px(point.x) * 100.0).round() / 100.0,
1751                bottom: (py(point.y) * 100.0).round() / 100.0,
1752            })
1753            .collect(),
1754    };
1755    let json = serde_json::to_string(&hover).unwrap_or_default();
1756    let mut out = String::new();
1757    let _ = write!(
1758        out,
1759        r#"<figure class="rx-chart rx-chart--{kind} rx-chart--points"{id} data-rx-chart="{json}">"#,
1760        kind = escape(&options.kind),
1761        id = options
1762            .id
1763            .as_deref()
1764            .map(|id| format!(r#" id="{}""#, escape(id)))
1765            .unwrap_or_default(),
1766        json = escape(&json),
1767    );
1768    if !options.title.is_empty() {
1769        let _ = write!(
1770            out,
1771            r#"<figcaption class="rx-visually-hidden">{}</figcaption>"#,
1772            escape(&options.title)
1773        );
1774    }
1775    if options.legend && several {
1776        let names: Vec<(usize, &str)> = clouds
1777            .iter()
1778            .enumerate()
1779            .map(|(i, c)| (i, c.name.as_str()))
1780            .collect();
1781        legend(&mut out, &names, "dot");
1782    }
1783    y_title(&mut out, options);
1784    let _ = write!(
1785        out,
1786        r#"<div class="rx-chart__frame" style="--rx-chart-h: {}px"><div class="rx-chart__y" aria-hidden="true">"#,
1787        options.height
1788    );
1789    let y_ticks = ticks(y_lo, y_hi, y_step);
1790    for t in &y_ticks {
1791        let _ = write!(
1792            out,
1793            r#"<span style="bottom: {}%">{}</span>"#,
1794            pct(py(*t)),
1795            escape(&formats.y.tick(*t))
1796        );
1797    }
1798    let label = if options.title.is_empty() {
1799        clouds
1800            .iter()
1801            .map(|c| c.name.as_str())
1802            .filter(|n| !n.is_empty())
1803            .collect::<Vec<_>>()
1804            .join(", ")
1805    } else {
1806        options.title.clone()
1807    };
1808    let label = match (&options.x_title, &options.y_title) {
1809        (Some(x), Some(y)) => {
1810            let axes = options.axes.replace(":y", y).replace(":x", x);
1811            if label.is_empty() {
1812                axes
1813            } else {
1814                format!("{label}: {axes}")
1815            }
1816        }
1817        _ => label,
1818    };
1819    let _ = write!(
1820        out,
1821        r#"</div><div class="rx-chart__plot" tabindex="0" role="img" aria-label="{}"><div class="rx-chart__grid" aria-hidden="true">"#,
1822        escape(&label)
1823    );
1824    for t in &y_ticks {
1825        let base = if t.abs() < y_step / 1e6 {
1826            " rx-chart__rule--base"
1827        } else {
1828            ""
1829        };
1830        let _ = write!(
1831            out,
1832            r#"<span class="rx-chart__rule{base}" style="bottom: {}%"></span>"#,
1833            pct(py(*t))
1834        );
1835    }
1836    let x_ticks = ticks(x_lo, x_hi, x_step);
1837    for t in &x_ticks {
1838        let base = if t.abs() < x_step / 1e6 {
1839            " rx-chart__rule--base"
1840        } else {
1841            ""
1842        };
1843        let _ = write!(
1844            out,
1845            r#"<span class="rx-chart__rule rx-chart__rule--x{base}" style="left: {}%"></span>"#,
1846            pct(px(*t))
1847        );
1848    }
1849    out.push_str(r#"</div><div class="rx-chart__points" aria-hidden="true">"#);
1850    // Big bubbles first, so small ones stay on top and reachable.
1851    let mut drawn: Vec<(usize, &(usize, &Point))> = order.iter().enumerate().collect();
1852    if bubble {
1853        drawn.sort_by(|a, b| {
1854            let size = |p: &Point| p.size.map(f64::abs).unwrap_or(0.0);
1855            size(b.1.1).total_cmp(&size(a.1.1))
1856        });
1857    }
1858    for (index, (s, point)) in drawn {
1859        let size = if bubble {
1860            format!("; --rx-point: {}px", diameter(point.size))
1861        } else {
1862            String::new()
1863        };
1864        let _ = write!(
1865            out,
1866            r#"<span class="rx-chart__point {}" data-index="{index}" style="left: {}%; bottom: {}%{size}"></span>"#,
1867            slot(*s),
1868            pct(px(point.x)),
1869            pct(py(point.y)),
1870        );
1871    }
1872    out.push_str(r#"</div><div class="rx-chart__tip" hidden></div></div>"#);
1873    out.push_str(r#"<div class="rx-chart__x" aria-hidden="true">"#);
1874    for (shown, t) in x_ticks.iter().enumerate() {
1875        let narrow = if shown % 2 == 1 && shown + 1 < x_ticks.len() {
1876            " rx-chart__x--odd"
1877        } else {
1878            ""
1879        };
1880        let _ = write!(
1881            out,
1882            r#"<span class="{narrow}" style="left: {}%">{}</span>"#,
1883            pct(px(*t)),
1884            escape(&formats.x.tick(*t))
1885        );
1886    }
1887    out.push_str("</div></div>");
1888    x_title(&mut out, options);
1889
1890    let mut heads = Vec::new();
1891    if several {
1892        heads.push(options.series_head.clone());
1893    }
1894    heads.push(x_name.clone());
1895    heads.push(y_name.clone());
1896    if bubble {
1897        heads.push(formats.size_title.clone());
1898    }
1899    let rows: Vec<(String, Vec<String>)> = order
1900        .iter()
1901        .enumerate()
1902        .map(|(i, (s, point))| {
1903            let mut cells = Vec::new();
1904            if several {
1905                cells.push(clouds[*s].name.clone());
1906            }
1907            cells.push(formats.x.full(point.x));
1908            cells.push(formats.y.full(point.y));
1909            if bubble {
1910                cells.push(
1911                    point
1912                        .size
1913                        .map(|v| formats.size.full(v))
1914                        .unwrap_or_else(|| "—".into()),
1915                );
1916            }
1917            (
1918                point.label.clone().unwrap_or_else(|| (i + 1).to_string()),
1919                cells,
1920            )
1921        })
1922        .collect();
1923    table(&mut out, options, &heads, &rows);
1924    out.push_str("</figure>");
1925    out
1926}
1927
1928#[cfg(test)]
1929mod tests {
1930    use super::*;
1931
1932    #[test]
1933    fn periods_parse_and_step_back() {
1934        assert_eq!(Period::parse("30d"), Some(Period::days(30)));
1935        assert_eq!(Period::parse("12M").map(|p| p.key()), Some("12m".into()));
1936        assert_eq!(Period::parse("ytd"), Some(Period::year_to_date()));
1937        for bad in [
1938            "",
1939            "0d",
1940            "400d",
1941            "40m",
1942            "105w",
1943            "d",
1944            "-3d",
1945            "2026-09-30..2026-09-01",
1946            "2020-01-01..2026-01-01",
1947            "2026-9-1..2026-09-30",
1948        ] {
1949            assert_eq!(Period::parse(bad), None, "{bad}");
1950        }
1951        assert_eq!(Period::days(30).bucket(), Bucket::Day);
1952        assert_eq!(Period::days(365).bucket(), Bucket::Month);
1953        let zone = Zone::UTC;
1954        let (start, end) = Period::days(7).days_in(zone);
1955        assert_eq!((end - start).num_days(), 7);
1956        let (before_start, before_end) = Period::days(7).previous().days_in(zone);
1957        assert_eq!(
1958            (before_end, (before_end - before_start).num_days()),
1959            (start, 7)
1960        );
1961        assert_eq!(Period::days(7).labels(zone).len(), 7);
1962        assert_eq!(Period::months(12).labels(zone).len(), 12);
1963        let (m0, m1) = Period::months(3).previous().days_in(zone);
1964        assert_eq!((m0.day(), m1.day()), (1, 1));
1965        assert_eq!(
1966            add_months(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), -1),
1967            NaiveDate::from_ymd_opt(2025, 12, 1).unwrap()
1968        );
1969    }
1970
1971    fn day(text: &str) -> NaiveDate {
1972        NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
1973    }
1974
1975    #[test]
1976    fn custom_ranges_parse_and_step_back() {
1977        let range = Period::between(day("2026-09-01"), day("2026-09-30")).unwrap();
1978        assert!(range.is_custom() && !Period::days(30).is_custom());
1979        assert_eq!(range.key(), "2026-09-01..2026-09-30");
1980        assert_eq!(Period::parse(&range.key()), Some(range));
1981        assert_eq!(
1982            range.days_in(Zone::UTC),
1983            (day("2026-09-01"), day("2026-10-01"))
1984        );
1985        // The period before: as many days, just before.
1986        assert_eq!(
1987            range.previous().days_in(Zone::UTC),
1988            (day("2026-08-02"), day("2026-09-01"))
1989        );
1990        // One day is fine; backwards or over three years is not.
1991        assert!(Period::between(day("2026-09-01"), day("2026-09-01")).is_some());
1992        assert!(Period::between(day("2026-09-02"), day("2026-09-01")).is_none());
1993        assert!(Period::between(day("2024-01-01"), day("2026-12-31")).is_some());
1994        assert!(Period::between(day("2024-01-01"), day("2027-01-01")).is_none());
1995        // From a query string.
1996        let query = "q=x&period=custom&from=2026-09-01&to=2026-09-30";
1997        assert_eq!(Period::from_query(query), Some(range));
1998        assert_eq!(
1999            Period::from_query("period=12w").map(|p| p.key()),
2000            Some("12w".into())
2001        );
2002        for bad in [
2003            "period=custom",
2004            "period=custom&from=2026-09-01",
2005            "period=custom&from=2026-09-31&to=2026-10-01",
2006            "period=custom&from=2026-10-01&to=2026-09-01",
2007            "from=2026-09-01&to=2026-09-30",
2008        ] {
2009            assert_eq!(Period::from_query(bad), None, "{bad}");
2010        }
2011        // Per day up to 92 days, per week up to 26 weeks, then per month.
2012        let span = |days: i64| {
2013            let from = day("2026-01-01");
2014            Period::between(from, from + Duration::days(days - 1))
2015                .unwrap()
2016                .bucket()
2017        };
2018        assert_eq!(
2019            (span(92), span(93), span(182), span(183)),
2020            (Bucket::Day, Bucket::Week, Bucket::Week, Bucket::Month)
2021        );
2022    }
2023
2024    #[test]
2025    fn weeks_start_on_monday() {
2026        assert_eq!(monday_of(day("2026-10-04")), day("2026-09-28")); // a Sunday
2027        assert_eq!(monday_of(day("2026-09-28")), day("2026-09-28"));
2028        let weeks = Period::weeks(4);
2029        assert_eq!(weeks.bucket(), Bucket::Week);
2030        let (start, end) = weeks.days_in(Zone::UTC);
2031        assert_eq!(start.weekday(), chrono::Weekday::Mon);
2032        assert!((end - start).num_days() > 21 && (end - start).num_days() <= 28);
2033        let labels = weeks.labels(Zone::UTC);
2034        assert_eq!(labels.len(), 4);
2035        assert_eq!(labels[0], start.format("%Y-%m-%d").to_string());
2036        // The four weeks before end where these start, at the same weekday.
2037        let (before_start, before_end) = weeks.previous().days_in(Zone::UTC);
2038        assert_eq!(
2039            (before_start, end - before_end),
2040            (start - Duration::days(28), Duration::days(28))
2041        );
2042        // A custom range per week: the first label is the Monday before.
2043        let range = Period::between(day("2026-09-02"), day("2026-09-30"))
2044            .unwrap()
2045            .per(Bucket::Week);
2046        assert_eq!(
2047            range.labels(Zone::UTC),
2048            [
2049                "2026-08-31",
2050                "2026-09-07",
2051                "2026-09-14",
2052                "2026-09-21",
2053                "2026-09-28"
2054            ]
2055        );
2056        assert_eq!(range.previous().bucket(), Bucket::Week);
2057        assert_eq!(Period::days(365).per(Bucket::Day).bucket(), Bucket::Day);
2058    }
2059
2060    #[test]
2061    fn series_add_up_and_compare() {
2062        let now = Series::new(vec!["a".into(), "b".into()], vec![30.0, 30.0]);
2063        let before = Series::new(vec!["a".into(), "b".into()], vec![20.0, 20.0]);
2064        assert_eq!(now.change_from(&before), Some(50.0));
2065        assert_eq!(now.change_from(&Series::default()), None);
2066        assert_eq!(
2067            serde_json::to_value(now.named("Sales")).unwrap()["name"],
2068            "Sales"
2069        );
2070    }
2071
2072    #[test]
2073    fn scales_are_clean() {
2074        assert_eq!(scale(0.0, 87.0), (0.0, 100.0, 25.0));
2075        assert_eq!(scale(0.0, 1234.0), (0.0, 1500.0, 500.0));
2076        assert_eq!(scale(-30.0, 70.0), (-50.0, 75.0, 25.0));
2077        assert_eq!(scale(0.0, 0.0), (0.0, 1.0, 0.25));
2078        // Scatter axes needn't start at 0, unless the data comes close.
2079        assert_eq!(
2080            point_scale([52.0, 87.0].into_iter(), false),
2081            (50.0, 90.0, 10.0)
2082        );
2083        assert_eq!(
2084            point_scale([3.0, 87.0].into_iter(), false),
2085            (0.0, 100.0, 25.0)
2086        );
2087        assert_eq!(point_scale([-5.0, -80.0].into_iter(), false).1, 0.0);
2088        assert_eq!(point_scale(std::iter::empty(), true), (0.0, 1.0, 0.25));
2089        assert_eq!(
2090            auto_decimals([1.0, 2.5].into_iter()),
2091            Some(1),
2092            "as many decimals as needed"
2093        );
2094        assert_eq!(auto_decimals([1.25].into_iter()), Some(2));
2095        assert_eq!(auto_decimals([3.0].into_iter()), Some(0));
2096    }
2097
2098    #[test]
2099    fn labels_and_ticks_read_well() {
2100        assert_eq!(display_label("2026-10-02", None), "Oct 2");
2101        assert_eq!(display_label("2026-08", None), "Aug 2026");
2102        assert_eq!(display_label("2026-10-02", Some("%d/%m")), "02/10");
2103        assert_eq!(display_label("Coffee", None), "Coffee");
2104        let f = |locale: &str| Formatter {
2105            format: "number".into(),
2106            decimals: None,
2107            currency: "USD".into(),
2108            locale: locale.into(),
2109            divisor: 1.0,
2110        };
2111        assert_eq!(f("en").tick(12_500.0), "12.5K");
2112        assert_eq!(f("es").tick(2_500_000.0), "2,5M");
2113        assert_eq!(f("en").tick(750.0), "750");
2114        assert_eq!(f("en").tick(2_000_000_000.0), "2B");
2115    }
2116
2117    // #255: "so far" periods on awkward days, and charts with awkward data.
2118
2119    /// Runs `f` with the clock on `date` at noon UTC.
2120    fn on<T>(date: &str, f: impl FnOnce() -> T) -> T {
2121        let at = day(date)
2122            .and_hms_opt(12, 0, 0)
2123            .unwrap()
2124            .and_utc()
2125            .timestamp();
2126        let now = chrono::Utc::now().timestamp();
2127        crate::clock::with_offset_sync(at - now, f)
2128    }
2129
2130    #[test]
2131    fn month_and_year_to_date_compare_with_as_many_days_before() {
2132        let zone = Zone::UTC;
2133        let mtd = Period::month_to_date();
2134        assert_eq!(mtd.key(), "mtd");
2135        assert_eq!(mtd.bucket(), Bucket::Day);
2136        // The 31st: the month so far is the whole month; the month before
2137        // (February) is cut at its own end.
2138        on("2026-03-31", || {
2139            assert_eq!(mtd.days_in(zone), (day("2026-03-01"), day("2026-04-01")));
2140            assert_eq!(
2141                mtd.previous().days_in(zone),
2142                (day("2026-02-01"), day("2026-03-01"))
2143            );
2144        });
2145        // 29 February: the year so far is 60 days; the year before has
2146        // as many days, from 1 January.
2147        on("2024-02-29", || {
2148            let ytd = Period::year_to_date();
2149            assert_eq!(ytd.days_in(zone), (day("2024-01-01"), day("2024-03-01")));
2150            assert_eq!(
2151                ytd.previous().days_in(zone),
2152                (day("2023-01-01"), day("2023-03-02"))
2153            );
2154        });
2155        // The last day of a leap year: the year before is cut at its end.
2156        on("2024-12-31", || {
2157            assert_eq!(
2158                Period::year_to_date().previous().days_in(zone),
2159                (day("2023-01-01"), day("2024-01-01"))
2160            );
2161        });
2162    }
2163
2164    fn render(source: &str) -> String {
2165        let mut env = minijinja::Environment::new();
2166        env.add_function("chart", chart("USD".into()));
2167        env.render_str(source, minijinja::context! {})
2168            .unwrap_or_else(|err| panic!("{err:#}"))
2169    }
2170
2171    #[test]
2172    fn awkward_data_still_draws() {
2173        // A gap in a line, values all below zero, all the same.
2174        let gap =
2175            render(r#"{{ chart("line", labels=["a", "b", "c", "d"], values=[1, none, 3, 4]) }}"#);
2176        assert!(gap.contains("<figure class=\"rx-chart"), "{gap}");
2177        for source in [
2178            r#"{{ chart("bar", [-5, -2, -9]) }}"#,
2179            r#"{{ chart("line", [3, 3, 3]) }}"#,
2180            r#"{{ chart("line", [0, 0]) }}"#,
2181        ] {
2182            assert!(
2183                render(source).contains("<figure class=\"rx-chart"),
2184                "{source}"
2185            );
2186        }
2187        // More series than colours: the rest share one.
2188        let many = render(
2189            r#"{{ chart("line", labels=["a"], series=[{"name": "1", "values": [1]}, {"name": "2", "values": [1]}, {"name": "3", "values": [1]}, {"name": "4", "values": [1]}, {"name": "5", "values": [1]}, {"name": "6", "values": [1]}, {"name": "7", "values": [1]}]) }}"#,
2190        );
2191        assert!(many.contains("rx-series-other"), "{many}");
2192    }
2193
2194    #[test]
2195    fn pies_skip_empty_slices_and_draw_a_whole_one_as_a_circle() {
2196        let pie = render(r#"{{ chart("pie", labels=["none", "all"], values=[0, 5]) }}"#);
2197        assert!(
2198            pie.contains(r#"<circle class="rx-chart__slice rx-series-2""#),
2199            "{pie}"
2200        );
2201        assert!(!pie.contains("rx-chart__hole"));
2202        assert!(pie.contains(r#"aria-label="none, all""#), "{pie}");
2203        let ring = render(r#"{{ chart("doughnut", labels=["all"], values=[5], legend=false) }}"#);
2204        assert!(ring.contains("rx-chart__hole"), "{ring}");
2205        assert!(!ring.contains("rx-chart__legend"));
2206        let empty = render(r#"{{ chart("pie", labels=["a"], values=[0]) }}"#);
2207        assert!(!empty.contains("rx-chart__slice"), "{empty}");
2208    }
2209
2210    /// Data given as a list of series, as one series object, as an object
2211    /// holding labels and series, and values that aren't numbers.
2212    #[test]
2213    fn data_comes_in_every_shape() {
2214        let list = render(
2215            r#"{{ chart("line", [{"name": "North", "values": [1, 2]}, {"values": [3, 4]}], labels=["x", "y"], name="Sales") }}"#,
2216        );
2217        assert!(list.contains(">North<"), "{list}");
2218        assert!(
2219            list.contains(">Sales 2<"),
2220            "a series without a name: {list}"
2221        );
2222        let one = render(r#"{{ chart("bar", {"name": "Visits", "values": [1, 2]}) }}"#);
2223        assert!(one.contains(">Visits<"), "{one}");
2224        let nested = render(
2225            r#"{{ chart("line", {"labels": ["Mon", "Tue"], "series": [{"name": "Cups", "values": [5, 6]}]}) }}"#,
2226        );
2227        assert!(
2228            nested.contains(">Mon<") && nested.contains(">Cups<"),
2229            "{nested}"
2230        );
2231        // Labels that aren't text are written as text; a number in text is a
2232        // number, other values are gaps.
2233        let odd = render(r#"{{ chart("line", labels=[1, true], values=["3", "x", 2.5]) }}"#);
2234        assert!(odd.contains(">True<"), "{odd}");
2235        assert!(
2236            odd.contains(">3.0</td>") && odd.contains(">—</td>"),
2237            "{odd}"
2238        );
2239        // Three values but two labels: the third gets its number.
2240        assert!(odd.contains(r#"<th scope="row">3</th>"#), "{odd}");
2241    }
2242
2243    #[test]
2244    fn axes_for_negative_and_flat_values() {
2245        assert_eq!(scale(-9.0, -2.0), (-10.0, 0.0, 2.5));
2246        let (lo, hi, step) = nice(3.0, 3.0);
2247        assert!(lo < 3.0 && hi > 3.0 && step > 0.0, "{lo} {hi} {step}");
2248        let (lo, hi, _) = nice(-4.0, -4.0);
2249        assert!(lo < -4.0 && hi > -4.0, "{lo} {hi}");
2250        let flat = render(r#"{{ chart("bar", [-4, -4]) }}"#);
2251        assert!(flat.contains(">-4<") || flat.contains(">−4<"), "{flat}");
2252    }
2253
2254    #[test]
2255    fn lines_break_at_gaps_and_keep_their_last_label() {
2256        let gap = render(r#"{{ chart("line", [1, none, 3, 4]) }}"#);
2257        assert_eq!(gap.matches(r#"class="rx-chart__line "#).count(), 2, "{gap}");
2258        // Twenty points: every third label, the last always, the one just
2259        // before it giving way.
2260        let many = render(r#"{{ chart("line", values=range(20)|list) }}"#);
2261        let x = many.split(r#"<div class="rx-chart__x""#).nth(1).unwrap();
2262        let x = x.split("</div>").next().unwrap();
2263        assert!(x.contains(">20</span>") && !x.contains(">19</span>"), "{x}");
2264        assert!(x.contains(">1</span>") && x.contains(">4</span>"), "{x}");
2265        // One point sits in the middle.
2266        let one = render(r#"{{ chart("line", [5]) }}"#);
2267        assert!(one.contains(r#"style="left: 50%">1</span>"#), "{one}");
2268    }
2269
2270    /// Line, bar and pie values with fractions keep them in the tooltip
2271    /// data and the table (they were rounded to whole numbers); whole
2272    /// numbers stay whole, and `decimals` still decides.
2273    #[test]
2274    fn fractions_keep_their_decimals() {
2275        let bars = render(r#"{{ chart("bar", [1.5, 2.25, 3]) }}"#);
2276        let table = bars.split("<tbody>").nth(1).unwrap();
2277        assert!(
2278            table.contains(">1.50<") && table.contains(">2.25<") && table.contains(">3.00<"),
2279            "{table}"
2280        );
2281        let pie = render(r#"{{ chart("pie", labels=["a", "b"], values=[4.5, 5.5]) }}"#);
2282        assert!(pie.contains(">4.5<"), "{pie}");
2283        let whole = render(r#"{{ chart("line", [1, 2]) }}"#);
2284        assert!(
2285            whole.split("<tbody>").nth(1).unwrap().contains(">2<"),
2286            "{whole}"
2287        );
2288        let chosen = render(r#"{{ chart("bar", [1.5], decimals=0) }}"#);
2289        assert!(
2290            chosen.split("<tbody>").nth(1).unwrap().contains(">2<"),
2291            "{chosen}"
2292        );
2293    }
2294
2295    /// Scatter and bubble data in every shape `read_points` takes, and a
2296    /// line chart's `name` for its one series.
2297    #[test]
2298    fn points_come_in_every_shape() {
2299        let nested = render(
2300            r#"{{ chart("scatter", {"series": [{"name": "North", "points": [[1, 2]]}, {"name": "South", "points": [[3, 4]]}]}) }}"#,
2301        );
2302        assert!(
2303            nested.contains(">North<") && nested.contains(">South<"),
2304            "{nested}"
2305        );
2306        let listed = render(r#"{{ chart("scatter", [{"name": "East", "points": [[1, 2]]}]) }}"#);
2307        // One series: drawn, without a legend to name it.
2308        assert!(listed.contains(r#"data-index="0""#), "{listed}");
2309        let pairs = render(r#"{{ chart("scatter", [[1, 2], [3, 4]], name="Pairs") }}"#);
2310        assert!(pairs.contains(r#"data-index="1""#), "two points: {pairs}");
2311        let one =
2312            render(r#"{{ chart("bubble", {"name": "Sizes", "points": [[1, 2, 3], [2, 3, 9]]}) }}"#);
2313        assert!(one.contains(r#"data-index="1""#), "{one}");
2314        let kwarg = render(
2315            r#"{{ chart("scatter", series=[{"name": "West", "points": [[5, 5]]}, {"name": "Far", "points": [[6, 6]]}]) }}"#,
2316        );
2317        assert!(
2318            kwarg.contains(">West<") && kwarg.contains(">Far<"),
2319            "{kwarg}"
2320        );
2321        let named = render(r#"{{ chart("line", [1, 2], name="Only") }}"#);
2322        assert!(named.contains(">Only<"), "{named}");
2323    }
2324
2325    #[test]
2326    fn scatter_marks_the_zero_line() {
2327        let html = render(r#"{{ chart("scatter", points=[[-5, -10], [8, 20]]) }}"#);
2328        assert!(html.contains("rx-chart__rule--base"), "{html}");
2329    }
2330
2331    #[test]
2332    fn scatter_with_negative_values_only() {
2333        let html = render(r#"{{ chart("scatter", points=[[-5, -10], [-1, -3]]) }}"#);
2334        // The axes reach below the lowest point and up to zero.
2335        assert!(
2336            html.contains(r#"<span style="bottom: 0%">-10</span>"#),
2337            "{html}"
2338        );
2339        assert!(html.contains(r#"style="left: 100%">0</span>"#), "{html}");
2340    }
2341}