Skip to content

Adding time

Find the instant reached after adding or subtracting an amount of working time, with add and sub.

add(t, d) returns the earliest instant at or after t with exactly d of working time between them. sub(t, d) returns the latest instant at or before t with exactly d of working time between them. This page covers both in Rust, Python and JavaScript.

Add working time

A support desk in London works 09:00–17:00 on weekdays with a lunch hour, and is closed on 25 and 28 December 2026. A ticket opened at 16:30 on Thursday 24 December with an 8-hour SLA uses 30 minutes on Thursday, none on the holidays or the weekend, 7 hours on Tuesday 29 December, and its last 30 minutes on Wednesday 30 December. It is due at 09:30 on Wednesday 30 December 2026, London time.

  • Thu 24
  • Fri 25
  • Sat–Sun
  • Mon 28
  • Tue 29
  • Wed 30
Working hours 09:00–17:00, break 12:00–13:00. Times in Europe/London. Due 09:30 on Wednesday 30 December 2026 (Europe/London).

Each tab adds the 8 hours, then subtracts them again from the due instant. Because 16:30 on Thursday is inside working time, sub returns the instant the ticket was opened.

use jiff::civil::{date, time};
use jiff::{SignedDuration, Zoned};
use workclock::{Break, Holidays, Schedule, Weekdays, WorkDay};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let days = Weekdays::MON_TO_FRI;
    let support = Schedule::builder()
        .time_zone("Europe/London")
        .rule(WorkDay::new(time(9, 0, 0, 0), time(17, 0, 0, 0), days))
        .rule(Break::new(time(12, 0, 0, 0), time(13, 0, 0, 0), days))
        .rule(Holidays::new([date(2026, 12, 25), date(2026, 12, 28)]))
        .build()?;

    let opened: Zoned = "2026-12-24T16:30[Europe/London]".parse()?;
    let due = support.add(&opened, SignedDuration::from_hours(8))?;
    assert_eq!(due.to_string(), "2026-12-30T09:30:00Z");

    let start = support.sub(due, SignedDuration::from_hours(8))?;
    assert_eq!(start.to_string(), "2026-12-24T16:30:00Z");
    Ok(())
}

What each language takes and returns:

  • Rust: workclock::Schedule::add(&self, t: impl Into<jiff::Timestamp>, d: jiff::SignedDuration) returns Result<jiff::Timestamp, workclock::Error>, and sub has the same signature. t can be a jiff::Timestamp or a &jiff::Zoned. For local time, convert the result with due.to_zoned(opened.time_zone().clone()), as crates/workclock/examples/sla.rs does.
  • Python: workclock.Schedule.add(dt, delta=None, /, *, hours=0, minutes=0, seconds=0) adds delta (a datetime.timedelta) plus hours, minutes and seconds. dt must be a timezone-aware datetime (a naive one raises ValueError); the result has the same tzinfo as dt. sub takes the same arguments.
  • JavaScript: add(t, ms) and sub(t, ms) take a Date or an ISO 8601 string with a UTC offset, and a duration in milliseconds, and return a Date. The duration must be a whole number of milliseconds within ±Number.MAX_SAFE_INTEGER, or they throw RangeError; a string or BigInt duration throws TypeError rather than being converted.

Python's add_many and sub_many, and JavaScript's addMany and subMany, do the same for many instants in one call.

Where the result lands

  • On a period's end. add returns the earliest instant with enough working time, so a result that uses up a period lands on the period's end, not on the start of the next one. On the London desk, adding 1 hour at 11:00 on Tuesday 29 December returns 12:00, the start of lunch, not 13:00 (DESIGN.md A2).
  • On a period's start, for sub. Subtracting 1 hour at 10:00 on a Monday of a 09:00–17:00 schedule returns 09:00 (DESIGN.md A9).
  • From outside working time, add counts from the start of the next working time: 1 hour from 17:00 on Monday is 10:00 on Tuesday (DESIGN.md A3).
  • Zero returns t unchanged, even outside working time or inside a break (DESIGN.md A5, A8, A11).
  • A negative amount reverses the direction. add(t, -d) is sub(t, d), and sub(t, -d) is add(t, d) (DESIGN.md A6).

Round trips

For every t and d, between(t, add(t, d)) is d: Measuring time counts exactly the working time add used.

The reverse, sub(add(t, d), d) returning t, holds only when t is inside working time. From outside, sub lands on the start of the working time that add used. On a 09:00–17:00 weekday schedule, adding 1 hour at 10:00 on a Saturday returns 10:00 on Monday, and subtracting 1 hour from that returns 09:00 on Monday, not Saturday (DESIGN.md A4 then A9).

When add and sub fail

ErrorRust (workclock::Error)Python (workclock)JavaScript (workclock)
No working time within the search bound: 366 days, plus seven days per holiday dateError::NoWorkingTimeNoWorkingTimeErrorNoWorkingTimeError
The result is outside the supported range, -009999-01-02T01:59:59Z to 9999-12-30T22:00:00ZError::OutOfRangeOutOfRangeErrorOutOfRangeError

A schedule that builds always has working time, so the search bound trips only when rules in different zones are kept apart by daylight saving time (DST) changes for more than a year, or, in theory, when decades of holidays all fall on the only working day. Python also raises OutOfRangeError for a result outside the years 1 to 9999 of datetime, or an amount that does not fit in 64-bit microseconds. Every error class is on Errors.

Edge cases

These examples are from DESIGN.md, and the Rust, Python and JavaScript test suites all run them from tests/fixtures/design_examples.json. Unless the row says otherwise, they use the week of Monday 5 January 2026 in UTC, and 9–17 Mon–Fri is a WorkDay from 09:00 to 17:00, Monday to Friday.

IDScheduleQueryResultWhat it shows
A19–17 Mon–Friadd(Mon 10:00, 2h)Mon 12:00Inside one period
A2Sameadd(Mon 16:00, 1h)Mon 17:00A result that uses up a period lands on its end
A3Sameadd(Mon 17:00, 1h)Tue 10:00From the end of a period, counting starts at the next one
A4Sameadd(Sat 10:00, 1h)Mon 2026-01-12 10:00From outside working time
A5Sameadd(Sat 10:00, 0h)Sat 10:00Zero returns t, even outside working time
A6Sameadd(Mon 10:00, -2h)Fri 2026-01-02 16:00A negative amount counts back
A7Samesub(Tue 09:00, 1h)Mon 16:00sub from the start of a period
A8Samesub(Sat 10:00, 0h)Sat 10:00Zero returns t for sub too
A9Samesub(Mon 2026-01-12 10:00, 1h)Mon 2026-01-12 09:00sub lands on a period's start, so A4 does not round-trip
A10Samesub(Sat 10:00, 1h)Fri 16:00sub from outside working time
A119–17 Mon–Fri, break 12–13add(Mon 12:30, 0h)Mon 12:30Zero returns t inside a break
O222–06 Mon–Friadd(Mon 22:00, 4h)Tue 02:00add through midnight in an overnight shift
O3Samesub(Tue 06:00, 4h)Tue 02:00sub within an overnight shift
O4Sameadd(Tue 02:00, 1h)Tue 03:00A shift in progress at t counts
K39–17 Mon–Fri, break 12–13 Mon–Friadd(Mon 11:30, 1h)Mon 13:30add skips a break
K4Sameadd(Mon 12:30, 30m)Mon 13:30From inside a break
K5Samesub(Mon 13:30, 1h)Mon 11:30sub skips a break
H29–17 Mon–Fri, holiday Wed 2026-01-07add(Tue 16:00, 2h)Thu 10:00add skips a holiday
HO322–06 Mon–Fri, holiday Tue 2026-01-06add(Mon 22:00, 3h)Wed 01:00A holiday cuts an overnight shift at midnight
D322–06 every day, America/New_Yorkadd(Sat 2026-03-07 22:00 EST, 7h)Sun 2026-03-08 06:00 EDTThe night clocks go forward has 7 hours
E49–17 Mon–Fri, holidays every date from 2026-01-01 to 2035-12-29add(2026-01-01 00:00, 1h)Mon 2035-12-31 10:00Holidays lengthen the search bound
E59–17 Mon–Friadd(Thu 9999-12-30 16:00, 2h)Out-of-range errorA result past the supported range
E79–17 Mondays only, holidays every Monday from 2026-01-12 to 2027-03-15 (62 dates)add(Tue 2026-01-06 00:00, 1h)Mon 2027-03-22 10:00A search across 62 missing weeks
E8Samesub(Mon 2027-03-22 09:00, 1h)Mon 2026-01-05 16:00The same search backwards

See also

Report a problem with this pageAsk a question in Discussions

On this page