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.
| Date | WorkDay | Break | Holidays | Working time |
|---|---|---|---|---|
| Thursday 24 December | 09:00–17:00 | 12:00–13:00 | None | 7 hours |
| Friday 25 December | 09:00–17:00 | 12:00–13:00 | 00:00–24:00 | None |
| Saturday 26 and Sunday 27 December | Not a working day | None | None | None |
| Monday 28 December | 09:00–17:00 | 12:00–13:00 | 00:00–24:00 | None |
| Tuesday 29 December | 09:00–17:00 | 12:00–13:00 | None | 7 hours |
| Wednesday 30 December | 09:00–17:00 | 12:00–13:00 | None | 7 hours |
| From Thursday 24 to Thursday 31 December | 21 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.
| Rust | Python | JavaScript | |
|---|---|---|---|
| Create | workclock::WorkDay::new(start, end, days) | workclock.WorkDay(start, end, days=range(5), *, time_zone=None) | { type: "workday", start, end, days, timeZone } |
start, end | jiff::civil::Time | datetime.time without a tzinfo | "HH:MM", "HH:MM:SS" or "HH:MM:SS.fff" |
| Days | workclock::Weekdays, required: Weekdays::MON_TO_FRI, Weekdays::ALL, or collected from jiff::civil::Weekday values | Integers 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 tzinfo | timeZone: "Asia/Kolkata" |
What a WorkDay produces:
- For each local date whose weekday is in its days, the block from
starton that date toend. - If
endis at or beforestart, the shift is overnight andendfalls on the next date.startequal toendis 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 ofjiff::civil::Date; duplicates are ignored..time_zone(name)sets its zone. - Python:
workclock.Holidays(dates, *, time_zone=None)takesdatetime.datevalues; adatetimeraisesTypeError. A python-holidays object contributes only the years it already holds, so create it withyears=...; one that holds no years raisesValueError. - JavaScript:
{ type: "holidays", dates: ["2026-12-25"], timeZone }, with dates asYYYY-MM-DDstrings.
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.
| ID | Schedule | Query | Result | What it shows |
|---|---|---|---|---|
| O1 | 22–06 Mon–Fri | between(Mon 22:00, Tue 06:00) | 8h | An overnight shift ends on the next date |
| O2 | 22–06 Mon–Fri | add(Mon 22:00, 4h) | Tue 02:00 | add runs through midnight |
| O3 | 22–06 Mon–Fri | sub(Tue 06:00, 4h) | Tue 02:00 | sub runs back through the same shift |
| O4 | 22–06 Mon–Fri | add(Tue 02:00, 1h) | Tue 03:00 | Monday's shift, in progress at the query, counts |
| O5 | 22–06 Mon–Fri | between(Sat 00:00, Sat 06:00) | 6h | Friday's shift runs into Saturday |
| O6 | 22–06 Mon–Fri | between(Mon 00:00, Mon 06:00) | 0h | No shift starts on Sunday, so none ends on Monday |
| O7 | 00–00 Mon–Sun | between(Mon 00:00, next Mon 00:00) | 168h | start equal to end is a 24-hour shift |
| O8 | 00–00 Mon–Fri | between(Mon 00:00, next Mon 00:00) | 120h | 24-hour shifts on weekdays only |
| O9 | 09–09 Mon–Fri | between(Mon 12:00, Tue 12:00) | 24h | A 24-hour shift need not start at midnight |
| K1 | 9–17 Mon–Fri, break 12–13 Mon–Fri | between(Mon 09:00, Mon 17:00) | 7h | A break removes its hour |
| K2 | Same | between(Mon 12:30, Mon 14:00) | 1h | A query that starts inside a break |
| K3 | Same | add(Mon 11:30, 1h) | Mon 13:30 | add skips the break |
| K4 | Same | add(Mon 12:30, 30m) | Mon 13:30 | add from inside a break starts after it |
| K5 | Same | sub(Mon 13:30, 1h) | Mon 11:30 | sub skips the break |
| H1 | 9–17 Mon–Fri, holiday Wed 2026-01-07 | between(Mon 09:00, Sat 00:00) | 32h | A holiday removes a working day |
| H2 | Same | add(Tue 16:00, 2h) | Thu 10:00 | add skips the holiday |
| H3 | Same | between(Wed 12:00, Thu 12:00) | 3h | A query that starts inside a holiday |
| H4 | Same | between(Mon 2027-01-04 09:00, 17:00) | 8h | No time off after the last holiday |
| HO1 | 22–06 Mon–Fri, holiday Tue 2026-01-06 | between(Mon 22:00, Tue 06:00) | 2h | A shift that crosses into a holiday keeps the part before midnight |
| HO2 | Same | between(Tue 22:00, Wed 06:00) | 6h | A shift that starts on a holiday keeps the part after midnight |
| HO3 | Same | add(Mon 22:00, 3h) | Wed 01:00 | add across both |
| Z1 | 9–17 Mon–Fri Asia/Kolkata, holiday 2026-01-26 | between(2026-01-26T03:30Z, 2026-01-26T11:30Z) | 0h | The holiday covers 09:00–17:00 IST, here given as UTC instants |
| Z2 | Same | add(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 |
| Z3 | 9–17 Mon–Fri America/Los_Angeles, holiday 2026-01-26 in the schedule's zone | between(Mon 09:00 PST, Mon 17:00 PST) | 0h | Holidays default to the schedule's zone, not UTC |
| Z4 | Same, holiday explicitly in UTC | Same | 1h | A rule's own zone moves its blocks |
| Z5 | 9–18 Mon–Fri America/Santiago, break 12–13 | between(2016-01-18 09:00, 18:00) in America/Los_Angeles | 4h | Query instants and rules can be in different zones |
See also
- Schedules: how rules combine into working time.
- Adding time and Measuring time:
add,subandbetween. - Time zones and DST: gaps, overlaps and shifts that span a clock change.
- Errors: what building a schedule with no working time raises.
- DESIGN.md: Rules: the specification.
Report a problem with this pageAsk a question in Discussions