Whether a resource's claims account for the time it owed.
A schedule is the ledger read as intent — who should be where. A timesheet is the same ledger read as record — who was. This module compares the two readings over a period and reports the difference.
Why a difference and not a sum
The obvious check is to total the claims and compare against a number. That check passes on data that is wrong. A consultant who misses a Tuesday and works the following Saturday totals exactly the same as one who did neither, and a day billed against an office that was shut totals the same as a day billed against one that was open. A total that balances is not evidence the period is correct — it is evidence that two errors were the same size.
So unaccounted and overclaimed are interval sets, not
quantities. They say "Tuesday afternoon", which is something a
person can act on, where "11.4 hours short" is not.
What counts as expected
Expected time is the resource's open hours inside the window, less anything the caller says removes the obligation:
Agenda.reconcile(ledger, dana, within: quarter, excluding: holidays)agenda does not know what a public holiday is, and does not resolve
one. Holidays arrive as an ordinary Tempo.IntervalSet.t/0 from
wherever the caller keeps them — a jurisdiction library, a CalDAV
feed through Agenda.from_ical/1, or a hand-written list.
The order matters and is deliberate. Holidays leave the expectation before claims are compared against it, so a holiday falling inside a period of leave cannot consume that leave: the day was never owed, so no claim is needed to account for it. That is the perennial payroll bug, and here it is unrepresentable rather than guarded against.
Leave is a claim, not a gap
Work and leave are both claims on the resource, distinguished by
Agenda.Allocation's tag. Both account for expected time — a day of
annual leave is not missing time — and by_tag reports how the
period divided between them.
Summary
Types
A limit that a period's claims did not satisfy.
What a resource owed over a window, and what it claimed.
Functions
true when nothing is unaccounted, nothing is overclaimed, and no
limit was breached.
What did not add up, as sentences.
Compare what resource claimed in the ledger against what it owed.
Types
@type breach() :: %{ period: atom(), bucket: tuple() | :undated, breach: {:over, Agenda.Limit.measure()} | {:under, Agenda.Limit.measure()} }
A limit that a period's claims did not satisfy.
bucket is the specific day, week or month at fault, as returned by
Agenda.Limit.bucket/2.
@type t() :: %Agenda.Reconciliation{ breaches: [breach()], by_tag: %{optional(Agenda.Allocation.tag() | nil) => Tempo.IntervalSet.t()}, claimed: Tempo.IntervalSet.t(), expected: Tempo.IntervalSet.t(), overclaimed: Tempo.IntervalSet.t(), resource: String.t(), unaccounted: Tempo.IntervalSet.t(), within: Tempo.Interval.t() }
What a resource owed over a window, and what it claimed.
Functions
true when nothing is unaccounted, nothing is overclaimed, and no
limit was breached.
Arguments
reconciliationis at/0.
Returns
trueorfalse.
Examples
iex> import Tempo.Sigils
iex> day = ~o"2026-06-16T09:00:00/2026-06-16T17:00:00"
iex> {:ok, dana} = Agenda.open(Agenda.resource("Dana"), day)
iex> arrangement = %Agenda.Arrangement{
...> session: "Acme",
...> interval: day,
...> allocations: %{consultant: [dana]}
...> }
iex> {:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement, tag: {:project, "ACME"})
iex> {:ok, report} = Agenda.reconcile(ledger, dana, within: ~o"2026-06-16/2026-06-17")
iex> Agenda.Reconciliation.balanced?(report)
true
What did not add up, as sentences.
Eligibility in this library is always explained rather than merely decided, and a reconciliation is the same: the point of the report is the sentence a person is sent, not the boolean.
Arguments
reconciliationis at/0.
Returns
- a list of sentences, empty when the period balances.
Examples
iex> import Tempo.Sigils
iex> {:ok, dana} =
...> Agenda.open(Agenda.resource("Dana"), ~o"2026-06-16T09:00:00/2026-06-16T17:00:00")
iex> arrangement = %Agenda.Arrangement{
...> session: "Acme",
...> interval: ~o"2026-06-16T09:00:00/2026-06-16T12:00:00",
...> allocations: %{consultant: [dana]}
...> }
iex> {:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement, tag: {:project, "ACME"})
iex> {:ok, report} = Agenda.reconcile(ledger, dana, within: ~o"2026-06-16/2026-06-17")
iex> Agenda.Reconciliation.explain(report)
["Dana: 5 hours unaccounted — 2026Y6M16DT12H0M0S/T17H0M0S"]
@spec reconcile(Agenda.Ledger.t(), Agenda.Resource.t(), keyword()) :: {:ok, t()} | {:error, term()}
Compare what resource claimed in the ledger against what it owed.
Arguments
ledgeris aAgenda.Ledger.t/0.resourceis aAgenda.Resource.t/0.
Options
:withinis the window to reconcile — a Tempo value or an ISO 8601 string. Required, because open hours may be an unbounded recurrence and have no materialisation without one.:excludingis time the resource did not owe — public holidays, a shutdown — as anTempo.IntervalSet.t/0, any Tempo value that denotes a span (a bare day,~o"2026-08-12", is the natural way to write a holiday), a string, or a list of any of those. Subtracted from the expectation before any claim is compared against it. The default is none.:expectedstates the owed time outright, as anTempo.IntervalSet.t/0, instead of deriving it from the resource's open hours.:excludingstill applies to it.
Returns
{:ok, reconciliation}; or{:error, reason}when the window or a set cannot be read.
Examples
iex> import Tempo.Sigils
iex> {:ok, dana} =
...> Agenda.open(Agenda.resource("Dana"), ~o"2026-06-16T09:00:00/2026-06-16T17:00:00")
iex> arrangement = %Agenda.Arrangement{
...> session: "Acme",
...> interval: ~o"2026-06-16T09:00:00/2026-06-16T12:00:00",
...> allocations: %{consultant: [dana]}
...> }
iex> {:ok, ledger} = Agenda.allocate(Agenda.ledger(), arrangement, tag: {:project, "ACME"})
iex> {:ok, report} = Agenda.reconcile(ledger, dana, within: ~o"2026-06-16/2026-06-17")
iex> Tempo.IntervalSet.members(report.unaccounted)
[~o"2026Y6M16DT12H0M0S/T17H0M0S"]"Three of the eight hours are accounted for; the afternoon is not."