Skip to content
yarunoka.dev

Reference

View Markdown

createYrnkBuilder(options?: YrnkBuilderOptions): YrnkBuilder

The store — the one stateful object of this package. One draft, one “something changed” signal: finer subscription granularity is a binding’s concern, and this minimal contract is exactly what React’s useSyncExternalStore consumes in one line.

The derived values are pure over (snapshot, resolvers), so the expensive one — the exit through core’s parse — is memoized per that pair. Swapping resolvers replaces the snapshot identity even though no field changed: the derived values change, and identity is the signal subscribers watch.

type BuilderOp =
// document
| { readonly type: 'document/set-label'; readonly value: string }
| { readonly type: 'document/set-description'; readonly value: string }
| { readonly type: 'document/set-timezone'; readonly value: string }
// bulk
| { readonly type: 'document/replace'; readonly draft: DraftDocument }
| { readonly type: 'document/load'; readonly document: YrnkDocument }
// resolvers
| { readonly type: 'resolvers/add' }
| { readonly type: 'resolvers/set'; readonly id: DraftId; readonly name: string }
| { readonly type: 'resolvers/remove'; readonly id: DraftId }
// calendar — the three built-in date-list positions
| {
readonly type: 'calendar/set-date-set-mode';
readonly target: DateSetTarget;
readonly mode: 'unset' | 'list' | 'name';
}
| {
readonly type: 'calendar/set-date-set-name';
readonly target: DateSetTarget;
readonly name: string;
}
| { readonly type: 'calendar/add-date'; readonly target: DateSetTarget }
| {
readonly type: 'calendar/set-date';
readonly target: DateSetTarget;
readonly id: DraftId;
readonly date: string;
}
| { readonly type: 'calendar/remove-date'; readonly target: DateSetTarget; readonly id: DraftId }
// calendar — workweek and business hours
| { readonly type: 'calendar/set-workweek'; readonly days: readonly YrnkDayName[] }
| { readonly type: 'calendar/add-business-hours-window' }
| {
readonly type: 'calendar/set-business-hours-window';
readonly id: DraftId;
readonly start: string;
readonly end: string;
}
| { readonly type: 'calendar/remove-business-hours-window'; readonly id: DraftId }
// calendar — the date_sets namespace
| { readonly type: 'calendar/add-date-set' }
| { readonly type: 'calendar/rename-date-set'; readonly id: DraftId; readonly name: string }
| { readonly type: 'calendar/remove-date-set'; readonly id: DraftId }
| { readonly type: 'calendar/add-date-set-date'; readonly setId: DraftId }
| {
readonly type: 'calendar/set-date-set-date';
readonly setId: DraftId;
readonly id: DraftId;
readonly date: string;
}
| {
readonly type: 'calendar/remove-date-set-date';
readonly setId: DraftId;
readonly id: DraftId;
}
// schedules list
| { readonly type: 'schedules/add' }
| { readonly type: 'schedules/remove'; readonly id: DraftId }
| { readonly type: 'schedules/move'; readonly id: DraftId; readonly to: number }
// one schedule
| { readonly type: 'schedule/set-label'; readonly scheduleId: DraftId; readonly value: string }
| {
readonly type: 'schedule/set-description';
readonly scheduleId: DraftId;
readonly value: string;
}
| { readonly type: 'schedule/set-from'; readonly scheduleId: DraftId; readonly value: string }
| { readonly type: 'schedule/set-until'; readonly scheduleId: DraftId; readonly value: string }
| { readonly type: 'schedule/add-year'; readonly scheduleId: DraftId }
| {
readonly type: 'schedule/set-year';
readonly scheduleId: DraftId;
readonly id: DraftId;
readonly value: string;
}
| { readonly type: 'schedule/remove-year'; readonly scheduleId: DraftId; readonly id: DraftId }
| { readonly type: 'schedule/add-month'; readonly scheduleId: DraftId }
| {
readonly type: 'schedule/set-month';
readonly scheduleId: DraftId;
readonly id: DraftId;
readonly value: string;
}
| { readonly type: 'schedule/remove-month'; readonly scheduleId: DraftId; readonly id: DraftId }
| { readonly type: 'schedule/add-day-atom'; readonly scheduleId: DraftId }
| {
readonly type: 'schedule/set-day-atom';
readonly scheduleId: DraftId;
readonly id: DraftId;
readonly atom: DraftDayAtom;
}
| {
readonly type: 'schedule/remove-day-atom';
readonly scheduleId: DraftId;
readonly id: DraftId;
}
| {
readonly type: 'schedule/set-shift';
readonly scheduleId: DraftId;
readonly shift: DraftShift | null;
}
| { readonly type: 'schedule/set-if'; readonly scheduleId: DraftId; readonly if: DraftIf | null }
| {
readonly type: 'schedule/set-time-kind';
readonly scheduleId: DraftId;
readonly kind: DraftTimeSpec['kind'];
}
| { readonly type: 'schedule/add-time'; readonly scheduleId: DraftId }
| {
readonly type: 'schedule/set-time';
readonly scheduleId: DraftId;
readonly id: DraftId;
readonly value: string;
}
| { readonly type: 'schedule/remove-time'; readonly scheduleId: DraftId; readonly id: DraftId }
| {
readonly type: 'schedule/set-grid-every';
readonly scheduleId: DraftId;
readonly count: string;
readonly unit: YrnkTimeUnit | null;
}
| {
readonly type: 'schedule/set-grid-between';
readonly scheduleId: DraftId;
readonly between: DraftBetween;
}
| {
readonly type: 'schedule/set-sequence-every';
readonly scheduleId: DraftId;
readonly count: string;
readonly unit: YrnkTimeUnit | null;
};

