Documentation
¶
Overview ¶
Package sundial is a pure-Go SLA clock: it measures elapsed working time over a business schedule, with pause/resume, breach detection, and write-once escalation levels. It has zero dependencies — only the standard library.
A sundial only advances in daylight; an SLA clock only advances during business hours. Nights, weekends and holidays do not count against a "resolve within 8 working hours" target. sundial is that clock, and only that clock: it owns no tickets, no queues, no notifications, no goroutines and no timers. The caller supplies now to every query, which makes the whole library deterministic, trivially testable, and safe to persist.
sched := sundial.Schedule{
Loc: loc, // e.g. America/New_York
Week: sundial.Weekdays(sundial.Window{Open: sundial.At(9, 0, 0), Close: sundial.At(17, 0, 0)}),
}
t, _ := sundial.Start(sundial.Config{Schedule: sched, Budget: 8 * time.Hour}, start)
due := t.DueAt() // wall-clock instant the budget is exhausted
left := t.Remaining(now) // working time still to go
over := t.Breached(now) // has elapsed reached the budget?
Schedule — the business calendar ¶
A Schedule is a timezone, per-weekday working [Window]s, and a set of full-day holiday closures. Its one primitive is Schedule.Working, which sums the working time in a half-open interval [from, to), skipping non-working days and holidays. Schedule.Add is its inverse: it advances a working-time budget from an instant and returns the wall-clock instant the budget runs out, so that Working(start, Add(start, d)) == d for every d the schedule can satisfy. When a budget lands exactly on a window's Close, Add returns that Close — the earliest instant the budget is exhausted — not the next Open.
DST ¶
Windows are civil-time: a 09:00–17:00 window is eight working hours whether or not the clocks changed that day, because a DST transition (typically 02:00) falls outside it. Working integrates real elapsed time inside windows in the schedule's zone, so a budget crossing a spring-forward day lands on the correct wall-clock instant — one real hour earlier than the naive arithmetic would suggest — while still counting as its full working hours.
Timer — the SLA clock ¶
Start builds a Timer from a Config (schedule, budget, levels) and a start instant. Timer.DueAt projects the due instant; Timer.Remaining and Timer.Breached read the state at a caller-supplied now; Timer.Elapsed reports consumed working time.
Pause and resume ¶
Timer.Pause stops the clock (a ticket waiting on the customer) and Timer.Resume restarts it. Working time inside a pause is excluded from elapsed, and a completed pause shifts Timer.DueAt later by the working time it consumed — so a pause spanning only nights or a weekend shifts nothing, because it contains no working time. An open pause (no resume yet) freezes elapsed at the pause instant. Pauses do not overlap; a resume without an open pause, or a pause while already paused, returns a typed error and never panics. Out-of-order pause/resume instants are clamped forward.
Escalation — write-once and idempotent ¶
Config carries ascending Level thresholds. Timer.Fired returns the levels whose elapsed working time has been reached as of now, each with the wall-clock instant it fired. Each firing instant is computed once, on first observation, and never rewritten, so a caller polling Fired repeatedly gets a stable, append-only history — the property that lets it drive real escalations without double-paging. Breach is the same shape: the breach instant is write-once (see Timer.BreachedAt).
Persistence ¶
Timer.Snapshot returns a serializable copy of the timer's start, pause intervals (including an open one), and firing and breach markers; Restore rebuilds a timer from a fresh Config and that snapshot, resuming exactly — escalation still idempotent across a restart. Only data persists; the Config is supplied afresh. A Schedule is itself JSON-serializable, its Loc written as an IANA zone name.
Not in scope ¶
No holiday data (sundial holds the set the caller gives it), no tickets, queues, assignment or notification, and no running process. sundial computes an SLA clock; everything else is the caller's. See docs/DESIGN.md, including the "Phase 1 — as built" note recording the decisions this implementation made where the design left a choice open.
Example ¶
Example measures elapsed working time and the due instant for an 8-working-hour SLA that starts late on a Friday afternoon and spills over the weekend.
package main
import (
"fmt"
"time"
"github.com/zkrebbekx/sundial"
)
// mustLoc loads a zone for the examples, falling back to UTC so the examples run
// deterministically even without a zone database. The business schedule is the
// same civil 09:00–17:00 either way.
func mustLoc() *time.Location {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
return time.UTC
}
return loc
}
func main() {
loc := mustLoc()
sched := sundial.Schedule{
Loc: loc,
Week: sundial.Weekdays(sundial.Window{Open: sundial.At(9, 0, 0), Close: sundial.At(17, 0, 0)}),
}
start := time.Date(2026, 1, 9, 16, 0, 0, 0, loc) // Friday 16:00
t, _ := sundial.Start(sundial.Config{Schedule: sched, Budget: 8 * time.Hour}, start)
// 1h Friday + 7h Monday = due Monday 16:00; the weekend does not count.
fmt.Println("due:", t.DueAt().Format("Mon 15:04"))
fmt.Println("remaining on Friday 16:30:", t.Remaining(start.Add(30*time.Minute)))
}
Output: due: Mon 16:00 remaining on Friday 16:30: 7h30m0s
Example (Escalation) ¶
Example_escalation shows write-once escalation firing: each level fires once, with a stable instant, so polling drives escalations without double-paging.
package main
import (
"fmt"
"time"
"github.com/zkrebbekx/sundial"
)
// mustLoc loads a zone for the examples, falling back to UTC so the examples run
// deterministically even without a zone database. The business schedule is the
// same civil 09:00–17:00 either way.
func mustLoc() *time.Location {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
return time.UTC
}
return loc
}
func main() {
loc := mustLoc()
sched := sundial.Schedule{
Loc: loc,
Week: sundial.Weekdays(sundial.Window{Open: sundial.At(9, 0, 0), Close: sundial.At(17, 0, 0)}),
}
start := time.Date(2026, 1, 5, 9, 0, 0, 0, loc) // Monday 09:00
t, _ := sundial.Start(sundial.Config{
Schedule: sched,
Budget: 8 * time.Hour,
Levels: []sundial.Level{{Name: "warn", At: 4 * time.Hour}, {Name: "breach", At: 8 * time.Hour}},
}, start)
for _, f := range t.Fired(time.Date(2026, 1, 6, 10, 0, 0, 0, loc)) { // 9 working hours in
fmt.Printf("%s fired at %s\n", f.Level.Name, f.At.Format("Mon 15:04"))
}
}
Output: warn fired at Mon 13:00 breach fired at Mon 17:00
Example (Pause) ¶
Example_pause shows that pausing while waiting on the customer excludes that working time, pushing the due instant later.
package main
import (
"fmt"
"time"
"github.com/zkrebbekx/sundial"
)
// mustLoc loads a zone for the examples, falling back to UTC so the examples run
// deterministically even without a zone database. The business schedule is the
// same civil 09:00–17:00 either way.
func mustLoc() *time.Location {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
return time.UTC
}
return loc
}
func main() {
loc := mustLoc()
sched := sundial.Schedule{
Loc: loc,
Week: sundial.Weekdays(sundial.Window{Open: sundial.At(9, 0, 0), Close: sundial.At(17, 0, 0)}),
}
start := time.Date(2026, 1, 5, 9, 0, 0, 0, loc) // Monday 09:00
t, _ := sundial.Start(sundial.Config{Schedule: sched, Budget: 8 * time.Hour}, start)
fmt.Println("due before pause:", t.DueAt().Format("Mon 15:04"))
_ = t.Pause(time.Date(2026, 1, 5, 11, 0, 0, 0, loc)) // waiting on customer 11:00–14:00
_ = t.Resume(time.Date(2026, 1, 5, 14, 0, 0, 0, loc))
fmt.Println("due after 3h pause:", t.DueAt().Format("Mon 15:04"))
}
Output: due before pause: Mon 17:00 due after 3h pause: Tue 12:00
Index ¶
- Variables
- func Holidays(dates ...Date) map[Date]struct{}
- func Weekdays(windows ...Window) [7][]Window
- type Config
- type Date
- type DayTime
- type Firing
- type Level
- type LevelFiring
- type PauseSpan
- type Schedule
- type Snapshot
- type Timer
- func (t *Timer) Breached(now time.Time) bool
- func (t *Timer) BreachedAt() (time.Time, bool)
- func (t *Timer) DueAt() time.Time
- func (t *Timer) Elapsed(now time.Time) time.Duration
- func (t *Timer) Fired(now time.Time) []Firing
- func (t *Timer) Pause(at time.Time) error
- func (t *Timer) Paused() bool
- func (t *Timer) Remaining(now time.Time) time.Duration
- func (t *Timer) Resume(at time.Time) error
- func (t *Timer) Snapshot() Snapshot
- func (t *Timer) Start() time.Time
- type Window
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrBadSchedule is returned when a Schedule is structurally unusable: a nil // Loc, a malformed holiday date, or a schedule with no working time at all // used under a positive Budget or level threshold (which could never be // reached). ErrBadSchedule = errors.New("sundial: invalid schedule") // ErrBadWindow is returned when a day's working windows are malformed: an // out-of-range or zero-length [DayTime] pair, a Close at or before its Open, // or windows that overlap or are out of ascending order within a day. ErrBadWindow = errors.New("sundial: invalid window") // ErrBadLevels is returned when escalation [Level] thresholds are not // strictly ascending, or any threshold is negative. Levels model "warn at // 4h, breach at 8h": a total order of increasing working-duration marks. ErrBadLevels = errors.New("sundial: invalid levels") // ErrBadBudget is returned when a [Config]'s Budget is negative. A zero // budget is valid and means "due immediately"; a negative one is meaningless. ErrBadBudget = errors.New("sundial: Budget must not be negative") // ErrAlreadyPaused is returned by [Timer.Pause] when the timer is already // paused (its clock is already frozen); pauses do not nest. ErrAlreadyPaused = errors.New("sundial: timer already paused") // ErrNotPaused is returned by [Timer.Resume] when the timer is not currently // paused, so there is no open pause to close. ErrNotPaused = errors.New("sundial: timer not paused") // ErrBadSnapshot is returned by [Restore] when a Snapshot is malformed — for // example a fired marker whose level index is out of range for the supplied // Config. A Snapshot produced by [Timer.Snapshot] is always well-formed; // this guards against a corrupt or hand-built one. ErrBadSnapshot = errors.New("sundial: malformed snapshot") )
Sentinel errors returned when a Schedule, Config or Snapshot is unusable, or a Timer's pause/resume protocol is violated. Match them with errors.Is rather than by comparing error strings; several are wrapped with %w to add context, so string comparison would miss them.
Functions ¶
func Holidays ¶
Holidays builds a holiday set from a list of dates, for use as a Schedule's Holidays field.
func Weekdays ¶
Weekdays builds a Schedule.Week with the given windows on Monday through Friday and no working time on the weekend — the common business-hours shape. The windows are shared read-only across the five days.
Types ¶
type Config ¶
type Config struct {
// Schedule is the business calendar the timer's clock advances on. Required
// and validated.
Schedule Schedule
// Budget is the working time the SLA allows from start. A zero budget means
// "due immediately"; a negative budget is rejected.
Budget time.Duration
// Levels are optional escalation thresholds, strictly ascending by At. A
// common shape is a warn level below Budget and a breach level at Budget.
Levels []Level
}
Config describes a Timer: the business Schedule its clock runs on, the working-time Budget that is the SLA target, and the optional escalation [Level]s. It is validated by Start and Restore.
type Date ¶
Date is a civil calendar date — year, month, day — with no time and no zone. It names a whole day in a Schedule's Loc, used for the holiday set.
func (Date) MarshalJSON ¶
MarshalJSON encodes the Date as a "YYYY-MM-DD" string.
func (*Date) UnmarshalJSON ¶
UnmarshalJSON decodes a "YYYY-MM-DD" string into a Date.
type DayTime ¶
DayTime is a civil time-of-day — hours, minutes and seconds since midnight — within a single day, read in a Schedule's Loc. It carries no date and no zone: the same DayTime names 09:00 whether or not the clocks changed that day, which is what makes a 09:00–17:00 window exactly eight working hours regardless of a DST transition outside it.
An Open bound must lie in [00:00:00, 24:00:00); a Close bound in (00:00:00, 24:00:00]. A Close of 24:00:00 denotes the end of the day (equivalently, the following midnight). A window does not cross midnight; express a night shift as two windows on adjacent weekdays.
func (DayTime) MarshalJSON ¶
MarshalJSON encodes the DayTime as an "HH:MM:SS" string.
func (*DayTime) UnmarshalJSON ¶
UnmarshalJSON decodes an "HH:MM:SS" (or "HH:MM") string into a DayTime.
type Firing ¶
type Firing struct {
// Level is the level that fired.
Level Level
// At is the wall-clock instant elapsed working time reached Level.At.
At time.Time
}
Firing records that a Level has fired: the level itself and the wall-clock instant at which the timer's elapsed working time first reached the level's threshold. The instant is write-once — computed on first observation and stable across every later poll and across a snapshot round-trip.
type Level ¶
type Level struct {
// Name identifies the level for the caller (for example "warn").
Name string
// At is the elapsed working time from start at which the level fires.
At time.Duration
}
Level is one escalation threshold: a name and the amount of elapsed working time from a Timer's start at which it fires. Levels within a Config must be strictly ascending by At (warn at 4h, breach at 8h, page at 12h).
type LevelFiring ¶
LevelFiring is one write-once escalation marker inside a Snapshot: the index of the fired level in the Config's Levels, and the instant it fired.
type PauseSpan ¶
type PauseSpan struct {
Start time.Time `json:"start"`
End time.Time `json:"end,omitempty"`
Open bool `json:"open,omitempty"`
}
PauseSpan is one pause interval inside a Snapshot. When Open is true the pause has no resume yet and End is unused.
type Schedule ¶
type Schedule struct {
// Loc is the zone the windows and holidays are expressed in. Required.
// Working time is integrated in this zone's civil time, so windows keep
// their civil length across DST transitions that fall outside them.
Loc *time.Location
// Week holds the working windows for each weekday, indexed by
// [time.Weekday] (0 = Sunday … 6 = Saturday). An empty day is non-working —
// that is how weekends are expressed. Within a day, windows must be in
// ascending, non-overlapping order.
Week [7][]Window
// Holidays is the set of full-day closures, keyed by civil [Date] in Loc. A
// date present here contributes zero working time regardless of its weekday.
Holidays map[Date]struct{}
}
Schedule is a business calendar: a timezone, the working windows for each weekday, and a set of full-day holiday closures. It answers the one primitive question everything else is built on — how much working time lies between two instants — via Schedule.Working, and its inverse via Schedule.Add.
A Schedule is a plain value: copy it freely. Its fields may be built directly; call Schedule.Validate (or construct a Timer, which validates for you) before relying on it, so Schedule.Working never sees a malformed week.
func (Schedule) Add ¶
Add returns the wall-clock instant reached by advancing d working-time from start — the inverse of Schedule.Working. It walks working windows forward from start, consuming d, and returns the instant the budget is exhausted:
Working(start, Add(start, d)) == d for every d >= 0 the schedule can satisfy.
Boundary rule: when the budget is exhausted exactly at a window's Close, Add returns that Close (the first instant at which the budget runs out), not the next window's Open. Both satisfy the round-trip, since the non-working gap between them carries no working time; Add picks the earlier, tighter instant.
A non-positive d returns start unchanged (nothing to advance). A schedule with no working time at all cannot advance a positive budget and likewise returns start; a Timer built on such a schedule with a positive Budget is rejected at construction, so this arises only for a directly-used empty Schedule. Add assumes a validated Schedule.
func (Schedule) MarshalJSON ¶
MarshalJSON encodes the Schedule, writing Loc as its IANA zone name and the holidays as a date list sorted for a byte-stable result.
func (*Schedule) UnmarshalJSON ¶
UnmarshalJSON decodes a Schedule, resolving Loc with time.LoadLocation. An empty Loc name decodes to a nil Loc, which Schedule.Validate then rejects.
func (Schedule) Validate ¶
Validate reports the first structural problem with the Schedule, or nil. A validated Schedule is safe for Schedule.Working and Schedule.Add.
func (Schedule) Working ¶
Working reports the working duration contained in the half-open interval [from, to): the sum, over every civil day the interval touches, of the overlap of [from, to) with that day's working windows, skipping holidays and non-working days.
The result is real elapsed time inside working windows. For the common case, where no DST transition falls inside a window, that equals the windows' civil length — a 09:00–17:00 window is eight hours whether or not the clocks changed that day, because the changed hour lies outside it. A window that itself straddles a transition contributes its real elapsed time (its civil length minus, or plus, the one-hour offset change), which is exactly what keeps Working and Schedule.Add nanosecond-exact inverses.
If from is not before to, the result is zero; a wholly non-working interval (a weekend, a holiday, an overnight gap) is likewise zero. Working assumes a validated Schedule (see Schedule.Validate).
type Snapshot ¶
type Snapshot struct {
// Start is the timer's start instant.
Start time.Time `json:"start"`
// Clock is the high-water mark of observed pause/resume instants.
Clock time.Time `json:"clock"`
// Pauses are the pause intervals in chronological order; the last may be
// open (still freezing the clock).
Pauses []PauseSpan `json:"pauses,omitempty"`
// Fired are the write-once level firing markers, by level index.
Fired []LevelFiring `json:"fired,omitempty"`
// Breached records whether the timer has been observed to breach, and when.
Breached bool `json:"breached,omitempty"`
BreachedAt time.Time `json:"breachedAt,omitempty"`
}
Snapshot is a serializable, point-in-time copy of a Timer's mutable state: its start, its pause intervals (including an open one), and its write-once firing and breach markers. It is a plain exported struct with stable JSON tags, so a caller can persist it with encoding/json (or anything else) on shutdown and hand it to Restore on boot to resume exactly — with escalation still idempotent, because the recorded firing instants survive.
A Snapshot carries no Config: the schedule, budget and levels are supplied afresh to Restore. Persist the Config's Schedule separately if you need it — it is itself JSON-serializable (its Loc marshals as an IANA zone name).
type Timer ¶
type Timer struct {
// contains filtered or unexported fields
}
Timer is a pure, clock-driven SLA clock: elapsed working time from a start instant over a Schedule, with pause/resume, breach detection, and write-once escalation firing. It owns no goroutines and no timers — the caller supplies now to every query — so it is deterministic and safe to persist.
The zero value is not usable; construct one with Start or Restore. A *Timer is safe for concurrent use; every method takes an internal mutex.
func Restore ¶
Restore rebuilds a running Timer from a fresh cfg and a Snapshot, resuming the exact state the snapshot captured — including an open pause (still frozen) and every write-once firing and breach marker, so escalation stays idempotent across a restart. cfg is validated as in Start; the snapshot is trusted except for bounds that would corrupt state, which yield ErrBadSnapshot.
The Config is supplied afresh because a Snapshot carries none. Restoring with a different Config resumes the persisted markers under the new schedule, budget or levels — that is the caller's choice.
func Start ¶
Start builds a running Timer from cfg with the given start instant, returning a typed sentinel (see the package errors) if cfg is unusable. The timer's clock begins at start; a now handed to a query before start yields zero elapsed working time.
func (*Timer) Breached ¶
Breached reports whether elapsed working time has reached the Budget as of now. On the first observation that it has, the write-once breach instant is recorded (see Timer.BreachedAt); later calls neither un-breach nor rewrite it. A zero Budget is breached from start.
func (*Timer) BreachedAt ¶
BreachedAt reports the write-once instant the timer breached, and whether it has been observed to breach yet. The instant is the wall-clock time elapsed working time reached the Budget, stable once recorded.
func (*Timer) DueAt ¶
DueAt reports the wall-clock instant the Budget is exhausted: Add(start, Budget) shifted later by the working time of every completed pause. It takes no now because it is a projection of state, not a reading at an instant.
While a pause is open the timer's elapsed time is frozen, so the true due instant recedes until the timer resumes; DueAt projects as if the clock resumes at the pause instant (completed pauses shift it, the open one does not). Resuming closes the pause and shifts DueAt later by that pause's working time.
func (*Timer) Elapsed ¶
Elapsed reports the working time consumed since start as of now, excluding paused intervals. It is zero for a now at or before start and never exceeds the working time the schedule places in the interval.
func (*Timer) Fired ¶
Fired returns the escalation levels whose threshold has been reached as of now, in ascending order, each paired with the write-once wall-clock instant it fired. The set is a pure function of elapsed working time at now; each firing instant is computed once, on first observation, and never rewritten — so polling Fired repeatedly yields a stable, append-only history that can drive real escalations without double-firing.
func (*Timer) Pause ¶
Pause stops the clock at instant at (a ticket waiting on the customer). While paused, elapsed working time is frozen. It returns ErrAlreadyPaused if the timer is already paused. at is clamped forward to the latest pause/resume instant already observed, so pauses never overlap or run backward.
func (*Timer) Paused ¶
Paused reports whether the timer is currently paused (its last pause is still open).
func (*Timer) Remaining ¶
Remaining reports the working time left before breach as of now, clamped at zero once elapsed reaches the Budget.
func (*Timer) Resume ¶
Resume restarts the clock at instant at, closing the open pause. The working time inside the just-closed pause is excluded from elapsed and shifts the due instant later. It returns ErrNotPaused if the timer is not paused. at is clamped forward to the pause instant, so a resume never precedes its pause.
type Window ¶
Window is a single contiguous stretch of working time within a day, expressed as civil DayTime bounds in the Schedule's Loc. Occupancy is half-open: [Open, Close). A day may hold several non-overlapping windows in ascending order (for example a lunch break splits 09:00–12:00 and 13:00–17:00).