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};
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}
731
732impl Formatter {
733    fn full(&self, value: f64) -> String {
734        match self.format.as_str() {
735            "money" => format_money(value, &self.currency, self.decimals, &self.locale),
736            "percent" => format!(
737                "{}%",
738                format_number(value, self.decimals.unwrap_or(1), &self.locale)
739            ),
740            _ => format_number(value, self.decimals.unwrap_or(0), &self.locale),
741        }
742    }
743
744    /// Axis ticks: `12.5K` (`12,5K` where the locale writes a decimal
745    /// comma), plain under 10,000.
746    fn tick(&self, value: f64) -> String {
747        let units: [(f64, &str); 4] = [(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")];
748        let suffix = if self.format == "percent" { "%" } else { "" };
749        if value.abs() >= 10_000.0 {
750            for (size, unit) in units {
751                if value.abs() >= size {
752                    let scaled = value / size;
753                    let decimals = if scaled.fract().abs() < 1e-9 { 0 } else { 1 };
754                    return format!(
755                        "{}{unit}{suffix}",
756                        format_number(scaled, decimals, &self.locale)
757                    );
758                }
759            }
760        }
761        let decimals = if value.fract().abs() < 1e-9 { 0 } else { 1 };
762        format!("{}{suffix}", format_number(value, decimals, &self.locale))
763    }
764}
765
766/// Clean ticks from `lo` to `hi` (0 always included): about four steps of
767/// 1, 2, 2.5 or 5 × 10ⁿ.
768fn scale(lo: f64, hi: f64) -> (f64, f64, f64) {
769    nice(lo.min(0.0), hi.max(0.0))
770}
771
772/// Clean ticks covering `lo` to `hi`, without forcing 0 in.
773fn nice(mut lo: f64, mut hi: f64) -> (f64, f64, f64) {
774    if (hi - lo).abs() < f64::EPSILON {
775        if lo == 0.0 {
776            hi = lo + 1.0;
777        } else {
778            let pad = lo.abs() / 10.0;
779            (lo, hi) = (lo - pad, hi + pad);
780        }
781    }
782    let raw = (hi - lo) / 4.0;
783    let magnitude = 10f64.powf(raw.log10().floor());
784    let normal = raw / magnitude;
785    let nice = if normal <= 1.0 {
786        1.0
787    } else if normal <= 2.0 {
788        2.0
789    } else if normal <= 2.5 {
790        2.5
791    } else if normal <= 5.0 {
792        5.0
793    } else {
794        10.0
795    };
796    let step = nice * magnitude;
797    lo = (lo / step).floor() * step;
798    hi = (hi / step).ceil() * step;
799    (lo, hi, step)
800}
801
802fn slot(i: usize) -> String {
803    if i < 6 {
804        format!("rx-series-{}", i + 1)
805    } else {
806        "rx-series-other".into()
807    }
808}
809
810fn pct(value: f64) -> String {
811    let rounded = (value * 100.0).round() / 100.0;
812    format!("{rounded}")
813}
814
815/// `chart(kind, data, …)`: see docs/ui.md "Charts".
816pub(crate) fn chart(
817    currency: String,
818) -> impl Fn(&State, String, Option<Value>, Kwargs) -> std::result::Result<Value, Error>
819+ Send
820+ Sync
821+ 'static {
822    move |state: &State, kind: String, data: Option<Value>, kwargs: Kwargs| {
823        let title: Option<String> = kwargs.get("title")?;
824        let height: Option<u32> = kwargs.get("height")?;
825        let format: Option<String> = kwargs.get("format")?;
826        let decimals: Option<u32> = kwargs.get("decimals")?;
827        let currency_kw: Option<String> = kwargs.get("currency")?;
828        let stacked: Option<bool> = kwargs.get("stacked")?;
829        let legend: Option<bool> = kwargs.get("legend")?;
830        let table: Option<bool> = kwargs.get("table")?;
831        let x_format: Option<String> = kwargs.get("x_format")?;
832        let x_title: Option<String> = kwargs.get("x_title")?;
833        let y_title: Option<String> = kwargs.get("y_title")?;
834        let id: Option<String> = kwargs.get("id")?;
835        let locale = locale(state);
836        let title = title.unwrap_or_default();
837        let currency = currency_kw
838            .map(|c| c.trim().to_ascii_uppercase())
839            .unwrap_or_else(|| currency.clone());
840        let formatter = |format: Option<String>, decimals: Option<u32>| Formatter {
841            format: format.unwrap_or_else(|| "number".into()),
842            decimals,
843            currency: currency.clone(),
844            locale: locale.clone(),
845        };
846        let mut options = Options {
847            kind: kind.clone(),
848            title: title.clone(),
849            height: height.unwrap_or(240).clamp(80, 800),
850            stacked: stacked.unwrap_or(false),
851            legend: legend.unwrap_or(true),
852            table: table.unwrap_or(true),
853            labels: Vec::new(),
854            id,
855            show_data: text(state, "ui.chart.show_data", &locale),
856            other: text(state, "ui.chart.other", &locale),
857            series_head: text(state, "ui.chart.series", &locale),
858            axes: text(state, "ui.chart.axes", &locale),
859            x_title,
860            y_title,
861        };
862        let html = match kind.as_str() {
863            "scatter" | "bubble" => {
864                let size_format: Option<String> = kwargs.get("size_format")?;
865                let size_title: Option<String> = kwargs.get("size_title")?;
866                let clouds = read_points(data, &kwargs, &title)?;
867                kwargs.assert_all_used()?;
868                let all = || clouds.iter().flat_map(|c| c.points.iter());
869                let formats = Formats {
870                    x: formatter(x_format, auto_decimals(all().map(|p| p.x))),
871                    y: formatter(
872                        format,
873                        decimals.or_else(|| auto_decimals(all().map(|p| p.y))),
874                    ),
875                    size: formatter(size_format, auto_decimals(all().filter_map(|p| p.size))),
876                    size_title: size_title.unwrap_or_else(|| text(state, "ui.chart.size", &locale)),
877                };
878                render_points(&clouds, &options, &formats)
879            }
880            "line" | "area" | "bar" | "pie" | "doughnut" => {
881                let data = read_data(data, &kwargs, &title)?;
882                kwargs.assert_all_used()?;
883                options.labels = data
884                    .labels
885                    .iter()
886                    .map(|l| display_label(l, x_format.as_deref()))
887                    .collect();
888                // Plain numbers get as many decimals as the data needs, as
889                // on scatter charts; money and percent keep their own.
890                let decimals = match format.as_deref() {
891                    None | Some("number") => decimals.or_else(|| {
892                        auto_decimals(
893                            data.series
894                                .iter()
895                                .flat_map(|s| s.values.iter().flatten().copied()),
896                        )
897                    }),
898                    _ => decimals,
899                };
900                let formatter = formatter(format, decimals);
901                if kind == "pie" || kind == "doughnut" {
902                    render_pie(&data, &options, &formatter)
903                } else {
904                    render_xy(&data, &options, &formatter)
905                }
906            }
907            other => {
908                return Err(Error::new(
909                    ErrorKind::InvalidOperation,
910                    format!(
911                        "chart: unknown kind `{other}` (line, area, bar, pie, doughnut, scatter or bubble)"
912                    ),
913                ));
914            }
915        };
916        Ok(Value::from_safe_string(html))
917    }
918}
919
920struct Options {
921    kind: String,
922    title: String,
923    height: u32,
924    stacked: bool,
925    legend: bool,
926    table: bool,
927    labels: Vec<String>,
928    id: Option<String>,
929    show_data: String,
930    other: String,
931    /// The data table's heading over series names.
932    series_head: String,
933    /// `:y by :x`, for a scatter chart's accessible name.
934    axes: String,
935    /// The axes' titles, shown along them.
936    x_title: Option<String>,
937    y_title: Option<String>,
938}
939
940/// The data the page's script reads for tooltips (and a table reads too).
941#[derive(Serialize)]
942struct Hover<'a> {
943    kind: &'a str,
944    labels: &'a [String],
945    series: Vec<HoverSeries>,
946}
947
948#[derive(Serialize)]
949struct HoverSeries {
950    name: String,
951    slot: String,
952    values: Vec<Option<String>>,
953}
954
955fn open_figure(out: &mut String, options: &Options, hover: &Hover) {
956    let json = serde_json::to_string(hover).unwrap_or_default();
957    let _ = write!(
958        out,
959        r#"<figure class="rx-chart rx-chart--{kind}"{id} data-rx-chart="{json}">"#,
960        kind = escape(&options.kind),
961        id = options
962            .id
963            .as_deref()
964            .map(|id| format!(r#" id="{}""#, escape(id)))
965            .unwrap_or_default(),
966        json = escape(&json),
967    );
968    if !options.title.is_empty() {
969        let _ = write!(
970            out,
971            r#"<figcaption class="rx-visually-hidden">{}</figcaption>"#,
972            escape(&options.title)
973        );
974    }
975}
976
977fn legend(out: &mut String, names: &[(usize, &str)], key: &str) {
978    out.push_str(r#"<ul class="rx-chart__legend" role="list">"#);
979    for (i, name) in names {
980        let _ = write!(
981            out,
982            r#"<li><span class="rx-chart__key rx-chart__key--{key} {slot}" aria-hidden="true"></span>{name}</li>"#,
983            slot = slot(*i),
984            name = escape(name),
985        );
986    }
987    out.push_str("</ul>");
988}
989
990fn table(out: &mut String, options: &Options, heads: &[String], rows: &[(String, Vec<String>)]) {
991    if !options.table {
992        return;
993    }
994    let _ = write!(
995        out,
996        r#"<details class="rx-chart__table"><summary>{}</summary><div class="rx-table-wrap"><table class="rx-table"><thead><tr><th scope="col"></th>"#,
997        escape(&options.show_data)
998    );
999    for head in heads {
1000        let _ = write!(
1001            out,
1002            r#"<th scope="col" class="rx-num">{}</th>"#,
1003            escape(head)
1004        );
1005    }
1006    out.push_str("</tr></thead><tbody>");
1007    for (label, cells) in rows {
1008        let _ = write!(out, r#"<tr><th scope="row">{}</th>"#, escape(label));
1009        for cell in cells {
1010            let _ = write!(out, r#"<td class="rx-num">{}</td>"#, escape(cell));
1011        }
1012        out.push_str("</tr>");
1013    }
1014    out.push_str("</tbody></table></div></details>");
1015}
1016
1017fn render_xy(data: &Data, options: &Options, formatter: &Formatter) -> String {
1018    let n = data.labels.len();
1019    let bar = options.kind == "bar";
1020    let stacked = bar && options.stacked;
1021    // The value range: per label sums for stacked bars.
1022    let (mut lo, mut hi) = (0.0f64, 0.0f64);
1023    for i in 0..n {
1024        let (mut up, mut down) = (0.0, 0.0);
1025        for line in &data.series {
1026            if let Some(v) = line.values[i] {
1027                if stacked {
1028                    if v >= 0.0 { up += v } else { down += v }
1029                } else {
1030                    hi = hi.max(v);
1031                    lo = lo.min(v);
1032                }
1033            }
1034        }
1035        hi = hi.max(up);
1036        lo = lo.min(down);
1037    }
1038    let (lo, hi, step) = scale(lo, hi);
1039    let y = |v: f64| (v - lo) / (hi - lo) * 100.0;
1040    let zero = y(0.0);
1041
1042    let hover = Hover {
1043        kind: &options.kind,
1044        labels: &options.labels,
1045        series: data
1046            .series
1047            .iter()
1048            .enumerate()
1049            .map(|(i, line)| HoverSeries {
1050                name: line.name.clone(),
1051                slot: slot(i),
1052                values: line
1053                    .values
1054                    .iter()
1055                    .map(|v| v.map(|v| formatter.full(v)))
1056                    .collect(),
1057            })
1058            .collect(),
1059    };
1060    let mut out = String::new();
1061    open_figure(&mut out, options, &hover);
1062    if options.legend && data.series.len() > 1 {
1063        let names: Vec<(usize, &str)> = data
1064            .series
1065            .iter()
1066            .enumerate()
1067            .map(|(i, s)| (i, s.name.as_str()))
1068            .collect();
1069        legend(&mut out, &names, if bar { "box" } else { "line" });
1070    }
1071    y_title(&mut out, options);
1072    let _ = write!(
1073        out,
1074        r#"<div class="rx-chart__frame" style="--rx-chart-h: {}px"><div class="rx-chart__y" aria-hidden="true">"#,
1075        options.height
1076    );
1077    let mut ticks = Vec::new();
1078    let mut tick = lo;
1079    while tick <= hi + step / 2.0 && ticks.len() < 12 {
1080        ticks.push(tick);
1081        tick += step;
1082    }
1083    for t in &ticks {
1084        let _ = write!(
1085            out,
1086            r#"<span style="bottom: {}%">{}</span>"#,
1087            pct(y(*t)),
1088            escape(&formatter.tick(*t))
1089        );
1090    }
1091    let label = if options.title.is_empty() {
1092        data.series
1093            .iter()
1094            .map(|s| s.name.as_str())
1095            .collect::<Vec<_>>()
1096            .join(", ")
1097    } else {
1098        options.title.clone()
1099    };
1100    let _ = write!(
1101        out,
1102        r#"</div><div class="rx-chart__plot" tabindex="0" role="img" aria-label="{}"><div class="rx-chart__grid" aria-hidden="true">"#,
1103        escape(&label)
1104    );
1105    for t in &ticks {
1106        let base = if t.abs() < step / 1e6 {
1107            " rx-chart__rule--base"
1108        } else {
1109            ""
1110        };
1111        let _ = write!(
1112            out,
1113            r#"<span class="rx-chart__rule{base}" style="bottom: {}%"></span>"#,
1114            pct(y(*t))
1115        );
1116    }
1117    out.push_str("</div>");
1118
1119    if bar {
1120        out.push_str(r#"<div class="rx-chart__bars" aria-hidden="true">"#);
1121        for i in 0..n {
1122            let _ = write!(out, r#"<div class="rx-chart__band" data-index="{i}">"#);
1123            if stacked {
1124                out.push_str(r#"<div class="rx-chart__column">"#);
1125                let (mut up, mut down) = (0.0, 0.0);
1126                let tops: Vec<usize> = {
1127                    // The outermost segment on each side gets the rounded end.
1128                    let mut last_up = None;
1129                    let mut last_down = None;
1130                    for (s, line) in data.series.iter().enumerate() {
1131                        match line.values[i] {
1132                            Some(v) if v > 0.0 => last_up = Some(s),
1133                            Some(v) if v < 0.0 => last_down = Some(s),
1134                            _ => {}
1135                        }
1136                    }
1137                    last_up.into_iter().chain(last_down).collect()
1138                };
1139                for (s, line) in data.series.iter().enumerate() {
1140                    let Some(v) = line.values[i].filter(|v| *v != 0.0) else {
1141                        continue;
1142                    };
1143                    let (from, to) = if v > 0.0 {
1144                        let from = up;
1145                        up += v;
1146                        (from, up)
1147                    } else {
1148                        let from = down;
1149                        down += v;
1150                        (down, from)
1151                    };
1152                    let classes = format!(
1153                        "rx-chart__bar {}{}{}",
1154                        slot(s),
1155                        if v < 0.0 { " rx-chart__bar--neg" } else { "" },
1156                        if tops.contains(&s) {
1157                            " rx-chart__bar--end"
1158                        } else {
1159                            ""
1160                        },
1161                    );
1162                    let _ = write!(
1163                        out,
1164                        r#"<span class="{classes}" style="bottom: {}%; height: {}%"></span>"#,
1165                        pct(y(from)),
1166                        pct(y(to) - y(from)),
1167                    );
1168                }
1169                out.push_str("</div>");
1170            } else {
1171                for (s, line) in data.series.iter().enumerate() {
1172                    out.push_str(r#"<div class="rx-chart__column">"#);
1173                    if let Some(v) = line.values[i].filter(|v| *v != 0.0) {
1174                        let (from, to) = if v > 0.0 { (0.0, v) } else { (v, 0.0) };
1175                        let _ = write!(
1176                            out,
1177                            r#"<span class="rx-chart__bar rx-chart__bar--end {}{}" style="bottom: {}%; height: {}%"></span>"#,
1178                            slot(s),
1179                            if v < 0.0 { " rx-chart__bar--neg" } else { "" },
1180                            pct(y(from)),
1181                            pct(y(to) - y(from)),
1182                        );
1183                    }
1184                    out.push_str("</div>");
1185                }
1186            }
1187            out.push_str("</div>");
1188        }
1189        out.push_str("</div>");
1190    } else {
1191        let x = |i: usize| {
1192            if n > 1 {
1193                i as f64 / (n - 1) as f64 * 1000.0
1194            } else {
1195                500.0
1196            }
1197        };
1198        let sy = |v: f64| 1000.0 - y(v) * 10.0;
1199        out.push_str(r#"<svg class="rx-chart__svg" viewBox="0 0 1000 1000" preserveAspectRatio="none" aria-hidden="true" focusable="false">"#);
1200        for (s, line) in data.series.iter().enumerate() {
1201            // Runs of values; a missing one breaks the line.
1202            let mut runs: Vec<Vec<(f64, f64)>> = vec![Vec::new()];
1203            for (i, value) in line.values.iter().enumerate() {
1204                match (value, runs.last_mut()) {
1205                    (Some(v), Some(run)) => run.push((x(i), sy(*v))),
1206                    _ => runs.push(Vec::new()),
1207                }
1208            }
1209            for run in runs.iter().filter(|r| !r.is_empty()) {
1210                let path: String = run
1211                    .iter()
1212                    .enumerate()
1213                    .map(|(k, (px, py))| {
1214                        format!("{}{:.1},{:.1}", if k == 0 { "M" } else { "L" }, px, py)
1215                    })
1216                    .collect();
1217                if options.kind == "area" {
1218                    let base = 1000.0 - zero * 10.0;
1219                    let _ = write!(
1220                        out,
1221                        r#"<path class="rx-chart__area {}" d="{path}L{:.1},{base:.1}L{:.1},{base:.1}Z"/>"#,
1222                        slot(s),
1223                        run.last().map(|p| p.0).unwrap_or(0.0),
1224                        run.first().map(|p| p.0).unwrap_or(0.0),
1225                    );
1226                }
1227                let _ = write!(
1228                    out,
1229                    r#"<path class="rx-chart__line {}" d="{path}" vector-effect="non-scaling-stroke"/>"#,
1230                    slot(s)
1231                );
1232            }
1233        }
1234        out.push_str("</svg>");
1235        // The last point of each line, as a dot that stays round.
1236        for (s, line) in data.series.iter().enumerate() {
1237            if let Some((i, v)) = line
1238                .values
1239                .iter()
1240                .enumerate()
1241                .rev()
1242                .find_map(|(i, v)| v.map(|v| (i, v)))
1243            {
1244                let _ = write!(
1245                    out,
1246                    r#"<span class="rx-chart__dot {}" style="left: {}%; bottom: {}%" aria-hidden="true"></span>"#,
1247                    slot(s),
1248                    pct(x(i) / 10.0),
1249                    pct(y(v)),
1250                );
1251            }
1252        }
1253    }
1254    out.push_str(r#"<span class="rx-chart__cross" hidden></span><div class="rx-chart__tip" hidden></div></div>"#);
1255
1256    // X labels: at most 8, the first and the last always.
1257    out.push_str(r#"<div class="rx-chart__x" aria-hidden="true">"#);
1258    let every = n.div_ceil(8).max(1);
1259    let mut shown_at: Vec<usize> = (0..n).step_by(every).collect();
1260    if let Some(&last) = shown_at.last()
1261        && last + 1 < n
1262    {
1263        // The last label always shows; one too close before it gives way.
1264        if n - 1 - last < every.div_ceil(2) && shown_at.len() > 1 {
1265            shown_at.pop();
1266        }
1267        shown_at.push(n - 1);
1268    }
1269    for (shown, &i) in shown_at.iter().enumerate() {
1270        let label = &options.labels[i];
1271        let left = if bar {
1272            (i as f64 + 0.5) / n as f64 * 100.0
1273        } else if n > 1 {
1274            i as f64 / (n - 1) as f64 * 100.0
1275        } else {
1276            50.0
1277        };
1278        // On phones every other label hides; the first and the last stay.
1279        let narrow = if shown % 2 == 1 && shown + 1 < shown_at.len() {
1280            " rx-chart__x--odd"
1281        } else {
1282            ""
1283        };
1284        let _ = write!(
1285            out,
1286            r#"<span class="{narrow}" style="left: {}%">{}</span>"#,
1287            pct(left),
1288            escape(label)
1289        );
1290    }
1291    out.push_str("</div></div>");
1292    x_title(&mut out, options);
1293
1294    let heads: Vec<String> = data.series.iter().map(|s| s.name.clone()).collect();
1295    let rows: Vec<(String, Vec<String>)> = (0..n)
1296        .map(|i| {
1297            (
1298                options.labels[i].clone(),
1299                data.series
1300                    .iter()
1301                    .map(|s| {
1302                        s.values[i]
1303                            .map(|v| formatter.full(v))
1304                            .unwrap_or_else(|| "—".into())
1305                    })
1306                    .collect(),
1307            )
1308        })
1309        .collect();
1310    table(&mut out, options, &heads, &rows);
1311    out.push_str("</figure>");
1312    out
1313}
1314
1315fn render_pie(data: &Data, options: &Options, formatter: &Formatter) -> String {
1316    let values = data
1317        .series
1318        .first()
1319        .map(|s| s.values.clone())
1320        .unwrap_or_default();
1321    // Six slices at most: the rest is "Other".
1322    let mut slices: Vec<(String, f64)> = data
1323        .labels
1324        .iter()
1325        .zip(values)
1326        .map(|(label, v)| (label.clone(), v.unwrap_or(0.0).max(0.0)))
1327        .collect();
1328    let display: Vec<String> = options.labels.clone();
1329    for (slice, label) in slices.iter_mut().zip(&display) {
1330        slice.0 = label.clone();
1331    }
1332    if slices.len() > 6 {
1333        let rest: f64 = slices[5..].iter().map(|s| s.1).sum();
1334        slices.truncate(5);
1335        slices.push((options.other.clone(), rest));
1336    }
1337    let total: f64 = slices.iter().map(|s| s.1).sum();
1338    let share = |v: f64| if total > 0.0 { v / total * 100.0 } else { 0.0 };
1339    let percent = |v: f64| format!("{}%", format_number(share(v), 1, &formatter.locale));
1340    let labels: Vec<String> = slices.iter().map(|s| s.0.clone()).collect();
1341    let hover = Hover {
1342        kind: &options.kind,
1343        labels: &labels,
1344        series: vec![HoverSeries {
1345            name: options.title.clone(),
1346            slot: String::new(),
1347            values: slices
1348                .iter()
1349                .map(|s| Some(format!("{} ({})", formatter.full(s.1), percent(s.1))))
1350                .collect(),
1351        }],
1352    };
1353    let mut out = String::new();
1354    open_figure(&mut out, options, &hover);
1355    let label = if options.title.is_empty() {
1356        labels.join(", ")
1357    } else {
1358        options.title.clone()
1359    };
1360    let _ = write!(
1361        out,
1362        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">"#,
1363        escape(&label)
1364    );
1365    let inner = if options.kind == "doughnut" {
1366        30.0
1367    } else {
1368        0.0
1369    };
1370    let outer = 48.0;
1371    let mut angle = -std::f64::consts::FRAC_PI_2;
1372    for (i, (_, v)) in slices.iter().enumerate() {
1373        if *v <= 0.0 || total <= 0.0 {
1374            continue;
1375        }
1376        let sweep = v / total * std::f64::consts::TAU;
1377        let class = format!("rx-chart__slice {}", slot(i));
1378        if sweep >= std::f64::consts::TAU - 1e-9 {
1379            let _ = write!(
1380                out,
1381                r#"<circle class="{class}" data-index="{i}" r="{}" fill-rule="evenodd"/>"#,
1382                outer
1383            );
1384            if inner > 0.0 {
1385                let _ = write!(out, r#"<circle class="rx-chart__hole" r="{inner}"/>"#);
1386            }
1387        } else {
1388            let (a0, a1) = (angle, angle + sweep);
1389            let large = if sweep > std::f64::consts::PI { 1 } else { 0 };
1390            let p = |r: f64, a: f64| (r * a.cos(), r * a.sin());
1391            let (x0, y0) = p(outer, a0);
1392            let (x1, y1) = p(outer, a1);
1393            let d = if inner > 0.0 {
1394                let (x2, y2) = p(inner, a1);
1395                let (x3, y3) = p(inner, a0);
1396                format!(
1397                    "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"
1398                )
1399            } else {
1400                format!("M0,0L{x0:.3},{y0:.3}A{outer},{outer} 0 {large} 1 {x1:.3},{y1:.3}Z")
1401            };
1402            let _ = write!(out, r#"<path class="{class}" data-index="{i}" d="{d}"/>"#);
1403        }
1404        angle += sweep;
1405    }
1406    out.push_str(r#"</svg><div class="rx-chart__tip" hidden></div></div>"#);
1407    if options.legend {
1408        out.push_str(r#"<ul class="rx-chart__legend rx-chart__legend--pie" role="list">"#);
1409        for (i, (name, v)) in slices.iter().enumerate() {
1410            let _ = write!(
1411                out,
1412                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>"#,
1413                slot(i),
1414                escape(name),
1415                escape(&formatter.full(*v)),
1416                escape(&percent(*v)),
1417            );
1418        }
1419        out.push_str("</ul>");
1420    }
1421    out.push_str("</div>");
1422    let heads = vec![
1423        if options.title.is_empty() {
1424            "".into()
1425        } else {
1426            options.title.clone()
1427        },
1428        "%".into(),
1429    ];
1430    let rows: Vec<(String, Vec<String>)> = slices
1431        .iter()
1432        .map(|(name, v)| (name.clone(), vec![formatter.full(*v), percent(*v)]))
1433        .collect();
1434    table(&mut out, options, &heads, &rows);
1435    out.push_str("</figure>");
1436    out
1437}
1438
1439/// The y axis' title, over the axis.
1440fn y_title(out: &mut String, options: &Options) {
1441    if let Some(title) = options.y_title.as_deref().filter(|t| !t.is_empty()) {
1442        let _ = write!(
1443            out,
1444            r#"<p class="rx-chart__axis-title rx-chart__axis-title--y" aria-hidden="true">{}</p>"#,
1445            escape(title)
1446        );
1447    }
1448}
1449
1450/// The x axis' title, under its labels.
1451fn x_title(out: &mut String, options: &Options) {
1452    if let Some(title) = options.x_title.as_deref().filter(|t| !t.is_empty()) {
1453        let _ = write!(
1454            out,
1455            r#"<p class="rx-chart__axis-title rx-chart__axis-title--x" aria-hidden="true">{}</p>"#,
1456            escape(title)
1457        );
1458    }
1459}
1460
1461// ---------------------------------------------------------------------------
1462// Scatter and bubble charts.
1463
1464/// One point of a scatter (x, y) or bubble (x, y, size) chart.
1465struct Point {
1466    x: f64,
1467    y: f64,
1468    size: Option<f64>,
1469    label: Option<String>,
1470}
1471
1472/// The points of one series.
1473struct Cloud {
1474    name: String,
1475    points: Vec<Point>,
1476}
1477
1478/// `[x, y]`, `[x, y, size]` or `{x, y, size, label}`; `None` without both
1479/// numbers.
1480fn point_of(value: &Value) -> Option<Point> {
1481    if value.kind() == ValueKind::Map {
1482        return Some(Point {
1483            x: attr(value, "x").as_ref().and_then(number_of)?,
1484            y: attr(value, "y").as_ref().and_then(number_of)?,
1485            size: attr(value, "size").as_ref().and_then(number_of),
1486            label: attr(value, "label").map(|l| {
1487                l.as_str()
1488                    .map(str::to_owned)
1489                    .unwrap_or_else(|| l.to_string())
1490            }),
1491        });
1492    }
1493    let items: Vec<Value> = value.try_iter().ok()?.collect();
1494    Some(Point {
1495        x: number_of(items.first()?)?,
1496        y: number_of(items.get(1)?)?,
1497        size: items.get(2).and_then(number_of),
1498        label: None,
1499    })
1500}
1501
1502fn points_of(value: &Value) -> Vec<Point> {
1503    match value.try_iter() {
1504        Ok(items) if value.kind() == ValueKind::Seq => {
1505            items.filter_map(|item| point_of(&item)).collect()
1506        }
1507        _ => Vec::new(),
1508    }
1509}
1510
1511/// A series `{name, points}`, or a list of points.
1512fn cloud_of(value: &Value, fallback: &str) -> Cloud {
1513    if value.kind() == ValueKind::Map {
1514        Cloud {
1515            name: attr(value, "name")
1516                .and_then(|n| n.as_str().map(str::to_owned))
1517                .unwrap_or_else(|| fallback.to_owned()),
1518            points: attr(value, "points")
1519                .map(|p| points_of(&p))
1520                .unwrap_or_default(),
1521        }
1522    } else {
1523        Cloud {
1524            name: fallback.to_owned(),
1525            points: points_of(value),
1526        }
1527    }
1528}
1529
1530/// The points of `chart("scatter" | "bubble", …)`: `data` (a series with
1531/// `points`, a list of them, or a list of points), `series=[…]`,
1532/// `points=[…]`.
1533fn read_points(
1534    data: Option<Value>,
1535    kwargs: &Kwargs,
1536    title: &str,
1537) -> std::result::Result<Vec<Cloud>, Error> {
1538    let series: Option<Value> = kwargs.get("series")?;
1539    let points: Option<Value> = kwargs.get("points")?;
1540    let name: Option<String> = kwargs.get("name")?;
1541    let fallback = name.clone().unwrap_or_else(|| title.to_owned());
1542    let mut clouds = Vec::new();
1543    let list_of_series = |value: &Value, clouds: &mut Vec<Cloud>| {
1544        if let Ok(items) = value.try_iter() {
1545            for (i, item) in items.enumerate() {
1546                clouds.push(cloud_of(&item, &format!("{} {}", fallback, i + 1)));
1547            }
1548        }
1549    };
1550    match data {
1551        Some(data) if data.kind() == ValueKind::Map => match attr(&data, "series") {
1552            Some(series) => list_of_series(&series, &mut clouds),
1553            None => clouds.push(cloud_of(&data, &fallback)),
1554        },
1555        Some(data) if data.kind() == ValueKind::Seq => {
1556            // A list of series has maps with `points`; a list of points has
1557            // pairs, triples or maps with `x`.
1558            let first = data.try_iter().ok().and_then(|mut items| items.next());
1559            if first.is_some_and(|v| v.kind() == ValueKind::Map && attr(&v, "points").is_some()) {
1560                list_of_series(&data, &mut clouds);
1561            } else {
1562                clouds.push(cloud_of(&data, &fallback));
1563            }
1564        }
1565        _ => {}
1566    }
1567    if let Some(series) = series {
1568        list_of_series(&series, &mut clouds);
1569    }
1570    if let Some(points) = points {
1571        clouds.push(Cloud {
1572            name: fallback.clone(),
1573            points: points_of(&points),
1574        });
1575    }
1576    if let Some(name) = name
1577        && clouds.len() == 1
1578    {
1579        clouds[0].name = name;
1580    }
1581    Ok(clouds)
1582}
1583
1584/// As many decimals as the values need, up to 2.
1585fn auto_decimals(values: impl Iterator<Item = f64>) -> Option<u32> {
1586    let mut decimals = 0;
1587    for value in values {
1588        let fract = (value.abs() * 100.0).round() as i64 % 100;
1589        if fract % 10 != 0 {
1590            return Some(2);
1591        }
1592        if fract != 0 {
1593            decimals = 1;
1594        }
1595    }
1596    Some(decimals)
1597}
1598
1599/// How a scatter or bubble chart's values read.
1600struct Formats {
1601    x: Formatter,
1602    y: Formatter,
1603    size: Formatter,
1604    size_title: String,
1605}
1606
1607/// The tooltips' data for points.
1608#[derive(Serialize)]
1609struct HoverPoints<'a> {
1610    kind: &'a str,
1611    points: Vec<HoverPoint>,
1612}
1613
1614#[derive(Serialize)]
1615struct HoverPoint {
1616    /// The point's label, or "".
1617    title: String,
1618    /// Its series' name with more than one series, or "".
1619    name: String,
1620    slot: String,
1621    /// `[axis title, value]`.
1622    rows: Vec<(String, String)>,
1623    /// Where it is, in percent of the plot.
1624    left: f64,
1625    bottom: f64,
1626}
1627
1628/// The value range of points, padded for bubbles so they stay inside, with
1629/// 0 in when the data starts near it.
1630fn point_scale(values: impl Iterator<Item = f64>, pad: bool) -> (f64, f64, f64) {
1631    let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
1632    for v in values {
1633        lo = lo.min(v);
1634        hi = hi.max(v);
1635    }
1636    if !lo.is_finite() {
1637        return scale(0.0, 0.0);
1638    }
1639    if pad {
1640        // Room around the data, but not across 0 (no "-20 units").
1641        let room = ((hi - lo) * 0.08).max(hi.abs().max(lo.abs()) * 0.02);
1642        lo = if lo >= 0.0 {
1643            (lo - room).max(0.0)
1644        } else {
1645            lo - room
1646        };
1647        hi = if hi <= 0.0 {
1648            (hi + room).min(0.0)
1649        } else {
1650            hi + room
1651        };
1652    }
1653    if lo >= 0.0 && lo <= hi * 0.25 {
1654        lo = 0.0;
1655    }
1656    if hi <= 0.0 && hi >= lo * 0.25 {
1657        hi = 0.0;
1658    }
1659    nice(lo, hi)
1660}
1661
1662fn ticks(lo: f64, hi: f64, step: f64) -> Vec<f64> {
1663    let mut ticks = Vec::new();
1664    let mut tick = lo;
1665    while tick <= hi + step / 2.0 && ticks.len() < 12 {
1666        ticks.push(tick);
1667        tick += step;
1668    }
1669    ticks
1670}
1671
1672fn render_points(clouds: &[Cloud], options: &Options, formats: &Formats) -> String {
1673    let bubble = options.kind == "bubble";
1674    let all = || clouds.iter().flat_map(|c| c.points.iter());
1675    let (x_lo, x_hi, x_step) = point_scale(all().map(|p| p.x), bubble);
1676    let (y_lo, y_hi, y_step) = point_scale(all().map(|p| p.y), bubble);
1677    let px = |v: f64| (v - x_lo) / (x_hi - x_lo) * 100.0;
1678    let py = |v: f64| (v - y_lo) / (y_hi - y_lo) * 100.0;
1679    // Bubbles: the area follows the size, up to 40 px across, 6 px at least.
1680    let biggest = all()
1681        .filter_map(|p| p.size)
1682        .fold(0.0f64, |a, b| a.max(b.abs()));
1683    let diameter = |size: Option<f64>| {
1684        let share = match (size, biggest > 0.0) {
1685            (Some(size), true) => (size.abs() / biggest).sqrt(),
1686            _ => 0.0,
1687        };
1688        (6.0 + share * 34.0).round()
1689    };
1690    let x_name = options.x_title.clone().unwrap_or_else(|| "x".into());
1691    let y_name = options.y_title.clone().unwrap_or_else(|| "y".into());
1692    let several = clouds.len() > 1;
1693
1694    // Every point, left to right, as the arrow keys visit them.
1695    let mut order: Vec<(usize, &Point)> = clouds
1696        .iter()
1697        .enumerate()
1698        .flat_map(|(s, c)| c.points.iter().map(move |p| (s, p)))
1699        .collect();
1700    order.sort_by(|a, b| a.1.x.total_cmp(&b.1.x).then(a.1.y.total_cmp(&b.1.y)));
1701
1702    let rows_of = |point: &Point| {
1703        let mut rows = vec![
1704            (x_name.clone(), formats.x.full(point.x)),
1705            (y_name.clone(), formats.y.full(point.y)),
1706        ];
1707        if bubble && let Some(size) = point.size {
1708            rows.push((formats.size_title.clone(), formats.size.full(size)));
1709        }
1710        rows
1711    };
1712    let hover = HoverPoints {
1713        kind: &options.kind,
1714        points: order
1715            .iter()
1716            .map(|(s, point)| HoverPoint {
1717                title: point.label.clone().unwrap_or_default(),
1718                name: if several {
1719                    clouds[*s].name.clone()
1720                } else {
1721                    String::new()
1722                },
1723                slot: slot(*s),
1724                rows: rows_of(point),
1725                left: (px(point.x) * 100.0).round() / 100.0,
1726                bottom: (py(point.y) * 100.0).round() / 100.0,
1727            })
1728            .collect(),
1729    };
1730    let json = serde_json::to_string(&hover).unwrap_or_default();
1731    let mut out = String::new();
1732    let _ = write!(
1733        out,
1734        r#"<figure class="rx-chart rx-chart--{kind} rx-chart--points"{id} data-rx-chart="{json}">"#,
1735        kind = escape(&options.kind),
1736        id = options
1737            .id
1738            .as_deref()
1739            .map(|id| format!(r#" id="{}""#, escape(id)))
1740            .unwrap_or_default(),
1741        json = escape(&json),
1742    );
1743    if !options.title.is_empty() {
1744        let _ = write!(
1745            out,
1746            r#"<figcaption class="rx-visually-hidden">{}</figcaption>"#,
1747            escape(&options.title)
1748        );
1749    }
1750    if options.legend && several {
1751        let names: Vec<(usize, &str)> = clouds
1752            .iter()
1753            .enumerate()
1754            .map(|(i, c)| (i, c.name.as_str()))
1755            .collect();
1756        legend(&mut out, &names, "dot");
1757    }
1758    y_title(&mut out, options);
1759    let _ = write!(
1760        out,
1761        r#"<div class="rx-chart__frame" style="--rx-chart-h: {}px"><div class="rx-chart__y" aria-hidden="true">"#,
1762        options.height
1763    );
1764    let y_ticks = ticks(y_lo, y_hi, y_step);
1765    for t in &y_ticks {
1766        let _ = write!(
1767            out,
1768            r#"<span style="bottom: {}%">{}</span>"#,
1769            pct(py(*t)),
1770            escape(&formats.y.tick(*t))
1771        );
1772    }
1773    let label = if options.title.is_empty() {
1774        clouds
1775            .iter()
1776            .map(|c| c.name.as_str())
1777            .filter(|n| !n.is_empty())
1778            .collect::<Vec<_>>()
1779            .join(", ")
1780    } else {
1781        options.title.clone()
1782    };
1783    let label = match (&options.x_title, &options.y_title) {
1784        (Some(x), Some(y)) => {
1785            let axes = options.axes.replace(":y", y).replace(":x", x);
1786            if label.is_empty() {
1787                axes
1788            } else {
1789                format!("{label}: {axes}")
1790            }
1791        }
1792        _ => label,
1793    };
1794    let _ = write!(
1795        out,
1796        r#"</div><div class="rx-chart__plot" tabindex="0" role="img" aria-label="{}"><div class="rx-chart__grid" aria-hidden="true">"#,
1797        escape(&label)
1798    );
1799    for t in &y_ticks {
1800        let base = if t.abs() < y_step / 1e6 {
1801            " rx-chart__rule--base"
1802        } else {
1803            ""
1804        };
1805        let _ = write!(
1806            out,
1807            r#"<span class="rx-chart__rule{base}" style="bottom: {}%"></span>"#,
1808            pct(py(*t))
1809        );
1810    }
1811    let x_ticks = ticks(x_lo, x_hi, x_step);
1812    for t in &x_ticks {
1813        let base = if t.abs() < x_step / 1e6 {
1814            " rx-chart__rule--base"
1815        } else {
1816            ""
1817        };
1818        let _ = write!(
1819            out,
1820            r#"<span class="rx-chart__rule rx-chart__rule--x{base}" style="left: {}%"></span>"#,
1821            pct(px(*t))
1822        );
1823    }
1824    out.push_str(r#"</div><div class="rx-chart__points" aria-hidden="true">"#);
1825    // Big bubbles first, so small ones stay on top and reachable.
1826    let mut drawn: Vec<(usize, &(usize, &Point))> = order.iter().enumerate().collect();
1827    if bubble {
1828        drawn.sort_by(|a, b| {
1829            let size = |p: &Point| p.size.map(f64::abs).unwrap_or(0.0);
1830            size(b.1.1).total_cmp(&size(a.1.1))
1831        });
1832    }
1833    for (index, (s, point)) in drawn {
1834        let size = if bubble {
1835            format!("; --rx-point: {}px", diameter(point.size))
1836        } else {
1837            String::new()
1838        };
1839        let _ = write!(
1840            out,
1841            r#"<span class="rx-chart__point {}" data-index="{index}" style="left: {}%; bottom: {}%{size}"></span>"#,
1842            slot(*s),
1843            pct(px(point.x)),
1844            pct(py(point.y)),
1845        );
1846    }
1847    out.push_str(r#"</div><div class="rx-chart__tip" hidden></div></div>"#);
1848    out.push_str(r#"<div class="rx-chart__x" aria-hidden="true">"#);
1849    for (shown, t) in x_ticks.iter().enumerate() {
1850        let narrow = if shown % 2 == 1 && shown + 1 < x_ticks.len() {
1851            " rx-chart__x--odd"
1852        } else {
1853            ""
1854        };
1855        let _ = write!(
1856            out,
1857            r#"<span class="{narrow}" style="left: {}%">{}</span>"#,
1858            pct(px(*t)),
1859            escape(&formats.x.tick(*t))
1860        );
1861    }
1862    out.push_str("</div></div>");
1863    x_title(&mut out, options);
1864
1865    let mut heads = Vec::new();
1866    if several {
1867        heads.push(options.series_head.clone());
1868    }
1869    heads.push(x_name.clone());
1870    heads.push(y_name.clone());
1871    if bubble {
1872        heads.push(formats.size_title.clone());
1873    }
1874    let rows: Vec<(String, Vec<String>)> = order
1875        .iter()
1876        .enumerate()
1877        .map(|(i, (s, point))| {
1878            let mut cells = Vec::new();
1879            if several {
1880                cells.push(clouds[*s].name.clone());
1881            }
1882            cells.push(formats.x.full(point.x));
1883            cells.push(formats.y.full(point.y));
1884            if bubble {
1885                cells.push(
1886                    point
1887                        .size
1888                        .map(|v| formats.size.full(v))
1889                        .unwrap_or_else(|| "—".into()),
1890                );
1891            }
1892            (
1893                point.label.clone().unwrap_or_else(|| (i + 1).to_string()),
1894                cells,
1895            )
1896        })
1897        .collect();
1898    table(&mut out, options, &heads, &rows);
1899    out.push_str("</figure>");
1900    out
1901}
1902
1903#[cfg(test)]
1904mod tests {
1905    use super::*;
1906
1907    #[test]
1908    fn periods_parse_and_step_back() {
1909        assert_eq!(Period::parse("30d"), Some(Period::days(30)));
1910        assert_eq!(Period::parse("12M").map(|p| p.key()), Some("12m".into()));
1911        assert_eq!(Period::parse("ytd"), Some(Period::year_to_date()));
1912        for bad in [
1913            "",
1914            "0d",
1915            "400d",
1916            "40m",
1917            "105w",
1918            "d",
1919            "-3d",
1920            "2026-09-30..2026-09-01",
1921            "2020-01-01..2026-01-01",
1922            "2026-9-1..2026-09-30",
1923        ] {
1924            assert_eq!(Period::parse(bad), None, "{bad}");
1925        }
1926        assert_eq!(Period::days(30).bucket(), Bucket::Day);
1927        assert_eq!(Period::days(365).bucket(), Bucket::Month);
1928        let zone = Zone::UTC;
1929        let (start, end) = Period::days(7).days_in(zone);
1930        assert_eq!((end - start).num_days(), 7);
1931        let (before_start, before_end) = Period::days(7).previous().days_in(zone);
1932        assert_eq!(
1933            (before_end, (before_end - before_start).num_days()),
1934            (start, 7)
1935        );
1936        assert_eq!(Period::days(7).labels(zone).len(), 7);
1937        assert_eq!(Period::months(12).labels(zone).len(), 12);
1938        let (m0, m1) = Period::months(3).previous().days_in(zone);
1939        assert_eq!((m0.day(), m1.day()), (1, 1));
1940        assert_eq!(
1941            add_months(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), -1),
1942            NaiveDate::from_ymd_opt(2025, 12, 1).unwrap()
1943        );
1944    }
1945
1946    fn day(text: &str) -> NaiveDate {
1947        NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
1948    }
1949
1950    #[test]
1951    fn custom_ranges_parse_and_step_back() {
1952        let range = Period::between(day("2026-09-01"), day("2026-09-30")).unwrap();
1953        assert!(range.is_custom() && !Period::days(30).is_custom());
1954        assert_eq!(range.key(), "2026-09-01..2026-09-30");
1955        assert_eq!(Period::parse(&range.key()), Some(range));
1956        assert_eq!(
1957            range.days_in(Zone::UTC),
1958            (day("2026-09-01"), day("2026-10-01"))
1959        );
1960        // The period before: as many days, just before.
1961        assert_eq!(
1962            range.previous().days_in(Zone::UTC),
1963            (day("2026-08-02"), day("2026-09-01"))
1964        );
1965        // One day is fine; backwards or over three years is not.
1966        assert!(Period::between(day("2026-09-01"), day("2026-09-01")).is_some());
1967        assert!(Period::between(day("2026-09-02"), day("2026-09-01")).is_none());
1968        assert!(Period::between(day("2024-01-01"), day("2026-12-31")).is_some());
1969        assert!(Period::between(day("2024-01-01"), day("2027-01-01")).is_none());
1970        // From a query string.
1971        let query = "q=x&period=custom&from=2026-09-01&to=2026-09-30";
1972        assert_eq!(Period::from_query(query), Some(range));
1973        assert_eq!(
1974            Period::from_query("period=12w").map(|p| p.key()),
1975            Some("12w".into())
1976        );
1977        for bad in [
1978            "period=custom",
1979            "period=custom&from=2026-09-01",
1980            "period=custom&from=2026-09-31&to=2026-10-01",
1981            "period=custom&from=2026-10-01&to=2026-09-01",
1982            "from=2026-09-01&to=2026-09-30",
1983        ] {
1984            assert_eq!(Period::from_query(bad), None, "{bad}");
1985        }
1986        // Per day up to 92 days, per week up to 26 weeks, then per month.
1987        let span = |days: i64| {
1988            let from = day("2026-01-01");
1989            Period::between(from, from + Duration::days(days - 1))
1990                .unwrap()
1991                .bucket()
1992        };
1993        assert_eq!(
1994            (span(92), span(93), span(182), span(183)),
1995            (Bucket::Day, Bucket::Week, Bucket::Week, Bucket::Month)
1996        );
1997    }
1998
1999    #[test]
2000    fn weeks_start_on_monday() {
2001        assert_eq!(monday_of(day("2026-10-04")), day("2026-09-28")); // a Sunday
2002        assert_eq!(monday_of(day("2026-09-28")), day("2026-09-28"));
2003        let weeks = Period::weeks(4);
2004        assert_eq!(weeks.bucket(), Bucket::Week);
2005        let (start, end) = weeks.days_in(Zone::UTC);
2006        assert_eq!(start.weekday(), chrono::Weekday::Mon);
2007        assert!((end - start).num_days() > 21 && (end - start).num_days() <= 28);
2008        let labels = weeks.labels(Zone::UTC);
2009        assert_eq!(labels.len(), 4);
2010        assert_eq!(labels[0], start.format("%Y-%m-%d").to_string());
2011        // The four weeks before end where these start, at the same weekday.
2012        let (before_start, before_end) = weeks.previous().days_in(Zone::UTC);
2013        assert_eq!(
2014            (before_start, end - before_end),
2015            (start - Duration::days(28), Duration::days(28))
2016        );
2017        // A custom range per week: the first label is the Monday before.
2018        let range = Period::between(day("2026-09-02"), day("2026-09-30"))
2019            .unwrap()
2020            .per(Bucket::Week);
2021        assert_eq!(
2022            range.labels(Zone::UTC),
2023            [
2024                "2026-08-31",
2025                "2026-09-07",
2026                "2026-09-14",
2027                "2026-09-21",
2028                "2026-09-28"
2029            ]
2030        );
2031        assert_eq!(range.previous().bucket(), Bucket::Week);
2032        assert_eq!(Period::days(365).per(Bucket::Day).bucket(), Bucket::Day);
2033    }
2034
2035    #[test]
2036    fn series_add_up_and_compare() {
2037        let now = Series::new(vec!["a".into(), "b".into()], vec![30.0, 30.0]);
2038        let before = Series::new(vec!["a".into(), "b".into()], vec![20.0, 20.0]);
2039        assert_eq!(now.change_from(&before), Some(50.0));
2040        assert_eq!(now.change_from(&Series::default()), None);
2041        assert_eq!(
2042            serde_json::to_value(now.named("Sales")).unwrap()["name"],
2043            "Sales"
2044        );
2045    }
2046
2047    #[test]
2048    fn scales_are_clean() {
2049        assert_eq!(scale(0.0, 87.0), (0.0, 100.0, 25.0));
2050        assert_eq!(scale(0.0, 1234.0), (0.0, 1500.0, 500.0));
2051        assert_eq!(scale(-30.0, 70.0), (-50.0, 75.0, 25.0));
2052        assert_eq!(scale(0.0, 0.0), (0.0, 1.0, 0.25));
2053        // Scatter axes needn't start at 0, unless the data comes close.
2054        assert_eq!(
2055            point_scale([52.0, 87.0].into_iter(), false),
2056            (50.0, 90.0, 10.0)
2057        );
2058        assert_eq!(
2059            point_scale([3.0, 87.0].into_iter(), false),
2060            (0.0, 100.0, 25.0)
2061        );
2062        assert_eq!(point_scale([-5.0, -80.0].into_iter(), false).1, 0.0);
2063        assert_eq!(point_scale(std::iter::empty(), true), (0.0, 1.0, 0.25));
2064        assert_eq!(
2065            auto_decimals([1.0, 2.5].into_iter()),
2066            Some(1),
2067            "as many decimals as needed"
2068        );
2069        assert_eq!(auto_decimals([1.25].into_iter()), Some(2));
2070        assert_eq!(auto_decimals([3.0].into_iter()), Some(0));
2071    }
2072
2073    #[test]
2074    fn labels_and_ticks_read_well() {
2075        assert_eq!(display_label("2026-10-02", None), "Oct 2");
2076        assert_eq!(display_label("2026-08", None), "Aug 2026");
2077        assert_eq!(display_label("2026-10-02", Some("%d/%m")), "02/10");
2078        assert_eq!(display_label("Coffee", None), "Coffee");
2079        let f = |locale: &str| Formatter {
2080            format: "number".into(),
2081            decimals: None,
2082            currency: "IDR".into(),
2083            locale: locale.into(),
2084        };
2085        assert_eq!(f("en").tick(12_500.0), "12.5K");
2086        assert_eq!(f("es").tick(2_500_000.0), "2,5M");
2087        assert_eq!(f("en").tick(750.0), "750");
2088        assert_eq!(f("en").tick(2_000_000_000.0), "2B");
2089    }
2090
2091    // #255: "so far" periods on awkward days, and charts with awkward data.
2092
2093    /// Runs `f` with the clock on `date` at noon UTC.
2094    fn on<T>(date: &str, f: impl FnOnce() -> T) -> T {
2095        let at = day(date)
2096            .and_hms_opt(12, 0, 0)
2097            .unwrap()
2098            .and_utc()
2099            .timestamp();
2100        let now = chrono::Utc::now().timestamp();
2101        crate::clock::with_offset_sync(at - now, f)
2102    }
2103
2104    #[test]
2105    fn month_and_year_to_date_compare_with_as_many_days_before() {
2106        let zone = Zone::UTC;
2107        let mtd = Period::month_to_date();
2108        assert_eq!(mtd.key(), "mtd");
2109        assert_eq!(mtd.bucket(), Bucket::Day);
2110        // The 31st: the month so far is the whole month; the month before
2111        // (February) is cut at its own end.
2112        on("2026-03-31", || {
2113            assert_eq!(mtd.days_in(zone), (day("2026-03-01"), day("2026-04-01")));
2114            assert_eq!(
2115                mtd.previous().days_in(zone),
2116                (day("2026-02-01"), day("2026-03-01"))
2117            );
2118        });
2119        // 29 February: the year so far is 60 days; the year before has
2120        // as many days, from 1 January.
2121        on("2024-02-29", || {
2122            let ytd = Period::year_to_date();
2123            assert_eq!(ytd.days_in(zone), (day("2024-01-01"), day("2024-03-01")));
2124            assert_eq!(
2125                ytd.previous().days_in(zone),
2126                (day("2023-01-01"), day("2023-03-02"))
2127            );
2128        });
2129        // The last day of a leap year: the year before is cut at its end.
2130        on("2024-12-31", || {
2131            assert_eq!(
2132                Period::year_to_date().previous().days_in(zone),
2133                (day("2023-01-01"), day("2024-01-01"))
2134            );
2135        });
2136    }
2137
2138    fn render(source: &str) -> String {
2139        let mut env = minijinja::Environment::new();
2140        env.add_function("chart", chart("USD".into()));
2141        env.render_str(source, minijinja::context! {})
2142            .unwrap_or_else(|err| panic!("{err:#}"))
2143    }
2144
2145    #[test]
2146    fn awkward_data_still_draws() {
2147        // A gap in a line, values all below zero, all the same.
2148        let gap =
2149            render(r#"{{ chart("line", labels=["a", "b", "c", "d"], values=[1, none, 3, 4]) }}"#);
2150        assert!(gap.contains("<figure class=\"rx-chart"), "{gap}");
2151        for source in [
2152            r#"{{ chart("bar", [-5, -2, -9]) }}"#,
2153            r#"{{ chart("line", [3, 3, 3]) }}"#,
2154            r#"{{ chart("line", [0, 0]) }}"#,
2155        ] {
2156            assert!(
2157                render(source).contains("<figure class=\"rx-chart"),
2158                "{source}"
2159            );
2160        }
2161        // More series than colours: the rest share one.
2162        let many = render(
2163            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]}]) }}"#,
2164        );
2165        assert!(many.contains("rx-series-other"), "{many}");
2166    }
2167
2168    #[test]
2169    fn pies_skip_empty_slices_and_draw_a_whole_one_as_a_circle() {
2170        let pie = render(r#"{{ chart("pie", labels=["none", "all"], values=[0, 5]) }}"#);
2171        assert!(
2172            pie.contains(r#"<circle class="rx-chart__slice rx-series-2""#),
2173            "{pie}"
2174        );
2175        assert!(!pie.contains("rx-chart__hole"));
2176        assert!(pie.contains(r#"aria-label="none, all""#), "{pie}");
2177        let ring = render(r#"{{ chart("doughnut", labels=["all"], values=[5], legend=false) }}"#);
2178        assert!(ring.contains("rx-chart__hole"), "{ring}");
2179        assert!(!ring.contains("rx-chart__legend"));
2180        let empty = render(r#"{{ chart("pie", labels=["a"], values=[0]) }}"#);
2181        assert!(!empty.contains("rx-chart__slice"), "{empty}");
2182    }
2183
2184    /// Data given as a list of series, as one series object, as an object
2185    /// holding labels and series, and values that aren't numbers.
2186    #[test]
2187    fn data_comes_in_every_shape() {
2188        let list = render(
2189            r#"{{ chart("line", [{"name": "North", "values": [1, 2]}, {"values": [3, 4]}], labels=["x", "y"], name="Sales") }}"#,
2190        );
2191        assert!(list.contains(">North<"), "{list}");
2192        assert!(
2193            list.contains(">Sales 2<"),
2194            "a series without a name: {list}"
2195        );
2196        let one = render(r#"{{ chart("bar", {"name": "Visits", "values": [1, 2]}) }}"#);
2197        assert!(one.contains(">Visits<"), "{one}");
2198        let nested = render(
2199            r#"{{ chart("line", {"labels": ["Mon", "Tue"], "series": [{"name": "Cups", "values": [5, 6]}]}) }}"#,
2200        );
2201        assert!(
2202            nested.contains(">Mon<") && nested.contains(">Cups<"),
2203            "{nested}"
2204        );
2205        // Labels that aren't text are written as text; a number in text is a
2206        // number, other values are gaps.
2207        let odd = render(r#"{{ chart("line", labels=[1, true], values=["3", "x", 2.5]) }}"#);
2208        assert!(odd.contains(">True<"), "{odd}");
2209        assert!(
2210            odd.contains(">3.0</td>") && odd.contains(">—</td>"),
2211            "{odd}"
2212        );
2213        // Three values but two labels: the third gets its number.
2214        assert!(odd.contains(r#"<th scope="row">3</th>"#), "{odd}");
2215    }
2216
2217    #[test]
2218    fn axes_for_negative_and_flat_values() {
2219        assert_eq!(scale(-9.0, -2.0), (-10.0, 0.0, 2.5));
2220        let (lo, hi, step) = nice(3.0, 3.0);
2221        assert!(lo < 3.0 && hi > 3.0 && step > 0.0, "{lo} {hi} {step}");
2222        let (lo, hi, _) = nice(-4.0, -4.0);
2223        assert!(lo < -4.0 && hi > -4.0, "{lo} {hi}");
2224        let flat = render(r#"{{ chart("bar", [-4, -4]) }}"#);
2225        assert!(flat.contains(">-4<") || flat.contains(">−4<"), "{flat}");
2226    }
2227
2228    #[test]
2229    fn lines_break_at_gaps_and_keep_their_last_label() {
2230        let gap = render(r#"{{ chart("line", [1, none, 3, 4]) }}"#);
2231        assert_eq!(gap.matches(r#"class="rx-chart__line "#).count(), 2, "{gap}");
2232        // Twenty points: every third label, the last always, the one just
2233        // before it giving way.
2234        let many = render(r#"{{ chart("line", values=range(20)|list) }}"#);
2235        let x = many.split(r#"<div class="rx-chart__x""#).nth(1).unwrap();
2236        let x = x.split("</div>").next().unwrap();
2237        assert!(x.contains(">20</span>") && !x.contains(">19</span>"), "{x}");
2238        assert!(x.contains(">1</span>") && x.contains(">4</span>"), "{x}");
2239        // One point sits in the middle.
2240        let one = render(r#"{{ chart("line", [5]) }}"#);
2241        assert!(one.contains(r#"style="left: 50%">1</span>"#), "{one}");
2242    }
2243
2244    /// Line, bar and pie values with fractions keep them in the tooltip
2245    /// data and the table (they were rounded to whole numbers); whole
2246    /// numbers stay whole, and `decimals` still decides.
2247    #[test]
2248    fn fractions_keep_their_decimals() {
2249        let bars = render(r#"{{ chart("bar", [1.5, 2.25, 3]) }}"#);
2250        let table = bars.split("<tbody>").nth(1).unwrap();
2251        assert!(
2252            table.contains(">1.50<") && table.contains(">2.25<") && table.contains(">3.00<"),
2253            "{table}"
2254        );
2255        let pie = render(r#"{{ chart("pie", labels=["a", "b"], values=[4.5, 5.5]) }}"#);
2256        assert!(pie.contains(">4.5<"), "{pie}");
2257        let whole = render(r#"{{ chart("line", [1, 2]) }}"#);
2258        assert!(
2259            whole.split("<tbody>").nth(1).unwrap().contains(">2<"),
2260            "{whole}"
2261        );
2262        let chosen = render(r#"{{ chart("bar", [1.5], decimals=0) }}"#);
2263        assert!(
2264            chosen.split("<tbody>").nth(1).unwrap().contains(">2<"),
2265            "{chosen}"
2266        );
2267    }
2268
2269    /// Scatter and bubble data in every shape `read_points` takes, and a
2270    /// line chart's `name` for its one series.
2271    #[test]
2272    fn points_come_in_every_shape() {
2273        let nested = render(
2274            r#"{{ chart("scatter", {"series": [{"name": "North", "points": [[1, 2]]}, {"name": "South", "points": [[3, 4]]}]}) }}"#,
2275        );
2276        assert!(
2277            nested.contains(">North<") && nested.contains(">South<"),
2278            "{nested}"
2279        );
2280        let listed = render(r#"{{ chart("scatter", [{"name": "East", "points": [[1, 2]]}]) }}"#);
2281        // One series: drawn, without a legend to name it.
2282        assert!(listed.contains(r#"data-index="0""#), "{listed}");
2283        let pairs = render(r#"{{ chart("scatter", [[1, 2], [3, 4]], name="Pairs") }}"#);
2284        assert!(pairs.contains(r#"data-index="1""#), "two points: {pairs}");
2285        let one =
2286            render(r#"{{ chart("bubble", {"name": "Sizes", "points": [[1, 2, 3], [2, 3, 9]]}) }}"#);
2287        assert!(one.contains(r#"data-index="1""#), "{one}");
2288        let kwarg = render(
2289            r#"{{ chart("scatter", series=[{"name": "West", "points": [[5, 5]]}, {"name": "Far", "points": [[6, 6]]}]) }}"#,
2290        );
2291        assert!(
2292            kwarg.contains(">West<") && kwarg.contains(">Far<"),
2293            "{kwarg}"
2294        );
2295        let named = render(r#"{{ chart("line", [1, 2], name="Only") }}"#);
2296        assert!(named.contains(">Only<"), "{named}");
2297    }
2298
2299    #[test]
2300    fn scatter_marks_the_zero_line() {
2301        let html = render(r#"{{ chart("scatter", points=[[-5, -10], [8, 20]]) }}"#);
2302        assert!(html.contains("rx-chart__rule--base"), "{html}");
2303    }
2304
2305    #[test]
2306    fn scatter_with_negative_values_only() {
2307        let html = render(r#"{{ chart("scatter", points=[[-5, -10], [-1, -3]]) }}"#);
2308        // The axes reach below the lowest point and up to zero.
2309        assert!(
2310            html.contains(r#"<span style="bottom: 0%">-10</span>"#),
2311            "{html}"
2312        );
2313        assert!(html.contains(r#"style="left: 100%">0</span>"#), "{html}");
2314    }
2315}