Skip to main content

max / goingson

take the placeholder and the options from makeover-layout Both moved onto Field in makeover-layout 0.8.0, so they land in a different struct on the way in. The JSON the frontend sends is unchanged.
Author: Max Johnson <me@maxj.phd> · 2026-08-06 18:49 UTC
Signed with PGP, not checked
Commit: f279e6d99f099fc380497b70fa379c8cdd4b21ca
Parent: 962cac5
2 files changed, +54 insertions, -23 deletions
@@ -37,6 +37,10 @@
37 37 const interval = rule?.interval || 1;
38 38 const weekdays = rule?.weekdays || [];
39 39 const monthlySpec = rule?.monthlySpec || null;
40 + // The rule carries an RFC 3339 instant; <input type="date"> takes the
41 + // date half. Empty means the series has no end, which is every rule
42 + // written before the field existed.
43 + const until = rule?.until ? rule.until.slice(0, 10) : '';
40 44
41 45 const weekdayCheckboxes = WEEKDAY_LABELS.map((label, i) => {
42 46 const checked = weekdays.includes(i) ? 'checked' : '';
@@ -89,6 +93,10 @@
89 93 </div>
90 94 </div>
91 95 </div>
96 + <div class="form-group recurrence-row">
97 + <label class="form-label recurrence-sublabel" for="${prefix}-until">Ends</label>
98 + <input type="date" class="field" id="${prefix}-until" name="${prefix}-until" value="${until}">
99 + </div>
92 100 </div>
93 101 `;
94 102 }
@@ -141,7 +149,10 @@
141 149 }
142 150 }
143 151
144 - return `Repeats every ${unit}${detail}`;
152 + const until = form.elements[`${prefix}-until`]?.value;
153 + const ends = until ? ` until ${until}` : '';
154 +
155 + return `Repeats every ${unit}${detail}${ends}`;
145 156 }
146 157
147 158 function initRecurrenceConfig(prefix, selectName) {
@@ -189,6 +200,9 @@
189 200 const monthlyDay = form.elements[`${prefix}-monthly-day`];
190 201 if (monthlyDay) monthlyDay.addEventListener('input', updatePreview);
191 202
203 + const untilInput = form.elements[`${prefix}-until`];
204 + if (untilInput) untilInput.addEventListener('input', updatePreview);
205 +
192 206 form.querySelectorAll(`[name="${prefix}-monthly-type"]`).forEach(radio => {
193 207 radio.addEventListener('change', updatePreview);
194 208 });
@@ -210,7 +224,13 @@
210 224 if (pattern === 'None') return null;
211 225
212 226 const interval = parseInt(form.elements[`${prefix}-interval`]?.value) || 1;
213 - const rule = { pattern, interval, weekdays: [], monthlySpec: null };
227 + const rule = { pattern, interval, weekdays: [], monthlySpec: null, until: null };
228 +
229 + // The date input gives a civil date with no time. End of that day, so a
230 + // series told to end on the 31st includes an occurrence on the 31st
231 + // whatever time of day it falls at.
232 + const untilDate = form.elements[`${prefix}-until`]?.value;
233 + if (untilDate) rule.until = `${untilDate}T23:59:59Z`;
214 234
215 235 if (pattern === 'Weekly') {
216 236 for (let i = 0; i < 7; i++) {
@@ -20,9 +20,9 @@
20 20
21 21 use std::collections::HashMap;
22 22
23 - use makeover_layout::{Field, FieldKind};
23 + use makeover_layout::{Choice, Field, FieldKind};
24 24 use makeover_webview::Emit;
25 - use makeover_webview::form::{Choice, Filling, Markup, Value, field_html};
25 + use makeover_webview::form::{Filling, Markup, Value, field_html};
26 26 use serde::Deserialize;
27 27 use tracing::{instrument, warn};
28 28
@@ -47,9 +47,15 @@
47 47
48 48 /// One field, as the frontend sends it.
49 49 ///
50 - /// A mirror of [`Field`] plus everything the description deliberately does not
51 - /// carry: the current value, the placeholder, the select's options. Those are
52 - /// renderer-side state and arrive in [`Filling`].
50 + /// A mirror of [`Field`] plus the one thing the description deliberately does
51 + /// not carry, the current value, which arrives in [`Filling`].
52 + ///
53 + /// It used to be three things. The placeholder and a select's options were
54 + /// renderer-side until makeover-layout 0.8.0 moved both onto [`Field`], so they
55 + /// still arrive in the same JSON and simply land in a different struct. The
56 + /// wire shape the frontend sends is unchanged by that, and deliberately: this
57 + /// type is the boundary, and a description reorganising itself is not a reason
58 + /// to make fifteen call sites in `js/` send something new.
53 59 #[derive(Debug, Deserialize)]
54 60 #[serde(rename_all = "camelCase")]
55 61 pub struct FieldSpec {
@@ -161,17 +167,7 @@
161 167 for spec in &fields {
162 168 let kind = kind_of(&spec.kind);
163 169
164 - let field = Field {
165 - kind,
166 - name: &spec.name,
167 - label: &spec.label,
168 - hint: spec.hint.as_deref(),
169 - error: spec.error.as_deref(),
170 - required: spec.required,
171 - extended: spec.extended,
172 - };
173 -
174 - // Built here rather than inline so the borrow outlives the Value that
170 + // Built here rather than inline so the borrow outlives the Field that
175 171 // points into it.
176 172 let choices: Vec<Choice<'_>> = spec
177 173 .options
@@ -184,12 +180,28 @@
184 180 })
185 181 .collect();
186 182
183 + let field = Field {
184 + kind,
185 + name: &spec.name,
186 + label: &spec.label,
187 + hint: spec.hint.as_deref(),
188 + error: spec.error.as_deref(),
189 + // Both moved onto the description in makeover-layout 0.8.0. They
190 + // arrived in `Filling` until then, which is why this function used
191 + // to assemble the field and its filling from the same spec in two
192 + // places.
193 + placeholder: spec.placeholder.as_deref(),
194 + options: &choices,
195 + required: spec.required,
196 + extended: spec.extended,
197 + };
198 +
187 199 let value = match kind {
188 200 FieldKind::Checkbox => Value::On(spec.checked.unwrap_or_default()),
189 - FieldKind::Select => Value::Chosen {
190 - options: &choices,
191 - value: spec.current_value(),
192 - },
201 + // A select's value is the value of one of its options and the
202 + // options are on the field now, so it takes the same variant
203 + // everything else typed does.
204 + FieldKind::Select => Value::Text(spec.current_value()),
193 205 _ => match spec.value.as_deref() {
194 206 Some(text) => Value::Text(text),
195 207 None => Value::Absent,
@@ -198,7 +210,6 @@
198 210
199 211 let filling = Filling {
200 212 value,
201 - placeholder: spec.placeholder.as_deref(),
202 213 trailing: spec.trailing_html.as_deref().map(Markup),
203 214 id_prefix: id_prefix.as_deref(),
204 215 };