Lunch and weekends
A ticket raised at 16:30 on Friday isn't late on Saturday morning. Only open hours count.
Open-source library for .NET
“Respond within 16 business hours.” Simple to say, easy to get wrong: lunch breaks, weekends, public holidays, time zones, daylight saving, and the days you spent waiting on the customer. SlaClock gets it right, so your helpdesk, ticketing or approval system doesn't have to.
var calendar = BusinessCalendar.Create("Asia/Phnom_Penh")
.Weekdays("08:00-12:00", "13:00-17:00")
.Closed(new DateOnly(2026, 11, 9), "Independence Day")
.Build();
// Friday 15:30 + 16 business hours
var due = calendar.Add(createdAt, TimeSpan.FromHours(16));
// → Wednesday 11 Nov, 10:30
Every team that builds a ticketing or approval system writes this code, and the bugs only show up in production, on holidays, and twice a year when the clocks change.
A ticket raised at 16:30 on Friday isn't late on Saturday morning. Only open hours count.
Public holidays, office closures over several days, and short days like New Year's Eve.
Hours are wall-clock times in your office's zone. The day the clocks change really is an hour shorter or longer.
Pause the clock while you wait for a reply, resume when they answer, and still know the deadline.
Try it
This is the real SlaClock library running in your browser on .NET WebAssembly, not a copy of it. The example is a 16-hour ticket created on a Friday afternoon, with a public holiday on Monday and a pause while waiting on the customer. Drag Check the status at to move through time.
Set up
Add it to the project that calculates deadlines, usually your API or service layer.
dotnet add package SlaClock
Weekly hours (split them for lunch), holidays, ranges of closed days, yearly holidays and half days, in your office's time zone. Build() checks it all and refuses overlapping or empty hours.
var calendar = BusinessCalendar.Create("Asia/Phnom_Penh")
.Weekdays("08:00-12:00", "13:00-17:00")
.On(DayOfWeek.Saturday, "08:00-12:00")
.Closed(new DateOnly(2026, 11, 9), "Independence Day")
.Closed(new DateOnly(2027, 4, 14), new DateOnly(2027, 4, 16), "Khmer New Year")
.ClosedEveryYear(1, 1, "International New Year")
.Hours(new DateOnly(2026, 12, 31), "New Year's Eve", ["08:00-12:00"])
.Build();
Every answer comes back in your office's local time, whatever offset you pass in.
| When is it due? | calendar.Add(from, TimeSpan.FromHours(16)) |
| How long did it take? | calendar.Elapsed(from, to) |
| Are we open now? | calendar.IsOpen(now) |
| When do we next open? | calendar.NextOpen(now) |
| Hours or holiday on a date? | calendar.HoursOn(date), calendar.DayName(date) |
var created = new DateTimeOffset(2026, 11, 6, 15, 30, 0, TimeSpan.FromHours(7));
calendar.Add(created, TimeSpan.FromHours(16)); // Wed 11 Nov 10:30 +07:00
calendar.IsOpen(created.AddDays(3)); // false (Independence Day)
calendar.Elapsed(created, created.AddDays(4)); // 07:30:00
Use it in an app
An ASP.NET Core app with a database, from startup to monthly reports. SlaClock never reads the clock or the database itself: you pass the times in, so it works with any storage and is easy to test.
Build the calendar once and share it. Calendars never change after Build(), so one instance is safe for the whole app.
// Program.cs
builder.Services.AddSingleton(
BusinessCalendar.Create("Asia/Phnom_Penh")
.Weekdays("08:00-12:00", "13:00-17:00")
.ClosedEveryYear(1, 1, "International New Year")
.Build());
Work out the deadline when the ticket is created and store it, so “what's due today?” is a plain database query.
public class TicketService(BusinessCalendar calendar, AppDb db)
{
public async Task<Ticket> Create(NewTicket input)
{
var now = DateTimeOffset.UtcNow;
var ticket = new Ticket
{
Title = input.Title,
CreatedAt = now,
DueAt = calendar.Add(now, TimeSpan.FromHours(16)),
};
db.Tickets.Add(ticket);
await db.SaveChangesAsync();
return ticket;
}
}
Keep the timer's events (started, paused, resumed, stopped) in a table. When a ticket changes status, record the event and refresh the deadline. While paused there is no deadline: it moves with the pause.
var sla = SlaTimer.FromEvents(calendar, TimeSpan.FromHours(16), ticket.SlaEvents);
if (newStatus == "Waiting on customer") sla.Pause(now, "Waiting on customer");
if (oldStatus == "Waiting on customer") sla.Resume(now);
if (newStatus == "Resolved") sla.Stop(now, "Resolved");
ticket.SlaEvents = sla.Events.ToList();
ticket.DueAt = sla.StatusAt(now).DueAt; // null while paused
A scheduled job checks open tickets every few minutes and warns the team before a deadline is missed.
foreach (var ticket in openTickets)
{
var status = SlaTimer.FromEvents(calendar, ticket.Target, ticket.SlaEvents)
.StatusAt(DateTimeOffset.UtcNow);
if (status.IsAtRisk) await notify.WarnTeam(ticket, status.Remaining);
if (status.IsBreached) await notify.Escalate(ticket, status.BreachedAt, status.Overdue);
}
StatusAt answers for any moment, so reports read the outcome at the time each ticket was resolved. And because you always pass the time in, tests use fixed dates and give the same result every run.
// Report: share of tickets that met their target
var met = resolved.Count(t =>
!SlaTimer.FromEvents(calendar, t.Target, t.SlaEvents).StatusAt(t.ResolvedAt).IsBreached);
// Test: Friday 16:00 + 8 business hours, with Monday a holiday
[Fact]
public void Due_on_Tuesday_afternoon()
{
var due = calendar.Add(Local(2026, 11, 6, 16), TimeSpan.FromHours(8));
Assert.Equal(Local(2026, 11, 10, 16), due);
}
Rules worth knowing
Ranges include their start and exclude their end, so 17:00 is closed and back-to-back ranges never double count.
A deadline that lands exactly at closing is returned as 17:00 Friday, not as 08:00 Monday.
Give each day its part, 22:00-24:00 and 00:00-06:00. They join into one open period.
Hours in the skipped hour move forward by the gap. In the repeated hour, the first occurrence counts.
Exactly on target is still on time. IsAtRisk turns on at 80 % of the target by default.
Overlapping hours, a calendar with no hours, unknown time zones and events out of order throw clear errors.
No. It's plain calculation on values you pass in. Store calendars and timer events however you like.
Calendars are immutable and thread-safe; register one as a singleton. Timers are per ticket and not shared.
Any IANA id (Asia/Phnom_Penh, Europe/London) or Windows id that your operating system knows. Use BusinessCalendar.AlwaysOpen for 24/7 targets.
Not built in yet: for now you read them from your settings and call the builder. Loading from configuration is planned for version 0.2.
Yes, MIT licensed. Use it in commercial software.