NAME
Time::Keeper::Business - business days, business hours and holidays on Time::Keeper
VERSION
Version 0.01
SYNOPSIS
use Time::Keeper;
use Time::Keeper::Business;
my $b = Time::Keeper::Business->new(
weekend => 'en-GB',
holidays => [ Time::Keeper::Business::Rule->uk, '2026-12-31' ],
working => [ '2026-12-19' ],
hours => { default => ['09:00-12:30', '13:30-17:30'], fri => ['09:00-13:00'] },
zone => 'Europe/London',
name => 'london',
);
my $d = Time::Keeper::Date->new(year => 2026, month => 12, day => 24);
$b->is_business_day($d); # 1
$b->is_holiday($d->plus(days => 1)); # Christmas Day
$b->holidays(year => 2027); # ([Date, name], ...)
$b->next_business_day($d); # 2026-12-29
$b->plus_business_days($d, 3); # 2027-01-04
$b->business_days_between($d, $d->plus(days => 14)); # 6
$b->roll($d->plus(days => 2), convention => 'modified_following');
$b->last_business_day(Time::Keeper::YearMonth->new(year => 2026, month => 12));
my $t = Time::Keeper->new(year => 2026, month => 12, day => 18, hour => 11, zone => 'Europe/London');
$b->is_business_hours($t); # 1
$b->next_close($t); # 2026-12-18T13:00, a short Friday
$b->plus_business_hours($t, hours => 4); # 2026-12-19T11:00, the worked Saturday
$b->business_hours_between($t, $t->plus(days => 3))->total('hours'); # 11.5
$b->deadline($t, days => 2, end_of_day => 1); # 2026-12-21T17:30
DESCRIPTION
A schedule, the days a business works, the holidays it does not, and the hours of the day, over Time::Keeper's values. It answers the next or the nth business day, how many business days or business hours lie between two instants, and when an SLA deadline falls. A schedule is not a Time::Keeper::Calendar, which is a reckoning of years and months such as the Hebrew calendar; a schedule reads any of those.
A schedule never changes, every method that would change one gives a new one and leaves the old as it was.
CONSTRUCTOR
new
my $b = Time::Keeper::Business->new(%spec);
The keys, each optional:
- weekend
-
The days that are closed every week: a list of day numbers, 1 for Monday to 7 for Sunday, or a locale code such as
ar-EG, whose territory's weekend CLDR gives (Friday and Saturday there), read once. Saturday and Sunday when the key is absent; an empty list for a seven-day week. One schedule has one weekend: a value's own locale is never consulted. - holidays
-
A list of holiday sources, any mix of:
a date: a string any Time::Keeper::Date can parse, a Time::Keeper::Date, or a Time::Keeper (the date its own zone shows);
a date in every year:
--12-25, a Time::Keeper::MonthDay, or{ month => 12, day => 25, name => 'Christmas Day' };--02-29falls in leap years only;a hash of dates, of either kind, to names (
undeffor no name);a code ref, called with a year and returning that year's holidays as any of the above, as
[date, name]pairs, or as a hash whose keys may also beMMDDorMM-DDof that year;an object with a
holidaysmethod, askedholidays(year => $y)and read as a code ref's answer: Date::Holidays and its adapters, which give{ MMDD => name }, a Time::Keeper::Business::Rule (a fixed date, the nth weekday, Easter, with observed days; three countries ship as examples), or another schedule;a list of any of these.
The first name given for a day is kept, and a later one fills a day that had none. A code ref or an object is asked once for each year the schedule is questioned about, and one year either side, and never again for that year; a question that would span more than about a thousand years of such sources is refused, since a list is the right form for that. A Date::Holidays object is used, never loaded: it is not a prerequisite of this module.
- working
-
Dates that are worked although they fall on the weekend: a Saturday a business opens to make up for a holiday. A date that is also a holiday given as a date is refused; a holiday a source gives for that day later closes it again. A working day that falls on a weekday changes nothing.
- zone
-
The zone the business hours are read in, any zone Time::Keeper accepts but
floating. Needed whenhoursis given. - hours
-
hours => ['09:00-17:00'] hours => { default => ['09:00-12:30', '13:30-17:30'], fri => ['09:00-13:00'], '2026-12-24' => ['09:00-12:00'], '2026-12-31' => [] }The business hours, as spans
HH:MM-HH:MMorHH:MM:SS-HH:MM:SSof the wall clock inzone, each half-open, ending by24:00and not overlapping another. A list applies to every day; a hash gives a list a weekday (1to7, ormontosun), adefaultfor the weekdays not named, and a list for a date that overrides its weekday, which may be empty for a day closed early. A weekday that is worked must have hours or a default; a weekend day without either has none, so a worked Saturday is a business day with no business hours. A holiday has no hours whatever the table says. A shift over midnight is written as two spans,22:00-24:00on the day and00:00-06:00on the next. - name
-
A name, for messages and for the string form.
An unknown key dies by name.
VALUES, ZONES AND CALENDARS
Every method that takes a value takes a Time::Keeper or a Time::Keeper::Date, and one that gives a value back gives the same class. A Time::Keeper's day is the date its own zone shows, and a value written back on another day keeps its time of day, zone, locale and calendar; a time of day the new day skipped is carried forward, as plus(days => 1, gap => 'forward') would carry it. A Time::Keeper::YearMonth is read as its first day where a month is wanted.
THE SCHEDULE
is_business_day
$b->is_business_day($v); # 1 or 0
Whether the value's day is worked: not a weekend day, or a weekend day listed as working, and not a holiday.
is_holiday
$b->is_holiday($v); # 'Christmas Day', 1, or undef
The holiday's name when the day is one, 1 when it is one without a name, undef otherwise. A weekend day is not a holiday unless it is listed as one.
is_weekend_day
Whether the day is one of the schedule's weekend days, worked or not.
holidays
my @pairs = $b->holidays(year => 2026);
my @pairs = $b->holidays($from, $to); # both ends included
my @pairs = $b->holidays($year_month); # the month
The holidays in the span, in date order, each a pair [$date, $name] of a Time::Keeper::Date and the name, or undef for a holiday without one. Two values give the span between their days; one value gives its month.
holiday_sources
The holiday sources as they were given.
weekend
The weekend days as a list of numbers, 1 for Monday to 7 for Sunday.
zone
name
hours
The key as given to "new", or undef.
with
my $ny = $b->with(zone => 'America/New_York');
A new schedule with the keys given replaced and the rest kept. A joined schedule ("join") cannot be changed this way: change the schedules and join them again.
with_holidays
my $c = $b->with_holidays('2027-05-03', { '2027-12-27' => 'Christmas Day (substitute)' });
A new schedule with more holiday sources added to the ones given.
with_working_days
A new schedule with more working days added.
join
my $both = $london->join($new_york); # closed when either is
my $any = $london->join($new_york, mode => 'both'); # closed only when both are
Two schedules as one. With mode => 'either', the default, a day is closed when either schedule has it closed, so the weekend is the union of the two and a holiday of either counts: a settlement needs both markets open. With mode => 'both' a day is closed only when both have it closed. name => names the result.
BUSINESS DAYS
Every method here takes a Time::Keeper or a Time::Keeper::Date and gives back the same class on the day found, a Time::Keeper with its time of day, zone, locale and calendar kept (see "VALUES, ZONES AND CALENDARS").
next_business_day
previous_business_day
my $d = $b->next_business_day($v); # strictly after
my $d = $b->previous_business_day($v); # strictly before
The first business day after, or before, the value's day. A schedule with no business day within a year of it dies.
roll
my $d = $b->roll($v); # following
my $d = $b->roll($v, convention => 'modified_following');
The value's day when it is a business day, else the day the convention names: following, the first business day after (the default); preceding, the last before; modified_following, as following unless that crosses into the next month, then preceding; modified_preceding, the mirror; nearest, whichever of the two is closer, the following on a tie, so a Saturday goes back and a Sunday forward; and none, which dies when the day is not a business day. These are ISDA's business day conventions and numpy's roll modes.
plus_business_days
minus_business_days
my $d = $b->plus_business_days($v, 10);
my $d = $b->plus_business_days($v, -3, roll => 'following');
my $d = $b->minus_business_days($v, 10);
The day that many business days on, or back. A day that is not a business day is rolled first, forward for a positive count and back for a negative one unless roll names a convention, and a count of zero is that roll: two business days after a Saturday is Wednesday. minus_business_days with n is plus_business_days with -n.
business_days_between
my $n = $b->business_days_between($from, $to);
my $n = $b->business_days_between($from, $to, include_end => 1);
The business days from the first day up to but not including the second, as numpy's busday_count counts them: Monday to the next Monday is five. Negative when the second day is earlier. include_end => 1 counts the second day too when it is a business day.
business_days
my @days = $b->business_days($from, $to);
Every business day from the first day to the second, both included, in order, each of the first value's class. More than about ten million is refused.
nth_business_day
last_business_day
my $d = $b->nth_business_day($v, 3); # the third of the value's month
my $d = $b->nth_business_day($ym, -1); # the last
my $d = $b->last_business_day($ym);
The nth business day of the value's month, counting from 1, or back from the end with a negative n; the value may be a Time::Keeper::YearMonth, which gives a Date. A month with fewer business days than asked dies with the count.
BUSINESS HOURS
The methods here take a Time::Keeper: a date has no clock, and a floating time no zone to read the hours in. The moment is read on the schedule's wall clock and the answer comes back in the caller's zone, locale, calendar and class.
HOW TIME IS RECKONED
Business time is the wall clock's, not elapsed time. Nine to five is eight hours on the day the clocks go forward as on any other, a repeated hour counts once, and a leap second is part of 23:59:59. An open or a close that falls on a time the clocks skipped is the first moment after it, and one in a repeated hour is the earlier reading, as "new" in Time::Keeper would choose. So on 8 March 2026 in New York a span 02:00-04:00 opens at 03:00.
is_business_hours
$b->is_business_hours($t); # 1 or 0
Whether the moment falls inside a span on a business day.
next_open
next_close
previous_open
previous_close
my $open = $b->next_open($t); # $t itself when open
my $close = $b->next_close($t); # the close of that span
my $open = $b->previous_open($t); # the open of the span $t is in, or the last before
my $close = $b->previous_close($t); # the last close at or before $t
The opens and closes around a moment. next_open is the first moment at or after it that is open, the moment itself when it is; next_close the close of the span that open begins; previous_close the last close at or before it, so at the stroke of a close it is that close; previous_open the open of the span the moment is in, or the last open before it. A schedule with no business hours within a year of the moment dies.
business_hours_on
my @spans = $b->business_hours_on($v); # ([$open, $close], ...)
The spans of the value's day as pairs of values, in the caller's zone for a Time::Keeper and in the schedule's for a Time::Keeper::Date; none on a closed day. A close of 24:00 is the next day's midnight.
BUSINESS TIME AND DEADLINES
Business time is the time inside the spans, on the wall clock (see "HOW TIME IS RECKONED"). The methods take a Time::Keeper.
plus_business_hours
minus_business_hours
my $t2 = $b->plus_business_hours($t, hours => 4);
my $t2 = $b->plus_business_hours($t, hours => 1, minutes => 30, seconds => 0, nanoseconds => 0);
my $t2 = $b->plus_business_hours($t, Time::Keeper::Duration->new(hours => 1, minutes => 30));
my $t2 = $b->minus_business_hours($t, hours => 8);
The moment that much business time after, or before, the one given, by hours, minutes, seconds, milliseconds, microseconds and nanoseconds, or by a Time::Keeper::Duration of those units (one with months or days dies: use "plus_business_days"). A start outside the hours counts from the next open, or going back from the previous close; an amount that ends exactly on a close stops at that close; zero is the next open. Nanoseconds are kept.
business_hours_between
my $d = $b->business_hours_between($t, $u); # a Duration, PT6H30M
my $h = $b->business_hours_between($t, $u)->total('hours'); # 6.5
my $h = $b->business_hours_between($t, $u, number => 1); # 6.5, without the Duration
The business time from the first moment to the second, as a Time::Keeper::Duration in hours, minutes, seconds and nanoseconds, negative when the second is first. On a schedule open every hour of every day it is the wall-clock difference, which differs from "until" in Time::Keeper by the hour the clocks changed when a change lies between.
deadline
my $by = $b->deadline($t, hours => 4); # first response
my $by = $b->deadline($t, days => 2, end_of_day => 1); # the close of the second business day
my $by = $b->deadline($t, days => 1, hours => 2);
my $by = $b->deadline($t); # the next open
An SLA deadline. The clock starts at the next open after the moment; days steps that many business days keeping the time of day (a time outside that day's hours moves to its next open); end_of_day => 1 then moves to the close of that day's last span; and the clock units, as "plus_business_hours" takes them, then run as business time. Every key is optional and none may be negative.
SERIALISATION
TO_JSON
A plain hash: weekend as a list of numbers, holidays as a hash of YYYY-MM-DD to the name or undef, working as the list of worked weekend days, and zone, name and hours where given. It is what "new" takes, so Time::Keeper::Business->new(%$json) is the same schedule.
STORABLE_freeze
STORABLE_thaw
FREEZE
THAW
Storable, and Sereal and CBOR::XS through FREEZE and THAW, carry the plain form, so a thawed schedule is the same schedule. A yearly date travels as its --MM-DD key. A schedule with a code ref or an object among its sources cannot be frozen, nor a join of schedules with yearly or such sources: list the holidays, or join the thawed schedules again.
THREADS
CLONE
Sets up a new thread's copy of the module. A schedule copied into a thread answers on its own.
EXAMPLES
The distribution's eg/ scripts print a year's holidays and the ten business days after 20 December for the three rule sets of Time::Keeper::Business::Rule:
perl eg/uk.pl 2027
perl eg/us-federal.pl 2027
perl eg/target2.pl 2027
An SLA desk in New York, open nine to six on weekdays, closed on the federal holidays, answering within four business hours and resolving by the close of the second business day:
my $desk = Time::Keeper::Business->new(
holidays => Time::Keeper::Business::Rule->us_federal,
hours => ['09:00-18:00'],
zone => 'America/New_York',
);
my $opened = Time::Keeper->now;
my $respond = $desk->deadline($opened, hours => 4);
my $resolve = $desk->deadline($opened, days => 2, end_of_day => 1);
Two markets as one, for a settlement that needs both open:
my $both = $london->join($new_york);
my $settle = $both->plus_business_days($trade_date, 2);
SEE ALSO
Time::Keeper, Time::Keeper::Date, Time::Keeper::Business::Rule, Date::Holidays, Business::Hours, Date::Business, DateTime::BusinessHours
AUTHOR
LNATION <email@lnation.org>
BUGS
Please report any bugs or feature requests to bug-time-keeper-business at rt.cpan.org, or through the web interface at https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Time-Keeper-Business.
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)