The closed set of editing operations. An op writes the draft and nothing else — no validation, no refusal: the draft accepts what was typed, validity is derived afterwards (errors / optionsAt), and “don’t offer that” is the UI’s move, informed by optionsAt. Two classes of caller bug throw instead of writing: addressing an id that does not exist, and aiming a form-specific op at another form (a date-list op at a position not in list mode, a times / grid / sequence op at another time form).

Granularity: lists whose elements carry ids get element-level ops (add / set / remove); small sum-typed values (an atom, shift, if, between) are set whole — the UI builds the value, the op places it.

type DateSetTarget = 'holidays' | 'business-holidays' | 'business-days';

Which built-in date-list position of the calendar an op addresses.

type DraftBetween =
| { readonly kind: 'whole-day' }
| { readonly kind: 'window'; readonly start: string; readonly end: string }
| { readonly kind: 'business-hour' };

The grid’s between position — a closed choice among the whole day, an explicit window, and the business_hour word, so no null form is needed.

type DraftCalendar = {
readonly holidays: DraftDateSetPosition;
readonly businessHolidays: DraftDateSetPosition;
readonly businessDays: DraftDateSetPosition;
/** [] reads as "key omitted" (the default Mon–Fri workweek) */
readonly workweek: readonly YrnkDayName[];
/** [] reads as "key omitted" */
readonly businessHours: readonly DraftEntry<{ readonly start: string; readonly end: string }>[];
readonly dateSets: readonly DraftDateSetEntry[];
};

The calendar under edit: the three built-in date-list positions, the workweek, business hours, and the open date_sets namespace.

type DraftDateSetEntry = {
readonly id: DraftId;
readonly name: string;
readonly dates: readonly DraftEntry<string>[];
};

An entry of date_sets. The model holds a Record, but a draft holds a list of named entries: renaming is then an in-place text edit, and a duplicated name is representable mid-edit (parse rejects it at the exit).

type DraftDateSetPosition =
| { readonly mode: 'unset' }
| { readonly mode: 'list'; readonly dates: readonly DraftEntry<string>[] }
| { readonly mode: 'name'; readonly name: string };

A built-in date-list position (holidays / business_holidays / business_days). An explicit empty list is a meaningful statement (“there are no such days”), so absence needs its own mode rather than the empty-equals-omitted reading.

