Agenda.Limit (Agenda v0.1.0)

Copy Markdown View Source

How much of a resource may — or must — be claimed over a period.

A limit is a budget on a stretch of calendar. It is not concurrency: concurrency asks how many claims may overlap at one instant, a limit asks how much falls inside a day, a week or a month, however far apart the claims sit.

Two measures

A limit counts either claims or time, and the difference is the difference between a roster and a timesheet:

Agenda.resource("Dana", limits: [day: 3, week: 12])
Agenda.resource("Dana", limits: [day: ~o"PT7H36M", week: ~o"PT38H"])

The first says "at most three engagements a day". The second says "at most seven hours thirty-six minutes a day" — the same period vocabulary and the same ledger, measured in duration rather than cardinality. Eight open hours is not eight jobs, and neither is it eight hours of billable work.

Ceilings and floors

An integer or a duration on its own is a ceiling. A floor is written explicitly, and both ends may be given at once:

limits: [week: [at_least: ~o"PT38H", at_most: ~o"PT45H"]]

"At least a full week, and no more than forty-five hours."

Only ceilings constrain the search, and this is a real asymmetry rather than an omission. A ceiling prunes: a candidate that would breach it can be rejected the moment it is considered, because nothing added later can bring the total back down. A floor cannot be used that way — a partial layout is supposed to be under the floor, and rejecting it would reject every layout before the last placement.

So a floor is a completion condition, not a placement condition. Agenda.arrange/3 and Agenda.plan/3 enforce ceilings and ignore floors; Agenda.reconcile/3 checks both, because it is the function that looks at a finished period and asks whether it adds up.

Summary

Types

What a limit counts — claims, or time.

t()

A budget over one period.

Functions

How a period's claims breach a limit, or nil when they do not.

Which day, week or month a moment falls in.

A limit's ceiling as a plain integer in the unit the limit measures.

What a count and a duration amount to, in the unit limit measures.

Read a resource's :limits keyword list into limits.

Read a resource's :limits, raising on anything malformed.

true when count claims totalling duration sit within the limit's ceiling.

What a set of claims comes to, as a count and a duration.

Types

measure()

@type measure() :: {:count, pos_integer()} | {:duration, Tempo.Duration.t()}

What a limit counts — claims, or time.

t()

@type t() :: %Agenda.Limit{
  at_least: measure() | nil,
  at_most: measure() | nil,
  period: :day | :week | :month
}

A budget over one period.

at_most is a ceiling and constrains the search. at_least is a floor and is checked only by Agenda.reconcile/3. Either may be nil, but not both.

Functions

breach(limit, count, duration)

@spec breach(t(), non_neg_integer(), Tempo.Duration.t()) ::
  {:over, measure()} | {:under, measure()} | nil

How a period's claims breach a limit, or nil when they do not.

Both ends are checked, so this is the function that sees a floor.

Arguments

  • limit is a t/0.

  • count is how many claims fall in the period.

  • duration is what they total, as a Tempo.Duration.t/0.

Returns

  • nil when the claims satisfy the limit; or

  • {:over, measure} naming the ceiling that was exceeded; or

  • {:under, measure} naming the floor that was not reached.

Examples

iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(week: [at_least: ~o"PT38H"])
iex> Agenda.Limit.breach(limit, 5, ~o"PT30H")
{:under, {:duration, ~o"PT38H"}}

iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(week: [at_least: ~o"PT38H"])
iex> Agenda.Limit.breach(limit, 5, ~o"PT38H")
nil

bucket(moment, period)

@spec bucket(Tempo.t() | term(), atom()) :: tuple() | :undated

Which day, week or month a moment falls in.

A bucket is {year, month, day} for every period — the components of the period's first moment, with day absent for a month. Uniform by construction, and never compared across periods.

Each is Tempo.trunc/2 to that period, reduced to plain components: the day, the day its week begins on, or its month. The boundary is therefore the calendar's own, not an arbitrary window measured from the first claim — and which calendar is the value's own. A week runs from whichever day that calendar starts its weeks on, so a value in a Sunday-start calendar buckets its Sundays with the following Monday where an ISO value buckets them with the preceding one. Reading the convention off the value is what keeps a weekly contract counting the seven days its holder actually works.

Arguments

  • moment is a Tempo.t/0.

  • period is :day, :week or :month.

Returns

  • an opaque bucket key, equal for two moments in the same period; or

  • :undated when the moment does not carry a full date, or the period is not one this module knows. Everything undated shares one bucket, which keeps a limit conservative rather than unenforced.

Examples

iex> import Tempo.Sigils
iex> Agenda.Limit.bucket(~o"2026-06-16T10:00:00", :day)
{2026, 6, 16}

iex> import Tempo.Sigils
iex> Agenda.Limit.bucket(~o"2026-06-16T10:00:00", :month)
{2026, 6, nil}

iex> import Tempo.Sigils
iex> # Tuesday and Thursday of one week share its Monday.
iex> Agenda.Limit.bucket(~o"2026-06-16T10:00:00", :week) ==
...>   Agenda.Limit.bucket(~o"2026-06-18T10:00:00", :week)
true

ceiling(limit)

@spec ceiling(t()) :: non_neg_integer() | nil

A limit's ceiling as a plain integer in the unit the limit measures.

Counts are themselves; durations are whole seconds. This is what a solver needs — a constraint model has integers and no opinion about what they mean — and keeping the conversion here means the bridge and the built-in search cannot disagree about the unit.

Arguments

  • limit is a t/0.

