eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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.
30import { defineApp, namespace } from "../apps.js";
31import { ICON_ALARM_CLOCK } from "../icons.js";
32import { schemaEvents, schemaInitialState, schemaRender, schemaStyles } from "../schema-render.js";
33import 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.
40const N = namespace(schema.name);
41
42// ---- time parsing (pure, unit-tested) -----------------------------------
43
44// Milliseconds for a relative unit word ("m"/"min"/"minutes" โ†’ 60000, etc).
45function 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.
57function 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.
68function 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.
90const REMIND_UNIT = "seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w";
91
92export 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.
138function 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.
152async 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.
163export 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