# `Agenda.Limit`
[🔗](https://github.com/elixir-tempo/agenda/blob/v0.3.0/lib/agenda/limit.ex#L1)

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.

`parse/1` and `parse!/1` read a resource's `:limits` into limits.
`sum/1` totals a set of claims as a count and a duration, `bucket/2`
says which day, week or month a moment falls in, and `permits?/3`,
`breach/3`, `measure/3` and `ceiling/1` hold a period's total against
a limit.

# `measure`

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

What a limit counts — claims, or time.

# `t`

```elixir
@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.

# `breach`

```elixir
@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:t/0`.

* `count` is how many claims fall in the period.

* `duration` is what they total, as a `t: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`

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

Which day, week or month a moment falls in.

A bucket is a three-element tuple for every period: `{year, month, day}`
for a day, `{year, month, nil}` for a month, and `{year, {:week, week},
nil}` for a week, as the calendar counts its weeks. 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 `t: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`

```elixir
@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: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`

```elixir
@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:t/0`.

* `count` is how many claims fall in the period.

* `duration` is what they total, as a `t: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`

```elixir
@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!`

```elixir
@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

* `limits` is as for `parse/1`.

### Returns

* the limits; or raises `ArgumentError`.

### Examples

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

# `permits?`

```elixir
@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:t/0`.

* `count` is how many claims fall in the period.

* `duration` is what they total, as a `t: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`

```elixir
@spec sum(Tempo.t() | [Tempo.t() | Agenda.Allocation.t()]) ::
  {non_neg_integer(), Tempo.Duration.t()}
```

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

* `claims` is a `t:Tempo.t/0` interval set, a list of `t:Tempo.t/0`
  intervals, or a list of `t:Agenda.Allocation.t/0`.
  Agenda knows Tempo's types intimately, so a caller holding a set
  should not have to take it apart first.

### 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}}

---

*Consult [api-reference.md](api-reference.md) for complete listing*
