ReminderPicker
Today, tomorrow, next week or custom, repeating, with a plain sentence of what was set.
Demo
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/reminder_picker.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/reminder_picker.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/reminder_picker.qwe
component ReminderPickerDemoLive() { state got = "" state last = "" state cleared = false <div.demo-stack> <ReminderPicker id="reminder-demo" today="2026-11-01" now="10:20" about="Pay the rent" on_set={(r) => { got = `${r.at} | ${r.sentence} | ${join(r.next, " ")}` }} on_change={(r) => { last = r != null ? r.sentence : "cannot be set" }}/> <p.demo-note id="reminder-got">{got}</p> <p.demo-note id="reminder-last">{last}</p> // Twelve-hour, late in the day, and given a value; and bad data. Radio groups stay apart. <div id="reminder-late"><ReminderPicker id="reminder-two" today="2026-02-27" now="23:10" hour12={true} value={{ date: "2026-02-28", time: "13:30", rule: { unit: "week", every: 2, days: [2, 6], ends: "after", times: 4 } }} on_clear={() => { cleared = true }}/></div> <p.demo-note id="reminder-cleared">{cleared ? "cleared" : ""}</p> <div id="reminder-bad" hidden="true"><ReminderPicker id="reminder-three" today="nope" now="99:99" time="later" value={{ date: "2026-02-30", rule: "no" }}/></div> </div>}view ReminderPickerDemo() = <div.demo-stack><ReminderPickerDemoLive/></div>Source
ui/reminder_picker.qwe
// ReminderPicker: when to be reminded. Later today, tomorrow, next week or a day of one's own,// at a time, once or again and again, and it says in a plain sentence what will happen,// with the next few days, so nobody has to work out what "every 2 weeks on Monday" comes to.// A monthly reminder on the 31st falls on the last day of a shorter month, and says so.// It picks; the reminder itself is the program's to send, by quilloNative.notify or a// `schedule` job, from what on_set gives it. On a tablet the day and the repeat stand side by side.//// component Bill() {// <ReminderPicker id="rent" today="2026-11-01" now="10:20" about="Pay the rent"// on_set={(r) => { save_reminder(r) }}/>// }//// id: a prefix for its ids and radio group, one per picker on a page ("reminder"). today:// "YYYY-MM-DD", now: "HH:MM"; left out, the browser's when it starts. about: what it is for, shown// at the top. time: the time a day starts with ("09:00"). hour12: 9:00 AM rather than 9:00 (false).// value: a reminder to start from, as on_set gave it. button: what Set says ("Set reminder"). class.// on_set(reminder): inside one of your components, { date, time, at ("YYYY-MM-DDTHH:MM", local),// rule, sentence, next }. rule is null, or { unit: "day", "weekdays", "week", "month" or "year",// every, days (for a week: 1 for Monday to 7 for Sunday), ends: "never", "on" or "after", until, times }.// next: the first three dates. on_change(reminder): the same at each change, null while it// cannot be set. on_clear(): shown with a value, the Remove button was pressed.import ui/theme.qwe use ui_clslet reminder_picker_css = css`.ui-reminder { container-type: inline-size; color: var(--ui-fg); font: 15px/1.4 var(--ui-font); }.ui-reminder-inner { display: grid; gap: 14px; }.ui-reminder-about { margin: 0; font-size: 17px; font-weight: 600; overflow-wrap: anywhere; }.ui-reminder-part { display: grid; gap: 10px; min-width: 0; margin: 0; padding: 0; border: 0; }.ui-reminder-part legend { padding: 0; margin-bottom: 6px; color: var(--ui-muted); font-size: 13px; font-weight: 600; }.ui-reminder-choices { display: grid; overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }.ui-reminder-choice { position: relative; display: flex; align-items: center; gap: 10px; min-height: 56px; padding: 6px 14px; cursor: pointer; -webkit-tap-highlight-color: transparent; }.ui-reminder-choice + .ui-reminder-choice { border-top: 1px solid var(--ui-border); }.ui-reminder-choice input { flex: none; width: 20px; height: 20px; margin: 0; accent-color: var(--ui-accent); }.ui-reminder-choice:has(input:disabled) { color: var(--ui-muted); cursor: default; }.ui-reminder-choice:has(input:focus-visible) { box-shadow: inset var(--ui-focus); }.ui-reminder-name { flex: 1; min-width: 0; font-weight: 500; }.ui-reminder-hint { flex: none; color: var(--ui-muted); font-size: 14px; }.ui-reminder-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }.ui-reminder-field { display: grid; gap: 4px; min-width: 0; color: var(--ui-muted); font-size: 13px; }.ui-reminder-input { box-sizing: border-box; width: 100%; min-width: 0; min-height: 46px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 16px/1.2 var(--ui-font); appearance: none; -webkit-appearance: none; }select.ui-reminder-input { appearance: auto; -webkit-appearance: auto; }.ui-reminder-input:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-reminder-step { display: flex; align-items: center; gap: 4px; }.ui-reminder-step button { display: grid; flex: none; place-items: center; width: 46px; height: 46px; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 20px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-reminder-step button:disabled { color: var(--ui-strong); cursor: default; }.ui-reminder-step span { flex: 1; min-width: 44px; color: var(--ui-fg); font-size: 16px; text-align: center; }.ui-reminder-step button:focus-visible, .ui-reminder-day:focus-visible, .ui-reminder-set:focus-visible, .ui-reminder-clear:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-reminder-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }.ui-reminder-day { min-width: 0; min-height: 46px; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 13px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-reminder-day[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-reminder-say { margin: 0; padding: 12px 14px; border-radius: var(--ui-radius); background: var(--ui-accent-soft); overflow-wrap: anywhere; }.ui-reminder-say[data-bad] { background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-reminder-next { margin: 6px 0 0; color: var(--ui-muted); font-size: 13px; }.ui-reminder-next:empty { display: none; }.ui-reminder-end { display: flex; flex-wrap: wrap; gap: 8px; }.ui-reminder-set, .ui-reminder-clear { flex: 1; min-height: 46px; padding: 0 16px; border: 0; border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 15px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-reminder-set:disabled { background: var(--ui-subtle); color: var(--ui-muted); cursor: default; }.ui-reminder-clear { flex: 0 0 auto; background: var(--ui-danger-soft); color: var(--ui-danger); }/* A tablet has the room to show both questions at once. */@container (min-width: 600px) { .ui-reminder-inner { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 24px; align-items: start; } .ui-reminder-about, .ui-reminder-told, .ui-reminder-end { grid-column: 1 / -1; } .ui-reminder-end { justify-content: flex-end; } .ui-reminder-set { flex: 0 0 auto; min-width: 200px; }}@media (hover: hover) { .ui-reminder-choice:hover { background: var(--ui-subtle); }}`let reminder_picker_weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]fn reminder_picker_pad(n: int) -> str = n < 10 ? `0${n}` : str(n)fn reminder_picker_time_ok(t) -> bool = type_of(t) == "str" && re.test(t, "^([01][0-9]|2[0-3]):[0-5][0-9]$")fn reminder_picker_minutes(t: str) -> int = int(slice(t, 0, 2)) * 60 + int(slice(t, 3, 5))// A full hour at least one hour off, or null when that runs past 23:00.fn reminder_picker_later(now: str) { let m = reminder_picker_minutes(now) + 60 let up = m % 60 == 0 ? m : m + 60 - m % 60 up > 1380 ? null : `${reminder_picker_pad(int(up / 60))}:00`}// 1st, 2nd, 3rd, 4th, 11th, 21st.fn reminder_picker_ordinal(n: int) -> str { let tail = n % 100 >= 11 && n % 100 <= 13 ? "th" : match n % 10 { 1 => "st" 2 => "nd" 3 => "rd" _ => "th" } `${n}${tail}`}// "9:00", or "9:00 AM" and "12:30 PM" with hour12.fn reminder_picker_clock(t: str, hour12: bool) -> str { let h = int(slice(t, 0, 2)) let m = slice(t, 3, 5) if !hour12 { return `${h}:${m}` } let shown = h % 12 == 0 ? 12 : h % 12 `${shown}:${m} ${h < 12 ? "AM" : "PM"}`}// "Monday", "Monday and Thursday", "Monday, Wednesday and Friday".fn reminder_picker_and(names: list) -> str { let n = len(names) if n == 0 { return "" } if n == 1 { return names[0] } `${join(slice(names, 0, n - 1), ", ")} and ${names[n - 1]}`}fn reminder_picker_day_words(d: str, today: str) -> str { let n = date.days_between(today, d) let year = slice(d, 0, 4) != slice(today, 0, 4) ? date.format(d, "dddd D MMMM YYYY") : date.format(d, "dddd D MMMM") if n == 0 { return "today" } if n == 1 { return `tomorrow, ${year},` } `on ${year}`}fn reminder_picker_repeat_words(rule, start: str) -> str { if rule == null { return "" } let n = rule.every let day = int(slice(start, 8, 10)) let body = match rule.unit { "day" => n == 1 ? "every day" : `every ${n} days` "weekdays" => "every weekday" "week" => { let names = rule.days | map((d) => reminder_picker_weekdays[d - 1]) `${n == 1 ? "every week" : `every ${n} weeks`} on ${reminder_picker_and(names)}` } "month" => { let spot = `on the ${reminder_picker_ordinal(day)}` let tail = day > 28 ? ", or the last day of a shorter month" : "" `${n == 1 ? "every month" : `every ${n} months`} ${spot}${tail}` } _ => { let leap = slice(start, 5, 10) == "02-29" let spot = date.format(start, "D MMMM") `${n == 1 ? "every year" : `every ${n} years`} on ${spot}${leap ? ", or 28 February in other years" : ""}` } } body}fn reminder_picker_end_words(rule, today: str) -> str { if rule == null || rule.ends == "never" { return "" } if rule.ends == "on" { let same = slice(rule.until, 0, 4) == slice(today, 0, 4) return `, until ${date.format(rule.until, same ? "D MMMM" : "D MMMM YYYY")}` } rule.times == 1 ? ", once only" : `, ${rule.times} times in all`}// The sentence for a reminder.fn reminder_picker_sentence(d: str, t: str, rule, today: str, hour12: bool) -> str { let first = `${reminder_picker_day_words(d, today)} at ${reminder_picker_clock(t, hour12)}` let again = reminder_picker_repeat_words(rule, d) let tail = again != "" ? `, then ${again}` : "" `Reminds you ${first}${tail}${reminder_picker_end_words(rule, today)}.`}// The dates it goes off, the first `count`, past none of the rule's ends.fn reminder_picker_dates(start: str, rule, count: int) -> list { if rule == null { return [start] } let limit = rule.ends == "after" ? min([count, rule.times]) : count let stop = rule.ends == "on" ? rule.until : "9999-12-31" let out = [] let i = 0 // A rule can pass many days between dates, but never many hundred. while len(out) < limit && i < 1500 { let d = reminder_picker_nth(start, rule, i) if d == null || d > stop { return out } if d != "" { out = out + [d] } i += 1 } out}// The i-th candidate of a rule: a date, "" for one that does not count, null past the end of the calendar.fn reminder_picker_nth(start: str, rule, i: int) { let y = int(slice(start, 0, 4)) if y + (rule.unit == "year" ? i * rule.every : 0) > 9000 { return null } if rule.unit == "day" { return date.add_days(start, i * rule.every) } if rule.unit == "weekdays" { let d = date.add_days(start, i) return date.weekday(d) <= 5 ? d : "" } if rule.unit == "month" { return date.add_months(start, i * rule.every) } if rule.unit == "year" { return date.add_years(start, i * rule.every) } // Weeks: i counts days from the Monday of the first week, and only every n-th week has any. let monday = date.add_days(start, 1 - date.weekday(start)) let d = date.add_days(monday, i) let week = int(i / 7) if week % rule.every != 0 || d < start { return "" } contains(rule.days, date.weekday(d)) ? d : ""}// The rule from the fields, or null for no repeat.fn reminder_picker_rule(unit: str, every: int, days: list, ends: str, until: str, times: str, start: str) { if unit == "none" { return null } let wd = date.weekday(start) let week_days = sort(unique(len(days) > 0 ? days : [wd])) let count = re.test(times, "^[0-9]{1,3}$") ? int(times) : 0 { unit, every: unit == "weekdays" ? 1 : every, days: unit == "week" ? week_days : [], ends, until: ends == "on" ? until : null, times: ends == "after" ? count : null }}// What stops it being set, in words, or "".fn reminder_picker_problem(d, t, rule, today: str, now: str) -> str { if d == null || !date.valid(d, "date") { return "Pick a day." } if !reminder_picker_time_ok(t) { return "Pick a time." } if d < today || (d == today && t <= now) { return "That time has gone. Pick a later one." } if d > "2999-12-31" { return "Pick a day before the year 3000." } if rule == null { return "" } if rule.ends == "on" && (rule.until == null || !date.valid(rule.until, "date")) { return "Pick the day it ends." } if rule.ends == "on" && rule.until < d { return "It ends before the first reminder." } if rule.ends == "after" && (rule.times < 1 || rule.times > 999) { return "Say how many times, from 1 to 999." } ""}// Which answer a day means, for a value given back.fn reminder_picker_rule_of(value) { let r = get(value, "rule") if type_of(r) != "map" { return { unit: "none", every: 1, days: [], ends: "never", until: "", times: "5" } } { unit: contains(["day", "weekdays", "week", "month", "year"], get(r, "unit")) ? r.unit : "none", every: type_of(get(r, "every")) == "int" ? r.every : 1, days: type_of(get(r, "days")) == "list" ? r.days : [], ends: contains(["on", "after"], get(r, "ends")) ? r.ends : "never", until: type_of(get(r, "until")) == "str" ? r.until : "", times: get(r, "times") != null ? str(r.times) : "5", }}component ReminderPicker(id: str = "reminder", today: str = "", now: str = "", about: str? = null, time: str = "09:00", hour12: bool = false, value = null, button: str = "Set reminder", on_set = null, on_change = null, on_clear = null, class: str? = null) { style global reminder_picker_css let given = type_of(value) == "map" && type_of(get(value, "date")) == "str" && date.valid(value.date, "date") ? value : null let saved = reminder_picker_rule_of(given != null ? given : {}) state day = date.valid(today, "date") ? today : date.today() state clock = reminder_picker_time_ok(now) ? now : "00:00" state choice = given != null ? "custom" : "tomorrow" state picked = given != null ? given.date : "" state hours = given != null && reminder_picker_time_ok(get(given, "time")) ? given.time : "" // the time, once someone has chosen one state unit = saved.unit state every = saved.every state days = saved.days state ends = saved.ends state until = saved.until state times = saved.times state told = "" let base_time = reminder_picker_time_ok(time) ? time : "09:00" let later = reminder_picker_later(clock) let tomorrow = date.add_days(day, 1) // On a Sunday the next Monday is tomorrow, already offered, so it is the one after. let monday = date.add_days(day, date.weekday(day) == 7 ? 8 : 8 - date.weekday(day)) let when = match choice { "later" => day "tomorrow" => tomorrow "week" => monday _ => picked != "" ? picked : null } let at = hours != "" ? hours : choice == "later" && later != null ? later : base_time let start_ok = when != null && date.valid(when, "date") let rule = start_ok ? reminder_picker_rule(unit, every, days, ends, until, times, when) : null let problem = reminder_picker_problem(when, at, rule, day, clock) let first_days = problem == "" ? reminder_picker_dates(when, rule, 3) : [] let real_start = len(first_days) > 0 ? first_days[0] : when let sentence = problem == "" ? reminder_picker_sentence(real_start, at, rule, day, hour12) : "" let result = () => problem != "" ? null : { date: real_start, time: at, at: `${real_start}T${at}`, rule, sentence, next: first_days } let told_up = () => { told = "" on_change && on_change(result()) } let name = `${id}-when` let hint_day = (d: str) => date.format(d, "ddd D MMM") <form class={ui_cls(["ui-reminder", class])} id={id} autocomplete="off" @mount={(_me) => { // Asked first: nothing here may read the clock half set. let t = js_new("Date") let y = t.getFullYear() let mo = t.getMonth() + 1 let dd = t.getDate() let hh = t.getHours() let mi = t.getMinutes() if !date.valid(today, "date") { day = date.of(y, mo, dd) } if !reminder_picker_time_ok(now) { clock = `${reminder_picker_pad(hh)}:${reminder_picker_pad(mi)}` } }} @submit={(e) => { e.preventDefault() let r = result() if r != null { told = "Set." on_set && on_set(r) } }}> <div.ui-reminder-inner> {about != null && <p.ui-reminder-about>{about}</p>} <fieldset.ui-reminder-part> <legend>When</legend> <div.ui-reminder-choices> <label.ui-reminder-choice> <input type="radio" name={name} value="later" checked={choice == "later"} disabled={later == null ? true : null} @change={() => { choice = "later"; told_up() }}/> <span.ui-reminder-name>Later today</span> <span.ui-reminder-hint>{later != null ? reminder_picker_clock(later, hour12) : "Too late today"}</span> </label> <label.ui-reminder-choice> <input type="radio" name={name} value="tomorrow" checked={choice == "tomorrow"} @change={() => { choice = "tomorrow"; told_up() }}/> <span.ui-reminder-name>Tomorrow</span> <span.ui-reminder-hint>{hint_day(tomorrow)}</span> </label> <label.ui-reminder-choice> <input type="radio" name={name} value="week" checked={choice == "week"} @change={() => { choice = "week"; told_up() }}/> <span.ui-reminder-name>Next week</span> <span.ui-reminder-hint>{hint_day(monday)}</span> </label> <label.ui-reminder-choice> <input type="radio" name={name} value="custom" checked={choice == "custom"} @change={() => { choice = "custom"; told_up() }}/> <span.ui-reminder-name>Pick a day</span> <span.ui-reminder-hint>{picked != "" && date.valid(picked, "date") ? hint_day(picked) : ""}</span> </label> </div> <div.ui-reminder-fields> {choice == "custom" && <label.ui-reminder-field for={`${id}-date`}>Day <input.ui-reminder-input id={`${id}-date`} type="date" min={day} value={picked} @input={(e) => { let v = e.value; picked = v; told_up() }}/> </label>} <label.ui-reminder-field for={`${id}-time`}>Time <input.ui-reminder-input id={`${id}-time`} type="time" value={at} @input={(e) => { let v = e.value; hours = v; told_up() }}/> </label> </div> </fieldset> <fieldset.ui-reminder-part> <legend>Repeat</legend> <label.ui-reminder-field for={`${id}-unit`}>How often <select.ui-reminder-input id={`${id}-unit`} value={unit} @change={(e) => { let v = e.value; unit = v; every = 1; days = []; told_up() }}> <option value="none" selected={unit == "none"}>Once</option> <option value="day" selected={unit == "day"}>Daily</option> <option value="weekdays" selected={unit == "weekdays"}>Every weekday</option> <option value="week" selected={unit == "week"}>Weekly</option> <option value="month" selected={unit == "month"}>Monthly</option> <option value="year" selected={unit == "year"}>Yearly</option> </select> </label> {(unit == "day" || unit == "week" || unit == "month" || unit == "year") && <div.ui-reminder-field>Every <div.ui-reminder-step role="group" aria-label="How many between each"> <button type="button" aria-label="Fewer" disabled={every <= 1 ? true : null} @click={() => { every = max([1, every - 1]); told_up() }}>−</button> <span aria-live="polite">{every} {unit}{every == 1 ? "" : "s"}</span> <button type="button" aria-label="More" disabled={every >= 99 ? true : null} @click={() => { every = min([99, every + 1]); told_up() }}>+</button> </div> </div>} {unit == "week" && <div.ui-reminder-days role="group" aria-label="On which days"> {for [i, n] in enumerate(reminder_picker_weekdays) => <button.ui-reminder-day type="button" key={n} aria-label={n} aria-pressed={contains(rule != null ? rule.days : [], i + 1) ? "true" : "false"} @click={() => { let now_days = rule != null ? rule.days : [] let d = i + 1 days = contains(now_days, d) ? now_days | filter((x) => x != d) : now_days + [d] told_up() }}>{slice(n, 0, 3)}</button>} </div>} {unit != "none" && <div.ui-reminder-fields> <label.ui-reminder-field for={`${id}-ends`}>Ends <select.ui-reminder-input id={`${id}-ends`} value={ends} @change={(e) => { let v = e.value; ends = v; told_up() }}> <option value="never" selected={ends == "never"}>Never</option> <option value="on" selected={ends == "on"}>On a day</option> <option value="after" selected={ends == "after"}>After some times</option> </select> </label> {ends == "on" && <label.ui-reminder-field for={`${id}-until`}>Last day <input.ui-reminder-input id={`${id}-until`} type="date" min={day} value={until} @input={(e) => { let v = e.value; until = v; told_up() }}/> </label>} {ends == "after" && <label.ui-reminder-field for={`${id}-times`}>Times <input.ui-reminder-input id={`${id}-times`} type="text" inputmode="numeric" value={times} @input={(e) => { let v = e.value; times = v; told_up() }}/> </label>} </div>} </fieldset> <div.ui-reminder-told> <p.ui-reminder-say role="status" data-bad={problem != "" ? "" : null}>{problem != "" ? problem : sentence}</p> <p.ui-reminder-next>{len(first_days) > 1 ? `Next: ${join(first_days | map((d) => hint_day(d)), ", ")}` : ""}{told != "" ? ` ${told}` : ""}</p> </div> <div.ui-reminder-end> {given != null && <button.ui-reminder-clear type="button" @click={() => { on_clear && on_clear() }}>Remove</button>} <button.ui-reminder-set type="submit" disabled={problem != "" ? true : null}>{button}</button> </div> </div> </form>}Related
QuickAddBarType a line and it finds the date, list and priority in the words, marked as you go.3 views · 7 downloadsInspectionFormA checklist of pass, fail and N/A with big targets, a note and stamped photo needed on a fail, a score, and a draft kept.2 views · 5 downloadsSendMoneyFlowPayee, amount, note and a last look, keeping what was typed on back, with every limit stated and a receipt.2 views · 6 downloadsSettingsListRows of toggles, values and links from data, with a search across them, and on_change(key, value).3 views · 6 downloadsTaskRowA task with a round check, swipes for its due day and delete, subtasks, and a smooth move when finished.3 views · 7 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloads
Thread
Thread 0 posts
On ReminderPicker: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.