type DraftDayAtom =
| { readonly kind: null }
| { readonly kind: 'month-day'; readonly day: string }
| { readonly kind: 'weekday'; readonly day: YrnkDayName | null }
| {
readonly kind: 'ordinal-weekday';
readonly ordinal: YrnkOrdinal | null;
readonly day: YrnkDayName | null;
}
| { readonly kind: 'last-day-of-month' }
| { readonly kind: 'calendar-word'; readonly word: YrnkCalendarWord | null }
| { readonly kind: 'name'; readonly name: string }
| { readonly kind: 'day-cycle'; readonly interval: string };

One atom of the days enumeration: the model’s forms with their leaves loosened, plus kind: null while the form is not chosen yet.

type DraftDayAtomEntry = { readonly id: DraftId; readonly atom: DraftDayAtom };

A days-list element wrapped for identity — the day atom’s counterpart of DraftEntry.

type DraftDayCondition = Exclude<DraftDayAtom, { kind: 'day-cycle' }>;

A day atom legal as a shift / if condition — every form except the day cycle.

type DraftDocument = {
/** '' reads as "key omitted" */
readonly label: string;
readonly description: string;
/** Always written out: a document cannot omit its timezone */
readonly timezone: string;
/** [] reads as "key omitted" */
readonly resolvers: readonly DraftEntry<string>[];
readonly calendar: DraftCalendar;
/** [] is spellable but invalid; parse rejects it at the exit */
readonly schedules: readonly DraftSchedule[];
};

The draft model’s root — the document model of @yarunoka/core mirrored with every node loosened to tolerate work in progress. Three loosenings and nothing else:

  • A freely-typed leaf (a time, a date, a boundary, an integer, a name, the timezone, an annotation) holds whatever string was typed. Validation is derived at read time; the draft never stores its own error state.

  • A closed-set leaf holds its value or null (not chosen yet), and a structural choice (the time form, the day atom form) holds kind: null the same way.

  • “Absent” splits by whether the document could spell it: where an empty spelling is invalid (annotations, boundaries, the axes, the resolvers list, workweek, business hours), the empty string / empty list reads as “key omitted”; where an explicit empty list is a meaningful statement (a date list), an explicit mode tells the forms apart.

type DraftEntry<T> = { readonly id: DraftId; readonly value: T };

A list element wrapped for identity: the id names the slot, not the value.

type DraftEveryTuple = {
readonly count: string;
readonly unit: YrnkTimeUnit | null;
};

The every tuple under edit: the count as typed, and the unit or null.

type DraftId = string;

Opaque, draft-only; the UI’s list key and the ops’ address. toYrnk drops it.

type DraftIf = {
/**
* null means "the day itself", exactly as in the model — the default
* is itself a valid choice, so "not chosen yet" needs no extra state
*/
readonly direction: YrnkDirection | null;
readonly negated: boolean;
readonly condition: DraftDayCondition;
};

The if modifier under edit — filtering without moving, as in the model.

type DraftPath = readonly string[];

Where a problem sits in the draft: field names and draft ids, outermost first (e.g. [‘schedules’, ‘s3’, ‘from’]). Ids rather than indexes, so the path survives list edits around it.

type DraftProblem = {
readonly path: DraftPath;
/** Wording comes from @yarunoka/core wherever core has the rule */
readonly message: string;
readonly origin: 'field' | 'document';
};

One validation finding. A field problem is a core validation helper’s answer placed at its draft path; a document problem is core’s parse() rejecting the whole (it carries no path — it appears only once every field is individually clean).

type DraftSchedule = {
readonly id: DraftId;
readonly label: string;
readonly description: string;
/** '' reads as "key omitted" */
readonly from: string;
readonly until: string;
/** [] reads as "axis absent" (no restriction) */
readonly years: readonly DraftEntry<string>[];
readonly months: readonly DraftEntry<string>[];
readonly days: readonly DraftDayAtomEntry[];
readonly shift: DraftShift | null;
readonly if: DraftIf | null;
readonly time: DraftTimeSpec;
};

One schedule under edit. The shape mirrors the model’s schedule — the axes, the modifiers, the time part — with the id as the schedule’s address for ops, options, and problem paths.

