# Rules

URL: https://workclock.karthik.nz/docs/concepts/rules.md

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.

**Rust**

```rust
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(())
}
```

**Python**

```python
from datetime import date, datetime, time
from zoneinfo import ZoneInfo

from workclock import Break, Holidays, Schedule, WorkDay

london = ZoneInfo("Europe/London")
support = Schedule(
    [
        WorkDay(time(9), time(17)),  # Monday to Friday by default
        Break(time(12), time(13)),
        Holidays([date(2026, 12, 25), date(2026, 12, 28)]),
    ],
    time_zone=london,
)

thursday = datetime(2026, 12, 24, tzinfo=london)
next_thursday = datetime(2026, 12, 31, tzinfo=london)
support.between(thursday, next_thursday)  # 21:00:00
```

**JavaScript**

```js
import { Schedule } from "workclock";

const support = new Schedule({
  timeZone: "Europe/London",
  rules: [
    { type: "workday", start: "09:00", end: "17:00" },
    { type: "break", start: "12:00", end: "13:00" },
    { type: "holidays", dates: ["2026-12-25", "2026-12-28"] },
  ],
});

// London is at UTC+00:00 in December: these are midnight, London time.
const thursday = "2026-12-24T00:00:00Z";
const nextThursday = "2026-12-31T00:00:00Z";
support.between(thursday, nextThursday); // 75600000 ms
```

## 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 `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](https://workclock.karthik.nz/docs/guides/migrating-from-businesstimedelta.md) lists every difference.

**Rust**

```rust
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(())
}
```

**Python**

```python
from datetime import datetime, time, timezone

from workclock import Schedule, WorkDay

nights = Schedule([WorkDay(time(22), time(6))])  # Monday to Friday, UTC

monday = datetime(2026, 1, 5, 22, tzinfo=timezone.utc)
tuesday = datetime(2026, 1, 6, 6, tzinfo=timezone.utc)
nights.between(monday, tuesday)  # 8:00:00
nights.add(monday, hours=4)  # 2026-01-06 02:00, UTC
```

**JavaScript**

```js
import { Schedule } from "workclock";

const HOUR = 3_600_000; // 1 hour in milliseconds

const nights = new Schedule({
  rules: [{ type: "workday", start: "22:00", end: "06:00" }],
});

const monday = "2026-01-05T22:00:00Z";
const tuesday = "2026-01-06T06:00:00Z";
nights.between(monday, tuesday); // 28800000 ms
nights.add(monday, 4 * HOUR); // Date 2026-01-06T02:00:00.000Z
```

## 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](https://workclock.karthik.nz/docs/concepts/errors.md) 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](https://github.com/seppemans/businesstimedelta/issues/9). Each tab shows the UTC holiday:

**Rust**

```rust
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(())
}
```

**Python**

```python
from datetime import date, datetime, time
from zoneinfo import ZoneInfo

from workclock import Holidays, Schedule, WorkDay

los_angeles = ZoneInfo("America/Los_Angeles")
office = Schedule(
    [
        WorkDay(time(9), time(17)),
        Holidays([date(2026, 1, 26)], time_zone="UTC"),
    ],
    time_zone=los_angeles,
)

start = datetime(2026, 1, 26, 9, tzinfo=los_angeles)
end = datetime(2026, 1, 26, 17, tzinfo=los_angeles)
office.between(start, end)  # 1:00:00
```

**JavaScript**

```js
import { Schedule } from "workclock";

const office = new Schedule({
  timeZone: "America/Los_Angeles",
  rules: [
    { type: "workday", start: "09:00", end: "17:00" },
    { type: "holidays", dates: ["2026-01-26"], timeZone: "UTC" },
  ],
});

const start = "2026-01-26T09:00:00-08:00";
const end = "2026-01-26T17:00:00-08:00";
office.between(start, end); // 3600000 ms
```

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](https://workclock.karthik.nz/docs/concepts/time-zones-and-dst.md).

## Edge cases

These examples are from [DESIGN.md](https://github.com/Karthik-beta/workclock/blob/main/DESIGN.md#rules), and the Rust, Python and JavaScript test suites all run them from [tests/fixtures/design_examples.json](https://github.com/Karthik-beta/workclock/blob/main/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](https://workclock.karthik.nz/docs/concepts/schedules.md): how rules combine into working time.
- [Adding time](https://workclock.karthik.nz/docs/concepts/adding-time.md) and [Measuring time](https://workclock.karthik.nz/docs/concepts/measuring-time.md): `add`, `sub` and `between`.
- [Time zones and DST](https://workclock.karthik.nz/docs/concepts/time-zones-and-dst.md): gaps, overlaps and shifts that span a clock change.
- [Errors](https://workclock.karthik.nz/docs/concepts/errors.md): what building a schedule with no working time raises.
- [DESIGN.md: Rules](https://github.com/Karthik-beta/workclock/blob/main/DESIGN.md#rules): the specification.
