Skip to content

Rules

How WorkDay, Break and Holidays rules produce working time and time off, including overnight shifts and per-rule time zones.

A rule is one input to a schedule. WorkDay makes time available for work; Break and Holidays are time off. Every rule has a time zone, which is the schedule's unless the rule sets its own. This page covers the three rule types in Rust, Python and JavaScript.

Rules across one week

The London support desk has three rules: a WorkDay from 09:00 to 17:00, Monday to Friday; a Break from 12:00 to 13:00 on the same days; and Holidays on 25 and 28 December 2026. Times are in Europe/London.

DateWorkDayBreakHolidaysWorking time
Thursday 24 December09:00–17:0012:00–13:00None7 hours
Friday 25 December09:00–17:0012:00–13:0000:00–24:00None
Saturday 26 and Sunday 27 DecemberNot a working dayNoneNoneNone
Monday 28 December09:00–17:0012:00–13:0000:00–24:00None
Tuesday 29 December09:00–17:0012:00–13:00None7 hours
Wednesday 30 December09:00–17:0012:00–13:00None7 hours
From Thursday 24 to Thursday 31 December21 hours

Each tab builds the schedule and counts those 21 hours.

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 thursday: Zoned = "2026-12-24T00:00[Europe/London]".parse()?;
    let next_thursday: Zoned = "2026-12-31T00:00[Europe/London]".parse()?;
    let worked = support.between(&thursday, &next_thursday);
    assert_eq!(worked, SignedDuration::from_hours(21));
    Ok(())
}

WorkDay

A WorkDay has a start, an end, the weekdays it runs on and a time zone.

RustPythonJavaScript
Createworkclock::WorkDay::new(start, end, days)workclock.WorkDay(start, end, days=range(5), *, time_zone=None){ type: "workday", start, end, days, timeZone }
start, endjiff::civil::Timedatetime.time without a tzinfo"HH:MM", "HH:MM:SS" or "HH:MM:SS.fff"
Daysworkclock::Weekdays, required: Weekdays::MON_TO_FRI, Weekdays::ALL, or collected from jiff::civil::Weekday valuesIntegers from 0 (Monday) to 6 (Sunday); Monday to Friday if omitted"mon" to "sun"; Monday to Friday if omitted
Own time zone.time_zone("Asia/Kolkata")time_zone="Asia/Kolkata" or a tzinfotimeZone: "Asia/Kolkata"

What a WorkDay produces:

  • For each local date whose weekday is in its days, the block from start on that date to end.
  • If end is at or before start, the shift is overnight and end falls on the next date. start equal to end is therefore a 24-hour shift.
  • A shift belongs to the weekday it starts on. A Friday 22:00–06:00 shift runs into Saturday, and no shift ends on Monday morning unless one starts on Sunday.
  • A shift already in progress at the query instant counts from that instant.

Overnight shifts

A night shift works 22:00–06:00, Monday to Friday, in UTC. Monday's shift counts 8 hours between Monday 22:00 and Tuesday 06:00, and adding 4 hours at Monday 22:00 lands on Tuesday 02:00 (DESIGN.md O1 and O2). businesstimedelta 1.0.1 gives 0 hours and Wednesday 02:00 for the same calls; the migration guide lists every difference.

use jiff::civil::time;
use jiff::{SignedDuration, Timestamp};
use workclock::{Schedule, Weekdays, WorkDay};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let days = Weekdays::MON_TO_FRI;
    let nights = Schedule::builder()
        .rule(WorkDay::new(time(22, 0, 0, 0), time(6, 0, 0, 0), days))
        .build()?;

    let monday: Timestamp = "2026-01-05T22:00:00Z".parse()?;
    let tuesday: Timestamp = "2026-01-06T06:00:00Z".parse()?;
    let worked = nights.between(monday, tuesday);
    assert_eq!(worked, SignedDuration::from_hours(8));

    let end = nights.add(monday, SignedDuration::from_hours(4))?;
    assert_eq!(end.to_string(), "2026-01-06T02:00:00Z");
    Ok(())
}

Break

A Break has the same shape as a WorkDay, including overnight breaks, but it is time off: its blocks are removed from the working time. A break outside working time has no effect.

  • Rust: workclock::Break::new(start, end, days), with .time_zone(name) for its own zone.
  • Python: workclock.Break(start, end, days=range(5), *, time_zone=None).
  • JavaScript: { type: "break", start, end, days, timeZone }.

Warning

In Python and JavaScript a break's days default to Monday to Friday, like a WorkDay's. A break during a weekend WorkDay needs its own days, or it removes nothing on Saturday and Sunday.

Holidays

Holidays is a set of local dates in a time zone. Each date removes the block from 00:00 on that date to 00:00 on the next date.

  • Rust: workclock::Holidays::new(dates) takes any iterator of jiff::civil::Date; duplicates are ignored. .time_zone(name) sets its zone.
  • Python: workclock.Holidays(dates, *, time_zone=None) takes datetime.date values; a datetime raises TypeError. A python-holidays object contributes only the years it already holds, so create it with years=...; one that holds no years raises ValueError.
  • JavaScript: { type: "holidays", dates: ["2026-12-25"], timeZone }, with dates as YYYY-MM-DD strings.

