# Migrating

URL: https://workclock.karthik.nz/docs/guides/migrating-from-businesstimedelta.md

Your Python code computes due dates and elapsed working time with businesstimedelta 1.0.1. You want it to run on workclock with as few changes as possible, then move to the typed API when it suits you. This guide is for Python only: businesstimedelta and `workclock.compat` are Python packages, and the Rust and JavaScript bindings have no compatibility layer.

## Change one import

workclock ships a compatibility layer for
[businesstimedelta](https://github.com/seppemans/businesstimedelta) 1.0.1.
Most code migrates with one line:

```python
import workclock.compat as businesstimedelta
```

`WorkDayRule`, `LunchTimeRule`, `HolidayRule`, `Rules` and `BusinessTimeDelta`
take the same arguments and defaults, support the same operators and
`hours`/`seconds` properties, and treat naive datetimes as UTC. The
businesstimedelta test suite runs against `workclock.compat` in CI.

For new code, the typed API (`workclock.Schedule`) is smaller and stricter: it
rejects naive datetimes, returns a signed `between`
([#12](https://github.com/seppemans/businesstimedelta/issues/12)), and has
batch methods for lists and NumPy arrays. `Rules.difference` keeps
businesstimedelta's absolute value, so existing code sees no change there.

## Fixed

| Upstream behaviour | workclock.compat |
|---|---|
| Overnight shifts: `difference(Mon 22:00, Tue 06:00)` is 0h, `Mon 22:00 + 4h` is Wed 02:00, `Tue 06:00 - 4h` raises `OverflowError` | 8h, Tue 02:00, Tue 02:00 |
| A holiday list that ends before the queried range raises `TypeError` ([#8](https://github.com/seppemans/businesstimedelta/issues/8), [#10](https://github.com/seppemans/businesstimedelta/issues/10)) | Dates after the last holiday are ordinary days |
| Holidays in a different zone from the working hours can loop forever ([#9](https://github.com/seppemans/businesstimedelta/issues/9)) | Terminates with the correct result |
| A time-off block that starts inside a period of working time does not always shorten it | Working time is always the union of working hours minus the union of time off |
| A rule without working time (such as `start_time == end_time`) hangs on addition | Raises `workclock.NoWorkingTimeError`; `difference` returns zero |
| Any search can run forever | Searches give up after 366 days, plus seven days per holiday date, without working time |

## Intentional differences

- **Result time zone.** Results are in the `tzinfo` of the datetime you pass
  in, or UTC if it is naive. businesstimedelta sometimes returns the rule's
  zone instead. The instants are the same, so comparisons are unaffected.
- **Negative deltas.** Adding a negative `BusinessTimeDelta` subtracts, and
  subtracting one adds. businesstimedelta returned the start of the next
  block minus the amount.
- **Repeated times at the end of daylight saving time.** A rule boundary that
  falls in the repeated hour resolves to its first occurrence, as jiff, RFC
  5545 and JavaScript Temporal do. businesstimedelta's pytz `localize()` picks
  the second.
- **Holidays on DST transition days** last from midnight to midnight, so they
  are shorter or longer than 24 hours by the size of the change (usually one
  hour). businesstimedelta always removes 24 hours.
- **Holiday dates** are read once, when the `HolidayRule` is created. Dates
  added to the list or object later, or a new `holidays` attribute, are not
  seen; businesstimedelta reads `holidays` on every call. A python-holidays
  object is filled in for every year from 1970 to 2100 first, and dates
  outside that window are not holidays. Lists of `datetime` objects raise
  `TypeError`; pass `date` objects.
- **Working days** must be integers from 0 (Monday) to 6. Other entries raise
  `ValueError` when the rule is used; businesstimedelta ignored them, or
  matched `1.0` as Tuesday.
- **`next()` and `previous()`** on `Rules` return the whole period of working
  time, so overlapping working-hour rules form one period. businesstimedelta
  returned the block of one rule.
- **Empty `WorkDayRule`.** With `start_time == end_time`, `next()` and
  `previous()` raise `workclock.NoWorkingTimeError`. businesstimedelta
  returned a zero-length block.
- **`HolidayRule.previous(dt)` at the start of a holiday** returns the holiday
  before it, which ends at or before `dt`. businesstimedelta returned the
  holiday that starts at `dt`.
- **Supported rules.** `Rules` accepts `WorkDayRule` and `LunchTimeRule` as
  working time, and `WorkDayRule`, `LunchTimeRule` and `HolidayRule` as time
  off. Subclasses of `Rule` that compute their own blocks, a `HolidayRule`
  used as working time, and nested `Rules` raise `TypeError`, because rules
  run on the engine rather than as Python code.
- **Default zone.** `tz` defaults to `datetime.timezone.utc` instead of
  `pytz.utc`; workclock does not depend on pytz. Zones may be IANA names,
  `zoneinfo.ZoneInfo`, pytz zones or `datetime.timezone.utc`. Fixed-offset
  `tzinfo` objects raise `TypeError`, because they have no IANA name.
- **Operators with other types.** `BusinessTimeDelta == x` and
  `BusinessTimeDelta - x` for unsupported `x` return `NotImplemented`, so
  Python raises `TypeError`, instead of raising `AttributeError` or returning
  `None`.
- **Errors** are `workclock.WorkclockError` subclasses that also inherit from
  `ValueError` or `OverflowError`, so existing `except` clauses keep working.

## Move to the typed API

The typed API is `workclock.Schedule` with the rules `workclock.WorkDay`, `workclock.Break` and `workclock.Holidays`. It runs on the same engine as `workclock.compat`. Both samples in this section use the London support desk: working hours 09:00–17:00 Monday to Friday in `Europe/London`, a 12:00–13:00 break, holidays on 25 and 28 December 2026, and a ticket opened at 16:30 on Thursday 24 December 2026 with an 8-hour SLA.

In Python, with `workclock.compat`. It takes a zone name such as `"Europe/London"` where businesstimedelta needed a pytz zone, and reads naive datetimes as UTC, which on 24 December is also London time:

```python title="Before: workclock.compat"
from datetime import date, datetime, time

import workclock.compat as businesstimedelta

london = "Europe/London"
workday = businesstimedelta.WorkDayRule(time(9), time(17), tz=london)
lunch = businesstimedelta.LunchTimeRule(tz=london)  # 12:00–13:00
holidays = businesstimedelta.HolidayRule(
    [date(2026, 12, 25), date(2026, 12, 28)], tz=london
)
support = businesstimedelta.Rules([workday, lunch, holidays])

opened = datetime(2026, 12, 24, 16, 30)  # naive: read as UTC
eight_hours = businesstimedelta.BusinessTimeDelta(support, hours=8)
opened + eight_hours  # 2026-12-30 09:30, UTC
support.difference(opened, datetime(2026, 12, 29, 11))
# <BusinessTimeDelta 2 hours 1800 seconds>
```

In Python, with `workclock.Schedule`. Every datetime carries its zone, and the results come back in that zone:

```python title="After: workclock.Schedule"
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,
)

opened = datetime(2026, 12, 24, 16, 30, tzinfo=london)
support.add(opened, hours=8)  # 2026-12-30 09:30, London time
support.between(opened, datetime(2026, 12, 29, 11, tzinfo=london))  # 2:30:00
```

Both reach 09:30 on Wednesday 30 December 2026. The ticket uses 30 minutes on 24 December, none over the holidays and the weekend, 7 hours on Tuesday 29 December and 30 minutes on 30 December:



### Replace each name

| `workclock.compat` | `workclock` typed API |
|---|---|
| `WorkDayRule(start_time, end_time, working_days, tz)` | `WorkDay(start, end, days, time_zone=...)` |
| `LunchTimeRule(start_time, end_time, working_days, tz)` | `Break(start, end, days, time_zone=...)` |
| `HolidayRule(holidays, tz)` | `Holidays(dates, time_zone=...)` |
| `Rules([...])` | `Schedule([...], time_zone=...)` |
| `dt + BusinessTimeDelta(rule, hours=8)` | `schedule.add(dt, hours=8)` |
| `dt - BusinessTimeDelta(rule, hours=8)` | `schedule.sub(dt, hours=8)` |
| `rule.difference(dt1, dt2)` | `schedule.between(dt1, dt2)` |
| `rule.next(dt)`, `rule.previous(dt)` | `schedule.next_period(dt)`, `schedule.previous_period(dt)` |

What else changes:

- **Aware datetimes only.** `workclock.Schedule` raises `ValueError` for a naive datetime instead of reading it as UTC.
- **No default times.** `WorkDay` and `Break` need `start` and `end`; `WorkDayRule` defaulted to 09:00–18:00 and `LunchTimeRule` to 12:00–13:00. `days` still defaults to Monday to Friday.
- **No `time_off` flag.** The rule type says it: `WorkDay` is working time, `Break` and `Holidays` are time off.
- **`between` returns a `datetime.timedelta`**, not a `BusinessTimeDelta` with `hours` and `seconds`, and it is negative when the second datetime is before the first ([#12](https://github.com/seppemans/businesstimedelta/issues/12)).
- **Errors when the schedule is built.** `Schedule(...)` raises `workclock.NoWorkingTimeError` if the working hours minus the breaks are empty. In `workclock.compat`, `Rules.difference` returns zero for such rules (C4).
- **Batch methods.** `between_many`, `add_many` and `sub_many` take lists or NumPy arrays and process them in one call.

## Edge cases

Where `workclock.compat` keeps businesstimedelta's behaviour and the typed API does not. The IDs are the examples in [DESIGN.md](https://github.com/Karthik-beta/workclock/blob/main/DESIGN.md), each checked by a test.

| Case | `workclock.compat` | `workclock.Schedule` |
|---|---|---|
| Second datetime before the first | `difference` is positive (C1) | `between` is negative (B4) |
| Adding zero at Saturday 10:00, 9–17 Mon–Fri | Monday 09:00 (C2) | Saturday 10:00 (A5) |
| Subtracting zero at Saturday 10:00 | Friday 17:00 (C3) | Saturday 10:00 (A8) |
| `start == end`, such as 09:00–09:00 | An empty rule (C4) | A 24-hour shift (O9) |
| A naive datetime | Read as UTC (N3) | `ValueError` (N1) |
| A 22:00–06:00 shift, Monday 22:00 to Tuesday 06:00 | 8 hours (C7) | 8 hours (O1) |

- Overnight and 24-hour shifts, breaks and holidays: [Rules](https://workclock.karthik.nz/docs/concepts/rules.md).
- Adding zero, and adding past a holiday: [Adding time](https://workclock.karthik.nz/docs/concepts/adding-time.md).
- Signed results: [Measuring time](https://workclock.karthik.nz/docs/concepts/measuring-time.md).
- Repeated and skipped times when clocks change for daylight saving time (DST): [Time zones and DST](https://workclock.karthik.nz/docs/concepts/time-zones-and-dst.md).
- Naive datetimes and the supported range: [Inputs and precision](https://workclock.karthik.nz/docs/concepts/inputs-and-precision.md).
- Every error class and when it is raised: [Errors](https://workclock.karthik.nz/docs/concepts/errors.md).

## See also

- [Quickstart: Python](https://workclock.karthik.nz/docs/quickstart/python.md)
- [Schedules](https://workclock.karthik.nz/docs/concepts/schedules.md)
- [MIGRATING.md](https://github.com/Karthik-beta/workclock/blob/main/MIGRATING.md), the source of the lists on this page
- [`python/workclock/compat.py`](https://github.com/Karthik-beta/workclock/blob/main/python/workclock/compat.py), the compatibility layer's docstrings