Returns

  • the ceiling as a non-negative integer, or nil when the limit sets no ceiling.

Examples

iex> [limit] = Agenda.Limit.parse!(day: 3)
iex> Agenda.Limit.ceiling(limit)
3

iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(day: ~o"PT8H")
iex> Agenda.Limit.ceiling(limit)
28800

iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(week: [at_least: ~o"PT38H"])
iex> Agenda.Limit.ceiling(limit)
nil

measure(limit, count, duration)

@spec measure(t(), non_neg_integer(), Tempo.Duration.t()) :: non_neg_integer()

What a count and a duration amount to, in the unit limit measures.

The companion to ceiling/1: both sides of a comparison expressed as integers in the same unit.

Arguments

  • limit is a t/0.

  • count is how many claims fall in the period.

  • duration is what they total, as a Tempo.Duration.t/0.

Returns

  • a non-negative integer — the count itself for a limit measuring claims, whole seconds for one measuring time.

Examples

iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(day: 3)
iex> Agenda.Limit.measure(limit, 2, ~o"PT8H")
2

iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(day: ~o"PT8H")
iex> Agenda.Limit.measure(limit, 2, ~o"PT3H")
10800

parse(limits)

@spec parse(keyword()) :: {:ok, [t()]} | {:error, term()}

Read a resource's :limits keyword list into limits.

Arguments

  • limits is a keyword list keyed by period — :day, :week or :month. Each value is a count, a duration, or a keyword list carrying :at_most and/or :at_least.

Returns

  • {:ok, limits} in the order given; or

  • {:error, reason} naming the period that could not be read.

Examples

iex> {:ok, [limit]} = Agenda.Limit.parse(week: 5)
iex> {limit.period, limit.at_most, limit.at_least}
{:week, {:count, 5}, nil}

iex> import Tempo.Sigils
iex> {:ok, [limit]} = Agenda.Limit.parse(day: ~o"PT7H36M")
iex> limit.at_most
{:duration, ~o"PT7H36M"}

iex> import Tempo.Sigils
iex> {:ok, [limit]} = Agenda.Limit.parse(week: [at_least: ~o"PT38H", at_most: ~o"PT45H"])
iex> {limit.at_least, limit.at_most}
{{:duration, ~o"PT38H"}, {:duration, ~o"PT45H"}}

iex> Agenda.Limit.parse(fortnight: 5)
{:error, "unknown limit period :fortnight — expected :day, :week or :month"}

parse!(limits)

@spec parse!(keyword()) :: [t()]

Read a resource's :limits, raising on anything malformed.

Agenda.Resource.new/2 returns a resource rather than a tuple, so a limit it cannot read has nowhere to go but an exception. That is the right outcome: a limit silently dropped is a contract silently not enforced.

Arguments

Returns

Examples

iex> [limit] = Agenda.Limit.parse!(day: 1)
iex> limit.at_most
{:count, 1}

permits?(limit, count, duration)

@spec permits?(t(), non_neg_integer(), Tempo.Duration.t()) :: boolean()

true when count claims totalling duration sit within the limit's ceiling.

A limit with no ceiling always permits, which is what makes a floor-only limit invisible to the search.

Arguments

  • limit is a t/0.

  • count is how many claims fall in the period.

  • duration is what they total, as a Tempo.Duration.t/0.

Returns

  • true or false.

Examples

iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(day: ~o"PT8H")
iex> {Agenda.Limit.permits?(limit, 1, ~o"PT7H"), Agenda.Limit.permits?(limit, 1, ~o"PT9H")}
{true, false}

iex> import Tempo.Sigils
iex> [limit] = Agenda.Limit.parse!(week: [at_least: ~o"PT38H"])
iex> Agenda.Limit.permits?(limit, 99, ~o"PT99H")
true

sum(claims)

What a set of claims comes to, as a count and a duration.

Named for Tempo.Duration.sum/1, and both measures are computed in one pass because a limit may be expressed either way and the caller does not know which until it looks at the limit.

Arguments

Returns

  • {count, duration}. A claim with no measurable length — unbounded, or a recurrence — contributes nothing to the duration and still contributes one to the count. That keeps a limit conservative rather than silently unenforced.

Examples

An interval set, taken whole:

iex> import Tempo.Sigils
iex> {:ok, set} = Tempo.IntervalSet.new([
...>   ~o"2026-06-16T09:00:00/2026-06-16T12:00:00",
...>   ~o"2026-06-16T13:00:00/2026-06-16T17:00:00"
...> ])
iex> {count, duration} = Agenda.Limit.sum(set)
iex> {count, Tempo.Duration.to_unit(duration, :hour)}
{2, {:ok, 7.0}}

A list of intervals:

iex> import Tempo.Sigils
iex> {count, duration} = Agenda.Limit.sum([~o"2026-06-16T09:00:00/2026-06-16T12:00:00"])
iex> {count, Tempo.Duration.to_unit(duration, :hour)}
{1, {:ok, 3.0}}

A list of allocations, summed by their intervals:

iex> import Tempo.Sigils
iex> allocations = [
...>   %Agenda.Allocation{interval: ~o"2026-06-16T09:00:00/2026-06-16T12:00:00"},
...>   %Agenda.Allocation{interval: ~o"2026-06-16T13:00:00/2026-06-16T17:00:00"}
...> ]
iex> {count, duration} = Agenda.Limit.sum(allocations)
iex> {count, Tempo.Duration.to_unit(duration, :hour)}
{2, {:ok, 7.0}}