sundial

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 26, 2026 License: MIT Imports: 6 Imported by: 0

README

sundial

A pure-Go SLA clock: elapsed working time over a business schedule, with pause/resume, breach detection, and escalation levels. Zero dependencies.

A sundial only advances in daylight. An SLA clock only advances during business hours — nights, weekends and holidays don't count against a "resolve within 8 working hours" target. sundial is that clock, and only that clock. The genuinely hard part of SLA tooling is never the product; it's the clock: measuring time that runs only inside a weekly schedule, pauses while a ticket waits on the customer, resumes correctly, is DST- and holiday-safe, and fires escalations exactly once. sundial is that computation, extracted and made storage-free.

  • Pure and clock-driven. No goroutines, no timers. The caller advances time by passing now to each query, so the library is deterministic, trivial to test, and safe to persist.
  • DST- and holiday-safe. Windows are civil-time: 09:00–17:00 is eight working hours whether or not the clocks changed that day. A budget crossing a spring-forward day lands on the correct wall-clock instant.
  • Working-time as a first-class primitive. Working(from,to) and its exact inverse Add(start,d)Working(start, Add(start,d)) == d to the nanosecond.
  • Pause / resume. Intervals waiting on the customer are excluded, even across business hours; a pause over a weekend consumes no working time and shifts nothing.
  • Write-once escalation. Ascending thresholds fire once, each with a stable fired-at instant — poll as often as you like without double-paging.
  • Serializable state. Snapshot the timer, persist it however you like, and restore to resume exactly — escalation stays idempotent across a restart.
  • Zero dependencies. Standard library only. Go 1.23+.
import "github.com/zkrebbekx/sundial"

Example

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)}),
}

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)

t.DueAt()                     // Monday 16:00 — 1h Friday + 7h Monday, weekend skipped
t.Remaining(now)              // working time still to go
t.Breached(now)               // has elapsed reached the budget?

The model

Schedule — the business calendar

A Schedule is a timezone, per-weekday working Windows, and a set of full-day holiday closures the caller supplies. It answers the one primitive everything else is built on:

func (s Schedule) Working(from, to time.Time) time.Duration // working time in [from, to)
func (s Schedule) Add(start time.Time, d time.Duration) time.Time // inverse of Working

Working sums the overlap of [from, to) with each day's windows, skipping weekends and holidays, in the schedule's zone. Add walks windows forward, consuming d, and returns the instant the budget runs out. They are exact inverses; 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.

sundial ships no holiday data — a caller who wants country calendars maps their dates into Schedule.Holidays, which keeps sundial zero-dependency and the holiday data where it belongs. The examples/ program shows the mapping.

Timer — the SLA clock
type Config struct {
    Schedule Schedule
    Budget   time.Duration // working time allowed (the SLA target)
    Levels   []Level       // optional escalation thresholds, ascending
}
type Level struct{ Name string; At time.Duration }

func Start(cfg Config, start time.Time) (*Timer, error)

func (t *Timer) DueAt() time.Time                      // instant the budget is exhausted
func (t *Timer) Remaining(now time.Time) time.Duration // working time left (0 if breached)
func (t *Timer) Elapsed(now time.Time) time.Duration   // working time consumed
func (t *Timer) Breached(now time.Time) bool
func (t *Timer) Pause(at time.Time) error              // stop the clock (waiting on customer)
func (t *Timer) Resume(at time.Time) error             // restart it
func (t *Timer) Fired(now time.Time) []Firing          // levels whose threshold has passed, each once

Pause / resume

A pause interval is excluded from elapsed working time. Elapsed at now is Working(start, now) minus the working time inside all pauses up to now. A completed pause shifts DueAt later by the working time it consumed — so a pause spanning only nights or a weekend shifts nothing, because it holds 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, so the timeline never runs backward.

Escalation — write-once and idempotent

Fired(now) 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:

cfg := sundial.Config{
    Schedule: sched, Budget: 8 * time.Hour,
    Levels: []sundial.Level{{Name: "warn", At: 4 * time.Hour}, {Name: "breach", At: 8 * time.Hour}},
}
t, _ := sundial.Start(cfg, start)
for _, f := range t.Fired(now) {
    fmt.Printf("%s fired at %s\n", f.Level.Name, f.At) // stable across every poll
}

Polling Fired repeatedly yields a stable, append-only history — the property that lets it drive real escalations without double-paging. Breached / BreachedAt behave the same way.

Persistence

Timer state is yours to persist, rather than dragging in a database:

