StatifierBlocks.DurationInput (StatifierBlocks v0.17.0)

Copy Markdown View Source

The :duration control's reading of the text an author typed.

ADR-0005 decision 9, as amended 2026-08-29 and again 2026-09-05 (clause 9a, one grammar), renders :duration as one text control:

Field typeRendering
:durationone text control; duration strings the expression language reads, with on-screen examples

The amendment's terms are the whole of this module's job. Each one is a function of the typed text and nothing else, which is why the reading lives here rather than in StatifierBlocks.Editor.Field - this module compiles and is asserted with LiveView absent, on the same principle as StatifierBlocks.Finding.severity_class/1, and ADR-0005 decision 1's namespace boundary is what makes that a property rather than a habit.

The three readings, and what each one means to the form

  • :empty - the key is omitted. A cleared field and a never-set field are the same value: there is no zero-duration stand-in and no third state for "the author touched this and then did not finish", so both read :empty and both leave the key out of config. StatifierBlocks.Editor.ConfigForm is where the omission is performed, because that is the module that decides where a decoded value is written.
  • :duration - a duration string a person types (30s, 1h30m, 3d8h), which is the only form a :duration field accepts.
  • :invalid - carries the sentence the field shows instead of offering an edit at all.

The stored form is the author's string verbatim - nothing here canonicalises on the way in, and duration is a projection for the caller that wants the normalised value, not something to write back.

Acceptance is StatifierBlocks.Core.Duration, exactly

This module decides nothing about which strings are durations. It asks StatifierBlocks.Core.Duration.parse/1, which asks Predicator.Duration.parse/1, which owns the grammar. The amendment is explicit about why: "A grammar restated here would be a second opinion that drifts."

That is a deliberate difference from the spike control this graduates (sb-709, spike/js/panes.js), which refused a strict subset - a sub-second unit, fractional components and repeated units alike. None of those three refusals survives. A fraction that normalises into whole components (1.5h is an hour and a half), a repeated unit that accumulates (3h2h is five hours) and a sub-second value (500ms) are all things the amendment names as the grammar's to define, and Core.Duration compiles all three, so refusing any of them here would be the second opinion.

Nothing is trimmed

"2d " is refused rather than read as "2d". The stored form is verbatim and Core.Duration does not trim either, so trimming here would make the inline check and the document gate disagree about the same bytes - the inline check would pass a value the gate then refuses, which is the one failure a per-field check exists to prevent.

The refusal says what is accepted, and stops there

One grammar means one thing can be wrong: the text is not a duration. The message names the shape a duration takes and offers the examples the form already shows, which is the difference between a form that teaches and a form that sulks. It is clause 9d that makes that wording part of the decision rather than a matter of style. The type-level messages that StatifierBlocks.Core.Send and StatifierBlocks.Core.Wait attach to a stored value are theirs.

Summary

Types

One typed duration, read. form is the whole decision; duration is the normalised projection of a valid reading and nil otherwise; message is the sentence the field shows and is "" for every reading but :invalid.

Functions

The examples the form shows beside the field, in the amendment's order.

The placeholder the empty control carries - one example, in the spelling the field stores.

Reads one stored or typed duration.

The sentence the field shows beneath a refused value.

True when a value belongs in config at all - false exactly for the reading that omits the key.

Types

reading()

@type reading() :: %{
  form: :empty | :duration | :invalid,
  duration: map() | nil,
  message: String.t()
}

One typed duration, read. form is the whole decision; duration is the normalised projection of a valid reading and nil otherwise; message is the sentence the field shows and is "" for every reading but :invalid.

Functions

examples()

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

The examples the form shows beside the field, in the amendment's order.

They are what replaces the affordance the retired unit dropdown used to carry: they are the form a person types, and showing them is how an author who has never typed a duration learns the spelling.

iex> StatifierBlocks.DurationInput.examples()
["30s", "15m", "1h30m", "2d", "3d8h"]

placeholder()

@spec placeholder() :: String.t()

The placeholder the empty control carries - one example, in the spelling the field stores.

iex> StatifierBlocks.DurationInput.placeholder()
"1h30m"

read(text)

@spec read(term()) :: reading()

Reads one stored or typed duration.

Total for any term. A non-binary stored value - a number an older document carried, say - is not a duration and is refused, but it is never discarded: the control still renders the bytes the document holds, on the same principle as ADR-0005 decision 12.

iex> StatifierBlocks.DurationInput.read("")
%{form: :empty, duration: nil, message: ""}

iex> StatifierBlocks.DurationInput.read("1h30m").form
:duration

iex> StatifierBlocks.DurationInput.read("500ms").form
:duration

iex> StatifierBlocks.DurationInput.read("soon").message
"Not a duration. Try 30s, 15m, 1h30m, 2d, 3d8h."

refusal_message()

@spec refusal_message() :: String.t()

The sentence the field shows beneath a refused value.

iex> StatifierBlocks.DurationInput.refusal_message()
"Not a duration. Try 30s, 15m, 1h30m, 2d, 3d8h."

set?(value)

@spec set?(term()) :: boolean()

True when a value belongs in config at all - false exactly for the reading that omits the key.

iex> StatifierBlocks.DurationInput.set?("2d")
true

iex> StatifierBlocks.DurationInput.set?("")
false