Skip to content

Working calendars

Many deadlines count working time: "answer within 10 working days", "remind after 2 working days", "escalate if untouched for 4 working hours". Others count calendar days but may not end on a closed day: "40 days, extended to the next working day if the last one is a weekend or a public holiday". A working calendar holds the working hours and the closed days these deadlines count with.

Working calendars come with the Orchestra Calendar module (orchestra_calendar). Without it, every duration counts in calendar time, and the editor offers no other way to count. A duration that names one anyway, brought by configuration, counts in calendar time and is logged. The module cannot be uninstalled while a workflow counts with one of its calendars.

An example

A service answers requests within ten working days, and reminds the person in charge every two working days:

n_answer:
  type: user
  config:
    payload_variable: decision
  features:
    timers:
      - after: P2D
        counting: working          # two working days
        calendar: office
        action: notify
        repeat: 0                  # every two working days, until answered
        settings: { notify_tag: assignee_reminder }
      - after: P10D
        counting: working          # ten working days
        calendar: office
        action: resume
        settings: { timeout_outcome: expired }

The office calendar opens Monday to Friday, 08:30 to 12:00 and 13:30 to 17:00, and closes on public holidays. A request parked on Friday 18 December 2026 at 10:00 then:

  • gets its first reminder on Tuesday 22 December at 10:00, two working days later;
  • expires on Tuesday 5 January 2027 at 10:00. Christmas Day and New Year's Day are closed, so the tenth working day is a week later than ten days of the calendar would say.

Three ways to count

Every duration a deadline is measured with says how it counts, under counting:

counting P10D from Friday 18 December 10:00 Use it for
calendar (the default) Monday 28 December 10:00 a duration that runs day and night
next_working Monday 28 December 10:00 a legal deadline: calendar days, extended to the next working moment
working Tuesday 5 January 10:00 working days and hours

With next_working, the duration is counted in calendar time, then a moment on a closed day or outside working hours moves to the next opening. Thirty days from Friday 6 November 2026 at 10:00 is Sunday 6 December, so the deadline is Monday 7 December at 08:30.

A month or a year that runs past the end of a shorter month stops at its last day before moving on: one month from 31 January 2026 is Saturday 28 February, so the deadline is Monday 2 March at the opening. Plain calendar time counts a month the same way: one month from 31 January is 28 February.

With working, only the time inside working hours counts:

  • P10D is ten working days, at the same time of day. A day counts when it has working hours and is not closed. A time of day the day reached does not work stays on that day: one working day from Thursday 16:30 is Friday 16:00 when Friday closes at 16:00.
  • PT4H is four working hours. Four working hours from Friday 16:00 end on Monday at 11:30: one hour on Friday, three on Monday from the 08:30 opening.
  • P2DT4H counts the days first, then the hours.

Years, months and weeks have no working length, so working refuses P1M, P1Y and P2W. Count them with next_working.

calendar names the calendar to count with. Leave it out to use the default calendar, see below. A duration counted in calendar time needs none.

Where a duration can count on a calendar

Duration Settings
A timeout that waits for a duration (relative) deadline_settings: { duration: P10D, counting: working, calendar: office }
A timeout that waits until a date (absolute), shifted by an offset deadline_settings: { until: hearing_date, offset: -P2D, counting: working, calendar: office }
A timer of a task's ladder { after: P2D, counting: working, calendar: office, action: notify }
A join's timeout as a task's timeout
The site's default timeout Counted in and Working calendar, beside Default timeout on the Orchestra settings

The editor shows Counted in beside each of them, and Working calendar once a working counting is chosen.

An offset going back, such as -P2D before a date, moves to the previous working moment instead, so "two working days before the hearing" stays before it. With next_working or working, a date with no offset still moves to a working moment.

Defining a calendar

Calendars are listed under Configuration → Workflow → Orchestra → Working calendars (/admin/orchestra/working-calendars). A calendar holds:

  • Working hours, typed per weekday as periods separated by commas, such as 08:30-12:00, 13:30-17:00. A day left empty is not worked. 24:00 ends a period at midnight.
  • Closed days, one per line: 12-25 closes 25 December every year, 2026-12-24 closes that date only, and 2026-08-03/2026-08-21 closes a range, both ends included.
  • Also closed on: closed days that providers supply, one checkbox per provider. Tick as many as apply: the calendar is closed on the days of each. A provider that takes settings shows them once ticked. Orchestra ships the Public holidays in France: the eight fixed holidays, and Easter Monday, Ascension Day and Whit Monday computed from Easter each year. Its Open on setting names the holidays the calendar works anyway. A module can add more providers, see below.
  • Open days, typed as the closed days are: days the calendar is open whatever closes them, its own closed days, its providers or the calendar it extends. A day is still worked only if its weekday has working hours.
  • Time zone: the hours and the days are read in it. A period on the day the clocks change lasts what the wall clock says.
  • Extends: a calendar whose closed days are closed here too. A service extends the organization's calendar and adds its own closing week, without typing the public holidays again. The working hours and the time zone stay the service's own.