snap := t.Snapshot()            // start, pause intervals (incl. an open one), fired/breach markers
blob, _ := json.Marshal(snap)   // persist however you like
// ... restart ...
var s sundial.Snapshot
json.Unmarshal(blob, &s)
t, _ := sundial.Restore(cfg, s) // resumes exactly; Config (with Schedule) supplied fresh

breachedAt and escalation firings survive a restart, so escalation stays idempotent across process death. A Schedule is itself JSON-serializable — its Loc marshals as an IANA zone name and is resolved with time.LoadLocation on restore.

DST

Windows are defined in civil time in the schedule's zone, so a 09:00–17:00 window is eight working hours whether or not the clocks changed that day (a spring-forward transition at ≈02:00 falls outside the window). Working integrates real elapsed time inside windows, so a budget crossing spring-forward lands on the correct wall-clock instant — one real hour earlier than the naive arithmetic would give, while still counting as its full working hours. Working and Add stay exact inverses across the transition.

Non-goals

  • No holiday data. sundial holds the holiday set the caller gives it; it ships no country calendars.
  • No tickets, queues, assignment, or notification. sundial tells you when an SLA is due and which escalations have fired; acting on that is the caller's.
  • No running process / timers / goroutines. Like a good clock, sundial is a computation: the caller supplies now. Nothing fires on its own, which makes it deterministic and testable. The examples/ program shows a ticker wrapper.

License

MIT

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

Examples

Constants

This section is empty.

Variables

View Source
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

func Holidays(dates ...Date) map[Date]struct{}

Holidays builds a holiday set from a list of dates, for use as a Schedule's Holidays field.

func Weekdays

func Weekdays(windows ...Window) [7][]Window

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

type Date struct {
	Year  int
	Month time.Month
	Day   int
}

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 On

func On(year int, month time.Month, day int) Date

On is a convenience constructor for a Date.

func (Date) MarshalJSON

func (d Date) MarshalJSON() ([]byte, error)

MarshalJSON encodes the Date as a "YYYY-MM-DD" string.

func (Date) String

func (d Date) String() string

String renders the Date as YYYY-MM-DD.

func (*Date) UnmarshalJSON

func (d *Date) UnmarshalJSON(b []byte) error

UnmarshalJSON decodes a "YYYY-MM-DD" string into a Date.

type DayTime

type DayTime struct {
	Hour int
	Min  int
	Sec  int
}

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 At

func At(hour, minute, second int) DayTime

At is a convenience constructor for a DayTime: At(9, 0, 0) is 09:00:00.

func (DayTime) MarshalJSON

func (dt DayTime) MarshalJSON() ([]byte, error)

MarshalJSON encodes the DayTime as an "HH:MM:SS" string.

func (DayTime) String

func (dt DayTime) String() string

String renders the DayTime as HH:MM:SS.

func (*DayTime) UnmarshalJSON

func (dt *DayTime) UnmarshalJSON(b []byte) error

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

type LevelFiring struct {
	Index int       `json:"index"`
	At    time.Time `json:"at"`
}

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

func (s Schedule) Add(start time.Time, d time.Duration) time.Time

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

func (s Schedule) MarshalJSON() ([]byte, error)

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

func (s *Schedule) UnmarshalJSON(b []byte) error

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

func (s Schedule) Validate() error

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

func (s Schedule) Working(from, to time.Time) time.Duration

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

func Restore(cfg Config, s Snapshot) (*Timer, error)

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

func Start(cfg Config, start time.Time) (*Timer, error)

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

func (t *Timer) Breached(now time.Time) bool

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

func (t *Timer) BreachedAt() (time.Time, bool)

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

func (t *Timer) DueAt() time.Time

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

func (t *Timer) Elapsed(now time.Time) time.Duration

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

func (t *Timer) Fired(now time.Time) []Firing

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

func (t *Timer) Pause(at time.Time) error

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

func (t *Timer) Paused() bool

Paused reports whether the timer is currently paused (its last pause is still open).

func (*Timer) Remaining

func (t *Timer) Remaining(now time.Time) time.Duration

Remaining reports the working time left before breach as of now, clamped at zero once elapsed reaches the Budget.

func (*Timer) Resume

func (t *Timer) Resume(at time.Time) error

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.

func (*Timer) Snapshot

func (t *Timer) Snapshot() Snapshot

Snapshot returns a consistent point-in-time copy of the timer's state, taken under the lock. Every slice is freshly allocated, so the caller may marshal or mutate the result while the timer keeps running.

func (*Timer) Start

func (t *Timer) Start() time.Time

Start reports the instant the timer was started.

type Window

type Window struct {
	Open  DayTime
	Close DayTime
}

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).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL