| 1 | // The Reminders mini-app: nudge yourself about something later. From any chat, |
| 2 | // /remind Call the plumber in 2h |
| 3 | // /remind Standup at 9:00 |
| 4 | // /remind Renew passport | 2026-08-01 |
| 5 | // files a reminder into your own reminders thread (its own sidebar spot), where |
| 6 | // it lives as a normal encrypted event โ the server sees ciphertext, never the |
| 7 | // text or the time. |
| 8 | // |
| 9 | // Firing is server-scheduled (ctx.schedule): the due `remind.fire` event is |
| 10 | // encrypted NOW and held by the server, which delivers it into the thread at |
| 11 | // the right moment โ so it alerts reliably on every device, closed or open (the |
| 12 | // service worker shows the event's encrypted `notice` when closed; a live client |
| 13 | // surfaces it as a message in the Reminders thread, floated up the chat list with |
| 14 | // an unread dot). The server learns only (thread, time), never the text โ the accepted timing |
| 15 | // trade-off of real alerts. Dismissing an unfired reminder cancels its schedule |
| 16 | // via the sid recorded on remind.add, from any device. If scheduling failed |
| 17 | // (offline), the reminder has no sid and the render ticker fires it best-effort |
| 18 | // client-side, like the earliest version of this app. |
| 19 | // |
| 20 | // ๐ค The app is SCHEMA-DESCRIBED: schemas/remind.json declares the state shape, |
| 21 | // event folds (sorted soonest-first), actions (schedule-first add, dismiss), |
| 22 | // the fallback-fire ticker, and views (docs/app-schema.md); schema-engine.js |
| 23 | // folds and schema-render.js draws โ the same schema the native apps interpret |
| 24 | // with SwiftUI, so the platforms can't drift. What stays hand-written here is |
| 25 | // the glue a schema doesn't cover: the natural-language time parser (the |
| 26 | // schema's named "reminder" parser), commands, sidebar preview/unread, chat. |
| 27 | // |
| 28 | // Relative import (not "/static/apps.js") so the parser + reducer are exercisable |
| 29 | // in Node tests while still resolving to /static/apps.js in the browser. |
| 30 | import { defineApp, namespace } from "../apps.js"; |
| 31 | import { ICON_ALARM_CLOCK } from "../icons.js"; |
| 32 | import { schemaEvents, schemaInitialState, schemaRender, schemaStyles } from "../schema-render.js"; |
| 33 | import schema from "./schemas/remind.json" with { type: "json" }; |
| 34 | |
| 35 | // The schema (schemas/remind.json) is the source of truth for the `events` map |
| 36 | // keys, so there's no drift there โ but the hand-written glue below (commands, |
| 37 | // chatMessage/preview/unread) still compares against the wire type by hand. |
| 38 | // N.ev("add") โ "remind.add" keeps THOSE comparisons locked to the schema's own |
| 39 | // name instead of a second hardcoded "remind" prefix. |
| 40 | const N = namespace(schema.name); |
| 41 | |
| 42 | // ---- time parsing (pure, unit-tested) ----------------------------------- |
| 43 | |
| 44 | // Milliseconds for a relative unit word ("m"/"min"/"minutes" โ 60000, etc). |
| 45 | function unitMs(u) { |
| 46 | u = u.toLowerCase(); |
| 47 | if (/^(seconds?|secs?|s)$/.test(u)) return 1000; |
| 48 | if (/^(minutes?|mins?|m)$/.test(u)) return 60000; |
| 49 | if (/^(hours?|hrs?|h)$/.test(u)) return 3600000; |
| 50 | if (/^(days?|d)$/.test(u)) return 86400000; |
| 51 | if (/^(weeks?|w)$/.test(u)) return 604800000; |
| 52 | return 0; |
| 53 | } |
| 54 | |
| 55 | // The next wall-clock occurrence of h:m โ today if it's still ahead, else |
| 56 | // tomorrow. Built in local time so "at 9:00" means the user's 9am. |
| 57 | function nextClockTime(h, m, now) { |
| 58 | if (h > 23 || m > 59) return null; |
| 59 | const d = new Date(now); |
| 60 | d.setHours(h, m, 0, 0); |
| 61 | let t = d.getTime(); |
| 62 | if (t <= now) t += 86400000; |
| 63 | return t; |
| 64 | } |
| 65 | |
| 66 | // An explicit "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" (T or space), in local time. A |
| 67 | // bare date defaults to 09:00 so a day-only reminder lands in the morning. |
| 68 | function parseAbsolute(str) { |
| 69 | const m = str.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2}))?$/); |
| 70 | if (!m) return null; |
| 71 | const y = +m[1], mo = +m[2], d = +m[3]; |
| 72 | const h = m[4] ? +m[4] : 9, min = m[5] ? +m[5] : 0; |
| 73 | // Reject out-of-range parts rather than letting Date silently roll them over |
| 74 | // (month 13 โ next year, 24:00 โ next day) โ the same rigor nextClockTime uses. |
| 75 | if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || min > 59) return null; |
| 76 | const dt = new Date(y, mo - 1, d, h, min, 0, 0); |
| 77 | // A day past its month's end still slides forward (2026-06-31 โ Jul 1, |
| 78 | // 2026-02-29 off-leap โ Mar 1), so confirm Date kept every field we asked for. |
| 79 | if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null; |
| 80 | const t = dt.getTime(); |
| 81 | return isNaN(t) ? null : t; |
| 82 | } |
| 83 | |
| 84 | // parseReminder splits "<text> <when>" into { text, when(epoch ms) }, or null if |
| 85 | // no time expression is found. Three shapes, tried in order: |
| 86 | // "โฆ | 2026-08-01[ 14:00]" explicit date/time after a bar |
| 87 | // "โฆ in 2h" / "in 45m" / โฆ relative from now |
| 88 | // "โฆ at 15:30" next occurrence of that clock time |
| 89 | // `now` is injectable so the parser is deterministic in tests. |
| 90 | const REMIND_UNIT = "seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w"; |
| 91 | |
| 92 | export function parseReminder(raw, now = Date.now()) { |
| 93 | let s = (raw || "").trim(); |
| 94 | if (!s) return null; |
| 95 | // Slack phrasing is "/remind me โฆ" โ drop a leading standalone "me" (but not |
| 96 | // "meeting"; \b keeps it a whole word). |
| 97 | s = s.replace(/^me\b\s*/i, "").trim(); |
| 98 | if (!s) return null; |
| 99 | |
| 100 | // Explicit "<text> | <absolute>" form. |
| 101 | const bar = s.indexOf("|"); |
| 102 | if (bar >= 0) { |
| 103 | const text = s.slice(0, bar).trim(); |
| 104 | const when = parseAbsolute(s.slice(bar + 1).trim()); |
| 105 | return text && when ? { text, when } : null; |
| 106 | } |
| 107 | |
| 108 | // The "when" can lead ("in 10s to do X" โ Slack) or trail ("do X in 10s"); the |
| 109 | // rest is the reminder text, with a leading "to " dropped either way. |
| 110 | let when = null; |
| 111 | let rest = null; |
| 112 | let m = s.match(new RegExp(`^in\\s+(\\d+)\\s*(${REMIND_UNIT})\\b\\s*(.*)$`, "i")); |
| 113 | if (m) { |
| 114 | const ms = unitMs(m[2]); |
| 115 | if (!ms) return null; |
| 116 | when = now + +m[1] * ms; |
| 117 | rest = m[3]; |
| 118 | } else if ((m = s.match(new RegExp(`^(.*?)\\s+in\\s+(\\d+)\\s*(${REMIND_UNIT})\\s*$`, "i")))) { |
| 119 | const ms = unitMs(m[3]); |
| 120 | if (!ms) return null; |
| 121 | when = now + +m[2] * ms; |
| 122 | rest = m[1]; |
| 123 | } else if ((m = s.match(/^at\s+(\d{1,2}):(\d{2})\b\s*(.*)$/i))) { |
| 124 | when = nextClockTime(+m[1], +m[2], now); |
| 125 | rest = m[3]; |
| 126 | } else if ((m = s.match(/^(.*?)\s+at\s+(\d{1,2}):(\d{2})\s*$/i))) { |
| 127 | when = nextClockTime(+m[2], +m[3], now); |
| 128 | rest = m[1]; |
| 129 | } |
| 130 | |
| 131 | if (when == null) return null; |
| 132 | const text = rest.replace(/^to\s+/i, "").trim(); |
| 133 | return text ? { text, when } : null; |
| 134 | } |
| 135 | |
| 136 | // The absolute time for the chat action line ("set a reminder: โฆ โ Wed, Jul 9, |
| 137 | // 14:30"). Locale short date + time. |
| 138 | function absTime(when) { |
| 139 | try { |
| 140 | return new Date(when).toLocaleString([], { weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); |
| 141 | } catch { |
| 142 | return ""; |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // addReminder is the one write path the COMMAND uses (the pane's composer runs |
| 147 | // the schema's "add" action โ the same schedule-first wire): schedule the |
| 148 | // future remind.fire FIRST โ its encrypted notice is what alerts every device, |
| 149 | // even with the app closed โ then record the reminder (with the schedule id, |
| 150 | // so any device can cancel it). A failed schedule (offline) still records the |
| 151 | // reminder, sid-less; the render ticker fires those best-effort. |
| 152 | async function addReminder(ctx, text, when) { |
| 153 | const id = ctx.id(); |
| 154 | const sid = await ctx.schedule( |
| 155 | { type: N.ev("fire"), id, notice: { title: "โฐ Reminder", body: text } }, |
| 156 | when, |
| 157 | ); |
| 158 | ctx.emit({ type: N.ev("add"), id, text, when, sid: sid || "" }); |
| 159 | } |
| 160 | |
| 161 | // A reminder is { id, text, when, ts, sid, fired, firedAt } โ see |
| 162 | // schemas/remind.json for the authoritative shape, folds and views. |
| 163 | export const app = defineApp({ |
| 164 | name: schema.name, |
| 165 | title: schema.title, |
| 166 | blurb: schema.blurb, // sidebar subtitle before first use (sync w/ native) |
| 167 | glyph: schema.glyph, |
| 168 | symbol: "alarm", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) |
| 169 | logo: ICON_ALARM_CLOCK, |
| 170 | // Also addable to a group chat (โฎ โ Add apps): shared reminders for the whole |
| 171 | // group โ the server delivers remind.fire into the GROUP thread, so every |
| 172 | // member folds it, sees the bubble, and gets the push. Same events, same |
| 173 | // folds, a different thread; /remind there scopes to the group instance. |
| 174 | // Yours or a chat's โ the same reducer serves every copy; only the thread |
| 175 | // differs (the places model, docs/space-apps.md). The SPACE place is parked |
| 176 | // while the places UI is hidden (ยง The pullback) โ restore by adding |
| 177 | // "space" back to this line. |
| 178 | places: ["you", "chat"], |
| 179 | initialState: schemaInitialState(schema), |
| 180 | events: schemaEvents(schema), |
| 181 | commands: { |
| 182 | "/remind": { |
| 183 | desc: "Set a reminder", |
| 184 | args: "<what> <when>", |
| 185 | run: (args, ctx) => { |
| 186 | const p = parseReminder(args); |
| 187 | // No parseable time โ ask for one. Worded for BOTH entry paths โ typed |
| 188 | // as /remind anywhere, or plain text in the Reminders thread (this is |
| 189 | // the app's defaultCommand) โ and carrying no `short`, so it renders |
| 190 | // even in-thread (an error is the command's only feedback there). |
| 191 | if (!p) return ctx.reply({ text: "When should I remind you? Try โcall mum in 10 minutesโ, โstandup at 9:00โ, or โrenew passport | 2026-08-01โ." }); |
| 192 | addReminder(ctx, p.text, p.when); |
| 193 | // Name who gets reminded (ctx.place): a reminder set in a shared copy |
| 194 | // fires for that whole chat or the whole space โ saying so up front is |
| 195 | // the places grammar's disclosure, not a nicety. |
| 196 | const who = ctx.place === "space" ? "everyone in this space" : ctx.place === "chat" ? "everyone in this chat" : "you"; |
| 197 | // `short` is what the reply collapses to once it calms down (~30s): a terse |
| 198 | // "Reminders added โโฆโ" one-liner, vs. the loud "I'll remind โฆ" card. |
| 199 | ctx.reply({ text: `I'll remind ${who}: โ${p.text}โ.`, short: `added โ${p.text}โ`, actions: [{ label: "View reminders", run: ctx.open }] }); |
| 200 | }, |
| 201 | }, |
| 202 | }, |
| 203 | // Plain text typed into the Reminders chat IS a /remind โ "what + when" is |
| 204 | // this app's whole grammar, so the global verb doubles as the default (no |
| 205 | // separate local /add needed). Missing time โ the usage reply above asks. |
| 206 | defaultCommand: "/remind", |
| 207 | composerPlaceholder: "Remind meโฆ e.g. call mum in 2h", |
| 208 | |
| 209 | // The bot's chat-view line for one event: an app-thread opens as a conversation |
| 210 | // with its bot (Change 3), so its events read as messages. null = no line. |
| 211 | // No `notify` hook: a fired reminder isn't a floating toast โ it arrives here as |
| 212 | // a message in the Reminders thread (with a sidebar unread + push when you're |
| 213 | // elsewhere), which is the whole point of the DM model. |
| 214 | chatIntro: |
| 215 | "This is the start of your chat history with Reminders. Just type what and when โ e.g. call mum in 10 minutes, or standup at 9:00. You can see and manage your reminders in the Interface view.", |
| 216 | |
| 217 | chatMessage(e, opts) { |
| 218 | // Firing is the bot messaging YOU (a bubble); setting one is an action you |
| 219 | // took. In a GROUP the bubble speaks to everyone โ the reminder may have |
| 220 | // been set by another member, so "you asked me" would be wrong there. |
| 221 | if (e.type === N.ev("fire")) { |
| 222 | const body = (e.notice && e.notice.body) || ""; |
| 223 | return opts && opts.group ? `Reminder: โ${body}โ` : `You asked me to remind you: โ${body}โ`; |
| 224 | } |
| 225 | if (e.type === N.ev("add")) { |
| 226 | const at = absTime(e.when); |
| 227 | // Group wording says when in prose ("for 9:00") โ the line lands among |
| 228 | // unrelated conversation; the personal thread keeps the terse dash. |
| 229 | const when = at ? (opts && opts.group ? ` for ${at}` : ` โ ${at}`) : ""; |
| 230 | return { text: `set a reminder: โ${e.text}โ${when}`, action: true }; |
| 231 | } |
| 232 | return null; |
| 233 | }, |
| 234 | |
| 235 | preview(e, opts) { |
| 236 | // The reminder text rides in the scheduled event's encrypted notice. |
| 237 | if (e.type === N.ev("fire")) return "โฐ " + ((e.notice && e.notice.body) || "Reminder fired"); |
| 238 | if (e.type === N.ev("add") && e.text) { |
| 239 | const actor = opts && opts.you ? "You" : e.name || "Someone"; |
| 240 | const at = absTime(e.when); |
| 241 | return `โฐ ${actor} set a reminder: โ${e.text}โ${at ? " for " + at : ""}`; |
| 242 | } |
| 243 | return null; |
| 244 | }, |
| 245 | |
| 246 | unread(e) { |
| 247 | return e.type === N.ev("fire"); |
| 248 | }, |
| 249 | |
| 250 | styles: schemaStyles, |
| 251 | // The schema's "add" action runs the named "reminder" parser โ parseReminder |
| 252 | // above, the one escape hatch a schema has into real code. |
| 253 | render: schemaRender(schema, { parsers: { reminder: parseReminder } }), |
| 254 | }); |
| 255 | |