The :duration control's reading of the text an author typed.
ADR-0005 decision 9, as amended 2026-08-29 (the :duration control,
predicator strings primary), renders :duration as one text control:
| Field type | Rendering |
|---|---|
:duration | one text control; predicator duration strings primary, 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 four 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 noPT0Sand no third state for "the author touched this and then did not finish", so both read:emptyand both leave the key out of config.StatifierBlocks.Editor.ConfigFormis where the omission is performed, because that is the module that decides where a decoded value is written.:predicator- a duration string a person types (1h30m,3d8h), which the amendment makes the primary input.:iso- ISO-8601 (PT1H30M), still accepted because it is the spelling ADR-0001 decision 6 already admits into config and the one existing documents hold. A field that refused it would refuse values already written.:invalid- carries the sentence the field shows instead of offering an edit at all.
The two valid readings differ only in what the author typed. The
stored form is the author's string verbatim - nothing here
canonicalises on the way in, and iso is a projection for the caller
that wants one, not a value to write back.
Acceptance is StatifierBlocks.Core.Duration, exactly
This module decides nothing about which strings are durations. It asks
StatifierBlocks.Core.Duration.to_iso/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 -
milliseconds, fractional components and repeated units alike. Two of
those three refusals do not survive the amendment. A fraction that
normalises into whole ISO components (1.5h is PT1H30M) and a
repeated unit that accumulates (3h2h is PT5H) are both things the
amendment names as predicator's to define, and Core.Duration compiles
both, so refusing them here would be the second opinion. Milliseconds
are the one refusal that stays, because it is a fact about the ISO
pivot rather than a reading of the grammar: ISO-8601 has no millisecond
component, so 500ms and 1.5s have no canonical form to compile to.
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.
Why the refusal is three sentences and not one
"Not a duration" is true of soon and of 500ms alike, and the second
is a person who knows the grammar hitting the pivot's limit. Telling
them which of the two they hit is the difference between a form that
teaches and a form that sulks. The three messages are this module's;
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; iso is the
canonical projection of a valid reading and "" 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 primary spelling.
Reads one stored or typed duration.
True when a value belongs in config at all - false exactly for the
reading that omits the key.
Types
@type reading() :: %{ form: :empty | :predicator | :iso | :invalid, iso: String.t(), message: String.t() }
One typed duration, read. form is the whole decision; iso is the
canonical projection of a valid reading and "" otherwise; message is
the sentence the field shows and is "" for every reading but
:invalid.
Functions
@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"]
@spec placeholder() :: String.t()
The placeholder the empty control carries - one example, in the primary spelling.
iex> StatifierBlocks.DurationInput.placeholder()
"1h30m"
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, iso: "", message: ""}
iex> StatifierBlocks.DurationInput.read("1h30m")
%{form: :predicator, iso: "PT1H30M", message: ""}
iex> StatifierBlocks.DurationInput.read("PT1H30M")
%{form: :iso, iso: "PT1H30M", message: ""}
iex> StatifierBlocks.DurationInput.read("500ms").message
"Milliseconds are not stored here - the smallest unit is a second."
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