Time and IDs

ISO durations

ISO 8601 durations such as P1DT2H and PT30M read, written, turned into seconds and added to dates and times, months moving the calendar.

Version 1.0.0 · 4 functions · 1 crate

#time#dates#api#calendars

Functions

What use iso_durations.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use duration_parse, duration_seconds from iso_durations (Quillo 0.6).

duration_parse(text: str) → anyor an error

The parts of an ISO 8601 duration: "P1Y2M10DT2H30M" is { negative, years: 1, months: 2, weeks: 0, days: 10, hours: 2, minutes: 30, seconds: 0 }. Fractions are allowed ("PT1.5H"), and a leading "-" makes it negative. An error for anything else.

duration_seconds(text: str) → floator an error

A duration in seconds, days counted as 24 hours: "PT1H30M" is 5400, "P1DT2H" is 93600. An error when it has years or months, which are no fixed number of seconds: add those to a date with duration_add.

duration_format(seconds: float) → stror an error

seconds written as an ISO 8601 duration, in days, hours, minutes and seconds: 5400 is "PT1H30M", 93600 is "P1DT2H", 0 is "PT0S".

duration_add(when: str, text: str) → stror an error

when plus the duration text. when is a date ("2026-01-31") or a date and time with or without an offset ("2026-03-29T01:30:00+01:00"), and the answer has the same form. Years and months move the calendar, keeping the day where it can: 2026-01-31 plus P1M is 2026-02-28. Hours of a date alone are an error.

Get it

$ quillo install iso_durations               # iso_durations.qwer into this folder$ quillo install iso_durations --example     # and its example, iso_durations.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/iso_durations.qwer$ curl -O https://quillolang.org/plugins/files/iso_durations.qwe

Then, in your program, all of it or only what you call:

main.qwe
use iso_durations.qweruse duration_parse, duration_seconds from iso_durations      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
chrono = { version = "0.4", default-features = false, features = ["std"] }

Example

iso_durations.qwe
// Video lengths from an API ("PT1H2M30S") shown as minutes, and subscription renewals added to dates.////   quillo run iso_durations.qwe      then open http://localhost:8080/videosuse "iso_durations.qwer"let videos = [{ title: "Intro", length: "PT4M13S" }, { title: "Deep dive", length: "PT1H2M30S" }, { title: "Live stream", length: "P1DT2H" }]route GET /videos = videos | map((v) => { title: v.title, seconds: duration_seconds(v.length)?, back: duration_format(duration_seconds(v.length)?)? })route GET /renew ? { from: str = "2026-01-31", every: str = "P1M" } = { next: duration_add(from, every)?, parts: duration_parse(every)? }route GET /meeting = { ends: duration_add("2026-11-20T09:30:00+01:00", "PT1H45M")?, year_later: duration_add("2028-02-29", "P1Y")? }
$ quillo run iso_durations.qwe$ curl 'localhost:8080/videos'$ curl 'localhost:8080/renew'$ curl 'localhost:8080/meeting'

Source