type DraftShift = {
readonly direction: YrnkDirection | null;
readonly orSame: boolean;
readonly condition: DraftDayCondition;
};

The shift modifier under edit. Unlike the model’s shift, direction may be null — a shift can exist before its direction is chosen.

type DraftTimeSpec =
| { readonly kind: null }
| { readonly kind: 'times'; readonly times: readonly DraftEntry<string>[] }
| { readonly kind: 'grid'; readonly every: DraftEveryTuple; readonly between: DraftBetween }
| { readonly kind: 'allday' }
| { readonly kind: 'sequence'; readonly every: DraftEveryTuple };

The time part under edit: one of the model’s four forms, or kind: null while the form is not chosen yet.

type OptionContext =
| { readonly at: 'day-atom-kind'; readonly scheduleId: DraftId }
| { readonly at: 'condition-kind'; readonly scheduleId: DraftId; readonly of: 'shift' | 'if' }
| { readonly at: 'time-kind'; readonly scheduleId: DraftId }
| { readonly at: 'between-kind'; readonly scheduleId: DraftId }
| { readonly at: 'calendar-word'; readonly scheduleId: DraftId }
| { readonly at: 'name'; readonly scheduleId: DraftId };

A decision point the UI can ask about. Every context is addressed through a schedule: the questions are asked while editing one, and the answers that depend on the document (the calendar, the names) read it through the draft as a whole.

type Options = readonly {
readonly value: string;
readonly available: boolean;
/** Why not, when available is false */
readonly reason?: string;
}[];

What a decision point offers: every value of its closed set, each marked available or not. Unavailable options are answered rather than dropped — whether to hide or to disable is the UI’s decision.

type PreviewOccurrence = {
readonly occurrence: YrnkOccurrence;
/** The draft schedules this occurrence came from */
readonly scheduleIds: readonly DraftId[];
};

One line of a preview: the occurrence as core answers it, and which draft schedules produced it.

type PreviewRequest =
| {
/** How many upcoming occurrences to answer */
readonly next: number;
/**
* The instant the answered occurrences lie strictly after; the
* current instant when omitted
*/
readonly after?: YrnkInstant;
/** How far the search may reach; ten years when omitted */
readonly horizon?: Temporal.Duration;
}
| { readonly range: { readonly from: YrnkInstant; readonly through: YrnkInstant } };

What to preview: the next N occurrences after an instant, or every occurrence in an explicit range.

type PreviewResult =
| {
readonly ok: true;
readonly occurrences: readonly PreviewOccurrence[];
/** True when the horizon ended a "next N" search short of N */
readonly exhausted: boolean;
}
| { readonly ok: false; readonly problems: readonly DraftProblem[] };

What preview() answers: the occurrences and whether the horizon cut a “next N” search short, or — when the draft cannot export — the same problems errors() reports.

type ToYrnkResult =
| { readonly ok: true; readonly document: YrnkDocument; readonly raw: Record<string, unknown> }
| { readonly ok: false; readonly problems: readonly DraftProblem[] };

The exit’s answer: the parsed document with its raw spelling when the draft exports cleanly, or the problems that keep it from the wire.

type YrnkBuilder = {
/** The current draft — an immutable snapshot */
getState(): DraftDocument;
/** Change notification; the returned function unsubscribes */
subscribe(listener: () => void): () => void;
dispatch(op: BuilderOp): void;
setResolvers(resolvers: Readonly<Record<string, YrnkResolver>>): void;
errors(): readonly DraftProblem[];
optionsAt(context: OptionContext): Options;
toYrnk(): ToYrnkResult;
preview(request: PreviewRequest): PreviewResult;
};

The store’s handle: the external-store trio (getState / subscribe / dispatch) plus the derived values an editor reads — validation, options, the strict document, and upcoming occurrences.

type YrnkBuilderOptions = {
/** The document to start editing; an empty new draft when omitted */
readonly initial?: YrnkDocument;
/** What the host binds the draft's declared resolver names to */
readonly resolvers?: Readonly<Record<string, YrnkResolver>>;
};

What createYrnkBuilder accepts: the starting document and the host’s resolver bindings.