Open on a day that is closed

All the French public holidays but one, Whit Monday worked as the solidarity day: tick Public holidays in France, then Whit Monday under its Open on. Whit Monday moves with Easter, so the provider skips it every year.

A date that does not move goes under Open days: 12-26 opens 26 December every year, 2026-12-24 that date only.

Each calendar of a chain decides for itself before the one it extends. A service that extends the organization's calendar and lists 2026-05-25 under Open days is open that day, even if the organization's calendar is closed. A calendar extending that service and listing the same date under Closed days is closed again.

Like statuses and notification profiles, a calendar is shared with every tenant, or scoped to one. A workflow offers the shared calendars and its own tenant's, and a shared workflow offers the shared calendars only. A calendar that a workflow of another scope names cannot be scoped to one tenant: the form refuses, naming them. A config import can still scope it, and an instance pinned to an older version may name a calendar its workflow no longer names. An instance of another tenant then counts with its own default calendar instead. This is logged, as for a calendar that is gone.

The default calendar

A calendar can be the Default calendar of its scope. A duration that names no calendar counts with the default calendar of its instance's tenant, and, when the tenant has none, with the shared default. The editor offers it as Use the default calendar (currently: …) when there is one, and asks for a calendar when there is none.

Saving an enabled calendar as the default takes the place of the previous one in its scope. A disabled calendar is nobody's default.

When a calendar changes

A deadline is computed when the token parks, and stored as a moment. A calendar edited afterwards does not move a deadline already armed. The next arming reads the new calendar: the next park, or the next fire of a recurring timer.

A workflow counting with a calendar depends on it, so config import creates the calendar first, and the calendar cannot be deleted while a workflow counts with it, a calendar extends it, or the site's default timeout names it. Its delete page lists what still names it. Disable it instead to stop offering it, while what already names it keeps counting with it.

A duration naming a calendar that is gone counts with the default calendar instead, and, with no default calendar either, in calendar time. That happens to an instance still running a version of its workflow that named a calendar deleted since. Both are logged. A deadline counted on the wrong days still ends; one that is not counted would never end.

Closed days from elsewhere

A module adds a source of closed days, such as another country's public holidays or the days an HR system knows, with a closed days provider:

namespace Drupal\my_module\Plugin\ClosedDaysProvider;

use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\orchestra_calendar\Attribute\ClosedDaysProvider;
use Drupal\orchestra_calendar\ClosedDaysProviderInterface;

#[ClosedDaysProvider(
  id: 'company_closing_days',
  label: new TranslatableMarkup('Company closing days'),
)]
final class CompanyClosingDays extends PluginBase implements ClosedDaysProviderInterface {

  public function getClosedDays(int $year): array {
    // Each closed day of that year, as "YYYY-MM-DD".
    return [sprintf('%04d-08-14', $year)];
  }

}

It is asked once per year a deadline's count crosses, so a provider reading its days from elsewhere caches them. A calendar using it depends on its module.

A provider takes settings for each calendar by implementing ConfigurableInterface and PluginFormInterface (ConfigurablePluginTrait helps), and declaring their schema as orchestra_calendar.closed_days_provider.<id>. The calendar form shows its settings form once it is ticked, and the calendar stores what it submits:

closed_days_providers:
  french_public_holidays:
    open_holidays: [whit_monday]
  company_closing_days: {}

Holidays that follow Easter

Good Friday, Easter Monday, Ascension Day and Whit Monday move with Easter. Easter::getSunday() gives Easter Sunday of a year, so a provider counts them from it rather than computing Easter again:

use Drupal\orchestra_calendar\Easter;

  public function getClosedDays(int $year): array {
    $easter = Easter::getSunday($year);
    return [
      // Good Friday and Easter Monday.
      $easter->modify('-2 days')->format('Y-m-d'),
      $easter->modify('+1 day')->format('Y-m-d'),
    ];
  }

It needs no PHP extension: PHP's own easter_days() belongs to the calendar extension, which many hosts do not enable. The Public holidays in France provider counts its three Easter holidays this way.