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
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 errorThe 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 errorA 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 errorseconds 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 errorwhen 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.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/iso_durations.qwer$ curl -O https://quillolang.org/plugins/files/iso_durations.qweThen, in your program, all of it or only what you call:
use iso_durations.qweruse duration_parse, duration_seconds from iso_durations // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
chrono = { version = "0.4", default-features = false, features = ["std"] }Example
// 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 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())}Related
business_days.qwer · 1.0.0Working days for due dates, delivery estimates and SLAs: add them, count them between dates, skip weekends and holidays, with US, UK, French and German public holidays built in.6 views · 5 downloadsEnglish dates dates_english.qwer · 1.0.0Dates typed in English read as times, in the user's time zone and US or UK style: next friday at 3pm, tomorrow 9am, in 2 weeks, 3 days ago.8 views · 10 downloadsKSUID ksuid.qwer · 1.0.0KSUIDs, Segment's K-Sortable Unique IDs: 27 base62 characters sorted by the second they were made, with 128 random bits; made, read back and checked.8 views · 7 downloadsChinese calendar chinese_calendar.qwer · 1.0.0The Chinese lunisolar calendar: a date's lunar year, month and day in Chinese, leap months included, the year's stem and branch, zodiac animal and element, Chinese New Year, lunar dates back to Gregorian, and the 24 solar terms.8 views · 7 downloadsCron lines cron.qwer · 1.0.0The next times a cron line runs, and whether it is valid: show a schedule, or run your own.7 views · 7 downloadsFiscal calendars fiscal_calendar.qwer · 1.0.0Fiscal calendars for reports: a date's fiscal year, quarter and month for years starting in any month, retail 4-4-5, 4-5-4 and 5-4-4 calendars of 52 or 53 weeks (the NRF's, Apple's), and ISO weeks.7 views · 11 downloadsThread 0 posts
On ISO durations: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.