Agenda.Reconciliation (Agenda v0.1.0)

Copy Markdown View Source

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.

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.

What did not add up, as sentences.

Compare what resource claimed in the ledger against what it owed.

Types

breach()

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

t()

@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

balanced?(reconciliation)

@spec balanced?(t()) :: boolean()

true when nothing is unaccounted, nothing is overclaimed, and no limit was breached.

Arguments

  • reconciliation is a t/0.

Returns

  • true or false.

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

explain(reconciliation)

@spec explain(t()) :: [String.t()]

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

  • reconciliation is a t/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"]

reconcile(ledger, resource, options)

@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

Options

  • :within is 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.

  • :excluding is time the resource did not owe — public holidays, a shutdown — as an Tempo.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.

  • :expected states the owed time outright, as an Tempo.IntervalSet.t/0, instead of deriving it from the resource's open hours. :excluding still 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."