What a holiday removes:

  • Calendar time, not whole shifts. An overnight shift that crosses into a holiday loses only the part after midnight; a shift that starts on a holiday keeps the part after the next midnight (DESIGN.md HO1–HO3).
  • A whole local day, so a holiday on the day clocks change for daylight saving time (DST) is shorter or longer than 24 hours by the size of the change, usually one hour.
  • Nothing past the last date. Holidays are a finite set; queries after the last one see no time off (DESIGN.md H4).

The dates are read once, when the schedule is built; workclock never calls back into Python or JavaScript per date. Each holiday date also lengthens the search bound by seven days: Errors explains why.

Time zones of rules

Each rule has its own time zone. A rule without one uses the schedule's, and that includes Holidays: a holiday is a local date in the schedule's zone, not in UTC.

An office in America/Los_Angeles works 09:00–17:00, Monday to Friday, with a holiday on Monday 26 January 2026. With the holiday in the schedule's zone, Monday has no working time (DESIGN.md Z3). With the holiday set to UTC, it removes 16:00 on Sunday to 16:00 on Monday, Los Angeles time, and leaves 1 hour of Monday's working hours (DESIGN.md Z4). That mismatch is behind businesstimedelta issue #9. Each tab shows the UTC holiday:

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

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let days = Weekdays::MON_TO_FRI;
    let office = Schedule::builder()
        .time_zone("America/Los_Angeles")
        .rule(WorkDay::new(time(9, 0, 0, 0), time(17, 0, 0, 0), days))
        .rule(Holidays::new([date(2026, 1, 26)]).time_zone("UTC"))
        .build()?;

    let start: Zoned = "2026-01-26T09:00[America/Los_Angeles]".parse()?;
    let end: Zoned = "2026-01-26T17:00[America/Los_Angeles]".parse()?;
    let worked = office.between(&start, &end);
    assert_eq!(worked, SignedDuration::from_hours(1));
    Ok(())
}

Without the holiday's own zone, each tab gives 0 hours. Working hours in each rule's zone, and what happens when clocks change, are on Time zones and DST.

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
O122–06 Mon–Fribetween(Mon 22:00, Tue 06:00)8hAn overnight shift ends on the next date
O222–06 Mon–Friadd(Mon 22:00, 4h)Tue 02:00add runs through midnight
O322–06 Mon–Frisub(Tue 06:00, 4h)Tue 02:00sub runs back through the same shift
O422–06 Mon–Friadd(Tue 02:00, 1h)Tue 03:00Monday's shift, in progress at the query, counts
O522–06 Mon–Fribetween(Sat 00:00, Sat 06:00)6hFriday's shift runs into Saturday
O622–06 Mon–Fribetween(Mon 00:00, Mon 06:00)0hNo shift starts on Sunday, so none ends on Monday
O700–00 Mon–Sunbetween(Mon 00:00, next Mon 00:00)168hstart equal to end is a 24-hour shift
O800–00 Mon–Fribetween(Mon 00:00, next Mon 00:00)120h24-hour shifts on weekdays only
O909–09 Mon–Fribetween(Mon 12:00, Tue 12:00)24hA 24-hour shift need not start at midnight
K19–17 Mon–Fri, break 12–13 Mon–Fribetween(Mon 09:00, Mon 17:00)7hA break removes its hour
K2Samebetween(Mon 12:30, Mon 14:00)1hA query that starts inside a break
K3Sameadd(Mon 11:30, 1h)Mon 13:30add skips the break
K4Sameadd(Mon 12:30, 30m)Mon 13:30add from inside a break starts after it
K5Samesub(Mon 13:30, 1h)Mon 11:30sub skips the break
H19–17 Mon–Fri, holiday Wed 2026-01-07between(Mon 09:00, Sat 00:00)32hA holiday removes a working day
H2Sameadd(Tue 16:00, 2h)Thu 10:00add skips the holiday
H3Samebetween(Wed 12:00, Thu 12:00)3hA query that starts inside a holiday
H4Samebetween(Mon 2027-01-04 09:00, 17:00)8hNo time off after the last holiday
HO122–06 Mon–Fri, holiday Tue 2026-01-06between(Mon 22:00, Tue 06:00)2hA shift that crosses into a holiday keeps the part before midnight
HO2Samebetween(Tue 22:00, Wed 06:00)6hA shift that starts on a holiday keeps the part after midnight
HO3Sameadd(Mon 22:00, 3h)Wed 01:00add across both
Z19–17 Mon–Fri Asia/Kolkata, holiday 2026-01-26between(2026-01-26T03:30Z, 2026-01-26T11:30Z)0hThe holiday covers 09:00–17:00 IST, here given as UTC instants
Z2Sameadd(Fri 2026-01-23T11:00Z, 1h)2026-01-27T04:00Z (Tue 09:30 IST)add from a UTC instant skips the holiday in Kolkata time
Z39–17 Mon–Fri America/Los_Angeles, holiday 2026-01-26 in the schedule's zonebetween(Mon 09:00 PST, Mon 17:00 PST)0hHolidays default to the schedule's zone, not UTC
Z4Same, holiday explicitly in UTCSame1hA rule's own zone moves its blocks
Z59–18 Mon–Fri America/Santiago, break 12–13between(2016-01-18 09:00, 18:00) in America/Los_Angeles4hQuery instants and rules can be in different zones

See also

Report a problem with this pageAsk a question in Discussions

On this page