iso_durations.qwer
//! ISO 8601 durations ("P1DT2H", "PT30M", "P1Y2M") read, written and added to dates and times, as calendars, YouTube, schema.org and many APIs write them.//! version: 1.0.0//!//! [dependencies]//! chrono = { version = "0.4", default-features = false, features = ["std"] }use chrono::{DateTime, Duration, FixedOffset, Months, NaiveDate, NaiveDateTime};use serde_json::{json, Value};#[derive(Default)]struct Dur {    neg: bool,    years: f64,    months: f64,    weeks: f64,    days: f64,    hours: f64,    minutes: f64,    seconds: f64,}fn parse(text: &str) -> Result<Dur, String> {    let t = text.trim().to_uppercase();    let bad = || format!("{:?} is not an ISO 8601 duration such as P1DT2H or PT30M", text);    if t.len() > 100 {        return Err(bad());    }    let (neg, t) = match t.strip_prefix('-') { Some(r) => (true, r.to_string()), None => (false, t.trim_start_matches('+').to_string()) };    let body = t.strip_prefix('P').ok_or_else(bad)?;    let mut d = Dur { neg, ..Default::default() };    let mut in_time = false;    let mut num = String::new();    let mut any = false;    let mut last_unit = 0;    for c in body.chars() {        match c {            '0'..='9' | '.' | ',' => num.push(if c == ',' { '.' } else { c }),            'T' if !in_time && num.is_empty() => in_time = true,            _ => {                let v: f64 = num.parse().map_err(|_| bad())?;                num.clear();                // Units come in order: Y M W D, then T H M S.                let order = match (in_time, c) { (false, 'Y') => 1, (false, 'M') => 2, (false, 'W') => 3, (false, 'D') => 4, (true, 'H') => 5, (true, 'M') => 6, (true, 'S') => 7, _ => return Err(bad()) };                if order <= last_unit {                    return Err(bad());                }                last_unit = order;                any = true;                match order { 1 => d.years = v, 2 => d.months = v, 3 => d.weeks = v, 4 => d.days = v, 5 => d.hours = v, 6 => d.minutes = v, _ => d.seconds = v }            }        }    }    if !num.is_empty() || !any || (in_time && last_unit < 5) {        return Err(bad());    }    Ok(d)}fn fixed_seconds(d: &Dur) -> f64 {    let s = d.weeks * 604800.0 + d.days * 86400.0 + d.hours * 3600.0 + d.minutes * 60.0 + d.seconds;    if d.neg { -s } else { s }}/// The parts of an ISO 8601 duration: "P1Y2M10DT2H30M" is `{ negative, years: 1, months: 2, weeks: 0, days: 10, hours: 2, minutes: 30, seconds: 0 }`. Fractions are allowed ("PT1.5H"), and a leading "-" makes it negative. An error for anything else.pub fn duration_parse(text: &str) -> Result<Value, String> {    let d = parse(text)?;    // Whole numbers come back as ints.    let n = |v: f64| if v.fract() == 0.0 && v.abs() < 1e15 { json!(v as i64) } else { json!(v) };    Ok(json!({ "negative": d.neg, "years": n(d.years), "months": n(d.months), "weeks": n(d.weeks), "days": n(d.days), "hours": n(d.hours), "minutes": n(d.minutes), "seconds": n(d.seconds) }))}/// A duration in seconds, days counted as 24 hours: "PT1H30M" is 5400, "P1DT2H" is 93600. An error when it has years or months, which are no fixed number of seconds: add those to a date with `duration_add`.pub fn duration_seconds(text: &str) -> Result<f64, String> {    let d = parse(text)?;    if d.years != 0.0 || d.months != 0.0 {        return Err("years and months have no fixed length: use duration_add".into());    }    Ok(fixed_seconds(&d))}/// `seconds` written as an ISO 8601 duration, in days, hours, minutes and seconds: 5400 is "PT1H30M", 93600 is "P1DT2H", 0 is "PT0S".pub fn duration_format(seconds: f64) -> Result<String, String> {    if !seconds.is_finite() || seconds.abs() > 1e13 {        return Err("seconds out of range".into());    }    let neg = seconds < 0.0;    let ms = (seconds.abs() * 1000.0).round() as u64;    let (days, rest) = (ms / 86_400_000, ms % 86_400_000);    let (h, m, s) = (rest / 3_600_000, rest % 3_600_000 / 60_000, rest % 60_000);    let mut out = String::from(if neg { "-P" } else { "P" });    if days > 0 {        out += &format!("{}D", days);    }    if h > 0 || m > 0 || s > 0 || days == 0 {        out.push('T');        if h > 0 { out += &format!("{}H", h) }        if m > 0 { out += &format!("{}M", m) }        if s > 0 || (h == 0 && m == 0) {            out += &if s % 1000 == 0 { format!("{}S", s / 1000) } else { format!("{}S", (s as f64 / 1000.0).to_string()) };        }    }    Ok(out)}/// `when` plus the duration `text`. `when` is a date ("2026-01-31") or a date and time with or without an offset ("2026-03-29T01:30:00+01:00"), and the answer has the same form. Years and months move the calendar, keeping the day where it can: 2026-01-31 plus P1M is 2026-02-28. Hours of a date alone are an error.pub fn duration_add(when: &str, text: &str) -> Result<String, String> {    let d = parse(text)?;    if d.years.fract() != 0.0 || d.months.fract() != 0.0 {        return Err("years and months must be whole to add them".into());    }    let months = (d.years * 12.0 + d.months) as i64;    let months = if d.neg { -months } else { months };    let fixed = Duration::milliseconds((fixed_seconds(&d) * 1000.0).round() as i64);    let shift = |n: NaiveDateTime| -> Result<NaiveDateTime, String> {        let n = if months >= 0 { n.checked_add_months(Months::new(months as u32)) } else { n.checked_sub_months(Months::new((-months) as u32)) };        n.and_then(|n| n.checked_add_signed(fixed)).ok_or_else(|| "out of range".to_string())    };    let w = when.trim();    if let Ok(t) = DateTime::<FixedOffset>::parse_from_rfc3339(w) {        let n = shift(t.naive_local())?;        return Ok(n.and_local_timezone(*t.offset()).single().ok_or("out of range")?.to_rfc3339());    }    if let Ok(n) = NaiveDateTime::parse_from_str(w, "%Y-%m-%dT%H:%M:%S%.f") {        return Ok(shift(n)?.format("%Y-%m-%dT%H:%M:%S").to_string());    }    let day = NaiveDate::parse_from_str(w, "%Y-%m-%d").map_err(|_| format!("{:?} is not a date or a date and time", when))?;    if fixed.num_milliseconds() % 86_400_000 != 0 {        return Err("a date alone takes whole days, weeks, months and years".into());    }    Ok(shift(day.and_hms_opt(0, 0, 0).unwrap_or_default())?.date().format("%Y-%m-%d").to_string())}
Thread
79Useful? Like it, or add to its thread below.

Thread 0 posts

On ISO durations: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.