// The Reminders mini-app: nudge yourself about something later. From any chat, // /remind Call the plumber in 2h // /remind Standup at 9:00 // /remind Renew passport | 2026-08-01 // files a reminder into your own reminders thread (its own sidebar spot), where // it lives as a normal encrypted event โ€” the server sees ciphertext, never the // text or the time. // // Firing is server-scheduled (ctx.schedule): the due `remind.fire` event is // encrypted NOW and held by the server, which delivers it into the thread at // the right moment โ€” so it alerts reliably on every device, closed or open (the // service worker shows the event's encrypted `notice` when closed; a live client // surfaces it as a message in the Reminders thread, floated up the chat list with // an unread dot). The server learns only (thread, time), never the text โ€” the accepted timing // trade-off of real alerts. Dismissing an unfired reminder cancels its schedule // via the sid recorded on remind.add, from any device. If scheduling failed // (offline), the reminder has no sid and the render ticker fires it best-effort // client-side, like the earliest version of this app. // // ๐Ÿค– The app is SCHEMA-DESCRIBED: schemas/remind.json declares the state shape, // event folds (sorted soonest-first), actions (schedule-first add, dismiss), // the fallback-fire ticker, and views (docs/app-schema.md); schema-engine.js // folds and schema-render.js draws โ€” the same schema the native apps interpret // with SwiftUI, so the platforms can't drift. What stays hand-written here is // the glue a schema doesn't cover: the natural-language time parser (the // schema's named "reminder" parser), commands, sidebar preview/unread, chat. // // Relative import (not "/static/apps.js") so the parser + reducer are exercisable // in Node tests while still resolving to /static/apps.js in the browser. import { defineApp, namespace } from "../apps.js"; import { ICON_ALARM_CLOCK } from "../icons.js"; import { schemaEvents, schemaInitialState, schemaRender, schemaStyles } from "../schema-render.js"; import schema from "./schemas/remind.json" with { type: "json" }; // The schema (schemas/remind.json) is the source of truth for the `events` map // keys, so there's no drift there โ€” but the hand-written glue below (commands, // chatMessage/preview/unread) still compares against the wire type by hand. // N.ev("add") โ†’ "remind.add" keeps THOSE comparisons locked to the schema's own // name instead of a second hardcoded "remind" prefix. const N = namespace(schema.name); // ---- time parsing (pure, unit-tested) ----------------------------------- // Milliseconds for a relative unit word ("m"/"min"/"minutes" โ†’ 60000, etc). function unitMs(u) { u = u.toLowerCase(); if (/^(seconds?|secs?|s)$/.test(u)) return 1000; if (/^(minutes?|mins?|m)$/.test(u)) return 60000; if (/^(hours?|hrs?|h)$/.test(u)) return 3600000; if (/^(days?|d)$/.test(u)) return 86400000; if (/^(weeks?|w)$/.test(u)) return 604800000; return 0; } // The next wall-clock occurrence of h:m โ€” today if it's still ahead, else // tomorrow. Built in local time so "at 9:00" means the user's 9am. function nextClockTime(h, m, now) { if (h > 23 || m > 59) return null; const d = new Date(now); d.setHours(h, m, 0, 0); let t = d.getTime(); if (t <= now) t += 86400000; return t; } // An explicit "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" (T or space), in local time. A // bare date defaults to 09:00 so a day-only reminder lands in the morning. function parseAbsolute(str) { const m = str.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2}))?$/); if (!m) return null; const y = +m[1], mo = +m[2], d = +m[3]; const h = m[4] ? +m[4] : 9, min = m[5] ? +m[5] : 0; // Reject out-of-range parts rather than letting Date silently roll them over // (month 13 โ†’ next year, 24:00 โ†’ next day) โ€” the same rigor nextClockTime uses. if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || min > 59) return null; const dt = new Date(y, mo - 1, d, h, min, 0, 0); // A day past its month's end still slides forward (2026-06-31 โ†’ Jul 1, // 2026-02-29 off-leap โ†’ Mar 1), so confirm Date kept every field we asked for. if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null; const t = dt.getTime(); return isNaN(t) ? null : t; } // parseReminder splits " " into { text, when(epoch ms) }, or null if // no time expression is found. Three shapes, tried in order: // "โ€ฆ | 2026-08-01[ 14:00]" explicit date/time after a bar // "โ€ฆ in 2h" / "in 45m" / โ€ฆ relative from now // "โ€ฆ at 15:30" next occurrence of that clock time // `now` is injectable so the parser is deterministic in tests. const REMIND_UNIT = "seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w"; export function parseReminder(raw, now = Date.now()) { let s = (raw || "").trim(); if (!s) return null; // Slack phrasing is "/remind me โ€ฆ" โ€” drop a leading standalone "me" (but not // "meeting"; \b keeps it a whole word). s = s.replace(/^me\b\s*/i, "").trim(); if (!s) return null; // Explicit " | " form. const bar = s.indexOf("|"); if (bar >= 0) { const text = s.slice(0, bar).trim(); const when = parseAbsolute(s.slice(bar + 1).trim()); return text && when ? { text, when } : null; } // The "when" can lead ("in 10s to do X" โ€” Slack) or trail ("do X in 10s"); the // rest is the reminder text, with a leading "to " dropped either way. let when = null; let rest = null; let m = s.match(new RegExp(`^in\\s+(\\d+)\\s*(${REMIND_UNIT})\\b\\s*(.*)$`, "i")); if (m) { const ms = unitMs(m[2]); if (!ms) return null; when = now + +m[1] * ms; rest = m[3]; } else if ((m = s.match(new RegExp(`^(.*?)\\s+in\\s+(\\d+)\\s*(${REMIND_UNIT})\\s*$`, "i")))) { const ms = unitMs(m[3]); if (!ms) return null; when = now + +m[2] * ms; rest = m[1]; } else if ((m = s.match(/^at\s+(\d{1,2}):(\d{2})\b\s*(.*)$/i))) { when = nextClockTime(+m[1], +m[2], now); rest = m[3]; } else if ((m = s.match(/^(.*?)\s+at\s+(\d{1,2}):(\d{2})\s*$/i))) { when = nextClockTime(+m[2], +m[3], now); rest = m[1]; } if (when == null) return null; const text = rest.replace(/^to\s+/i, "").trim(); return text ? { text, when } : null; } // The absolute time for the chat action line ("set a reminder: โ€ฆ โ€” Wed, Jul 9, // 14:30"). Locale short date + time. function absTime(when) { try { return new Date(when).toLocaleString([], { weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } catch { return ""; } } // addReminder is the one write path the COMMAND uses (the pane's composer runs // the schema's "add" action โ€” the same schedule-first wire): schedule the // future remind.fire FIRST โ€” its encrypted notice is what alerts every device, // even with the app closed โ€” then record the reminder (with the schedule id, // so any device can cancel it). A failed schedule (offline) still records the // reminder, sid-less; the render ticker fires those best-effort. async function addReminder(ctx, text, when) { const id = ctx.id(); const sid = await ctx.schedule( { type: N.ev("fire"), id, notice: { title: "โฐ Reminder", body: text } }, when, ); ctx.emit({ type: N.ev("add"), id, text, when, sid: sid || "" }); } // A reminder is { id, text, when, ts, sid, fired, firedAt } โ€” see // schemas/remind.json for the authoritative shape, folds and views. export const app = defineApp({ name: schema.name, title: schema.title, blurb: schema.blurb, // sidebar subtitle before first use (sync w/ native) glyph: schema.glyph, symbol: "alarm", // native chrome: the SF Symbols name iOS/Mac render (docs/icons.md) logo: ICON_ALARM_CLOCK, // Also addable to a group chat (โ‹ฎ โ†’ Add apps): shared reminders for the whole // group โ€” the server delivers remind.fire into the GROUP thread, so every // member folds it, sees the bubble, and gets the push. Same events, same // folds, a different thread; /remind there scopes to the group instance. // Yours or a chat's โ€” the same reducer serves every copy; only the thread // differs (the places model, docs/space-apps.md). The SPACE place is parked // while the places UI is hidden (ยง The pullback) โ€” restore by adding // "space" back to this line. places: ["you", "chat"], initialState: schemaInitialState(schema), events: schemaEvents(schema), commands: { "/remind": { desc: "Set a reminder", args: " ", run: (args, ctx) => { const p = parseReminder(args); // No parseable time โ†’ ask for one. Worded for BOTH entry paths โ€” typed // as /remind anywhere, or plain text in the Reminders thread (this is // the app's defaultCommand) โ€” and carrying no `short`, so it renders // even in-thread (an error is the command's only feedback there). 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โ€." }); addReminder(ctx, p.text, p.when); // Name who gets reminded (ctx.place): a reminder set in a shared copy // fires for that whole chat or the whole space โ€” saying so up front is // the places grammar's disclosure, not a nicety. const who = ctx.place === "space" ? "everyone in this space" : ctx.place === "chat" ? "everyone in this chat" : "you"; // `short` is what the reply collapses to once it calms down (~30s): a terse // "Reminders added โ€œโ€ฆโ€" one-liner, vs. the loud "I'll remind โ€ฆ" card. ctx.reply({ text: `I'll remind ${who}: โ€œ${p.text}โ€.`, short: `added โ€œ${p.text}โ€`, actions: [{ label: "View reminders", run: ctx.open }] }); }, }, }, // Plain text typed into the Reminders chat IS a /remind โ€” "what + when" is // this app's whole grammar, so the global verb doubles as the default (no // separate local /add needed). Missing time โ†’ the usage reply above asks. defaultCommand: "/remind", composerPlaceholder: "Remind meโ€ฆ e.g. call mum in 2h", // The bot's chat-view line for one event: an app-thread opens as a conversation // with its bot (Change 3), so its events read as messages. null = no line. // No `notify` hook: a fired reminder isn't a floating toast โ€” it arrives here as // a message in the Reminders thread (with a sidebar unread + push when you're // elsewhere), which is the whole point of the DM model. chatIntro: "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.", chatMessage(e, opts) { // Firing is the bot messaging YOU (a bubble); setting one is an action you // took. In a GROUP the bubble speaks to everyone โ€” the reminder may have // been set by another member, so "you asked me" would be wrong there. if (e.type === N.ev("fire")) { const body = (e.notice && e.notice.body) || ""; return opts && opts.group ? `Reminder: โ€œ${body}โ€` : `You asked me to remind you: โ€œ${body}โ€`; } if (e.type === N.ev("add")) { const at = absTime(e.when); // Group wording says when in prose ("for 9:00") โ€” the line lands among // unrelated conversation; the personal thread keeps the terse dash. const when = at ? (opts && opts.group ? ` for ${at}` : ` โ€” ${at}`) : ""; return { text: `set a reminder: โ€œ${e.text}โ€${when}`, action: true }; } return null; }, preview(e, opts) { // The reminder text rides in the scheduled event's encrypted notice. if (e.type === N.ev("fire")) return "โฐ " + ((e.notice && e.notice.body) || "Reminder fired"); if (e.type === N.ev("add") && e.text) { const actor = opts && opts.you ? "You" : e.name || "Someone"; const at = absTime(e.when); return `โฐ ${actor} set a reminder: โ€œ${e.text}โ€${at ? " for " + at : ""}`; } return null; }, unread(e) { return e.type === N.ev("fire"); }, styles: schemaStyles, // The schema's "add" action runs the named "reminder" parser โ€” parseReminder // above, the one escape hatch a schema has into real code. render: schemaRender(schema, { parsers: { reminder: parseReminder } }), });