openvibers/openvibe.today Commits
Your own reminders on openvibe.today: plain words, your time zone, bell and push (L48 R1) (#36)
Changes
nnew file mode 100644@@@ -0,0 +1,24 @@++-- phase: expand++-- A person's own reminders (plan L48, R1): "stretch every 2 hours from 9 to 5", "take my meds every day at 9 am and++-- 9 pm", "call mom on Sunday at 6 pm". The words are parsed once (server/when.js) into a rule; next_at is the next++-- time it fires, in the person's own time zone, and the job claims a due row by moving next_at on in one UPDATE, so++-- two processes never send twice. Private to the person: exported and erased with the account.++CREATE TABLE IF NOT EXISTS today_my_reminders (++ id text COLLATE "C" PRIMARY KEY, -- rem_ + ULID++ subject text COLLATE "C" NOT NULL, -- usr_… the person++ title text NOT NULL, -- what to remind them of, in their words++ said text NOT NULL, -- what they typed or said, kept to show and edit++ rule jsonb NOT NULL, -- server/when.js: once | daily | interval | monthly++ tz text NOT NULL, -- IANA zone, validated with Intl++ url text, -- where it came from (a stream, a job, a page), https only++ source text COLLATE "C" NOT NULL DEFAULT 'today', -- today | vibe | live | work | … (the site that set it)++ status text COLLATE "C" NOT NULL DEFAULT 'active', -- active | done++ next_at timestamptz, -- null when it is done++ last_sent_at timestamptz,++ sent_count integer NOT NULL DEFAULT 0,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT today_my_reminders_status CHECK (status IN ('active', 'done'))++);++CREATE INDEX IF NOT EXISTS today_my_reminders_due ON today_my_reminders (next_at) WHERE status = 'active';++CREATE INDEX IF NOT EXISTS today_my_reminders_by_subject ON today_my_reminders (subject, created_at);@@@ -195,3 +195,31 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri.td-hol-day a:hover { background: var(--bg-hover, var(--ovk-panel-2, #1a2233)); }.td-hol-day span { color: var(--text-secondary, #a8b3c4); font-size: 14px; }@media (max-width: 560px) { .td-hol-day { grid-template-columns: 1fr; gap: 4px; } .td-hol-date { padding-top: 0; } }++++/* Remind me (plan L48, R1): one box in plain words, what it understood under it, the list with its few actions. */++.td-remind { margin: 0 0 18px; }++.td-remind-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; margin-top: 6px; }++.td-remind-form input[name="text"] { min-width: 0; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: inherit; font: inherit; font-size: 1rem; }++.td-remind-form input[name="text"]:focus { outline: 2px solid var(--room-strong, var(--accent, #4f8cff)); outline-offset: 1px; }++.td-remind-out { grid-column: 1 / -1; margin: 0; font-size: .86rem; color: var(--text-muted, #7386a3); min-height: 1.3em; }++.td-remind-out.is-ok { color: var(--text-primary, #e8edf3); }++.td-remind-out.is-bad { color: #f87171; }++.td-mine { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }++.td-mine-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--room-strong, #4f8cff) 6%, transparent); }++.td-mine-row.is-done { opacity: .65; }++.td-mine-ico .ovk-i { width: 18px; height: 18px; color: var(--room-strong); }++.td-mine-main { display: grid; gap: 2px; min-width: 0; }++.td-mine-main b { overflow-wrap: anywhere; }++.td-mine-main a { color: inherit; }++.td-mine-main small { color: var(--text-muted, #7386a3); }++.td-mine-acts { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }++.td-chip-btn { padding: 6px 10px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); background: transparent; color: inherit; font: inherit; font-size: .8rem; cursor: pointer; min-height: 32px; }++.td-chip-btn:hover { background: var(--bg-hover, rgba(127,140,160,.12)); }++.td-mine-done { margin-top: 10px; }++.td-mine-done summary { cursor: pointer; color: var(--text-muted, #7386a3); font-size: .86rem; }++.td-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++@media (max-width: 560px) {++ .td-remind-form { grid-template-columns: minmax(0, 1fr); }++ .td-mine-row { grid-template-columns: auto minmax(0, 1fr); }++ .td-mine-acts { grid-column: 1 / -1; justify-content: flex-start; }++}@@@ -62,5 +62,36 @@});}if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();++ // Remind me: the form's time zone is the device's, and the words are read back as they are typed.++ function remindForm() {++ document.querySelectorAll('[data-tz]').forEach(function (el) { el.value = zone; });++ var form = document.querySelector('[data-remind-preview]');++ if (!form || form.getAttribute('data-ready')) return;++ form.setAttribute('data-ready', '1');++ var input = form.querySelector('input[name="text"]'), out = form.querySelector('[data-remind-out]');++ var hint = out ? out.textContent : '', timer = 0, seq = 0;++ input.addEventListener('input', function () {++ clearTimeout(timer);++ var text = input.value.trim();++ if (!text) { out.textContent = hint; out.classList.remove('is-bad', 'is-ok'); return; }++ timer = setTimeout(function () {++ var mine = ++seq;++ fetch(form.getAttribute('data-remind-preview'), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ text: text, tz: zone }) })++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })++ .then(function (x) {++ if (mine !== seq) return;++ if (!x.ok) { out.textContent = x.d.error || 'Say what, and when.'; out.classList.add('is-bad'); out.classList.remove('is-ok'); return; }++ out.textContent = '“' + x.d.title + '”: ' + x.d.summary + ' · first ' + fmt(new Date(x.d.next_at), { weekday: 'short', hour: 'numeric', minute: '2-digit' });++ out.classList.add('is-ok'); out.classList.remove('is-bad');++ }).catch(function () { /* the form still works */ });++ }, 300);++ });++ document.querySelectorAll('time[data-local="when"]').forEach(function (el) {++ var d = new Date(el.getAttribute('datetime'));++ if (!isNaN(d)) el.textContent = fmt(d, { weekday: 'short', hour: 'numeric', minute: '2-digit' });++ });++ }++ document.addEventListener('ov:boost:load', remindForm);++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', remindForm); else remindForm();document.addEventListener('ov:boost:load', run);})();@@@ -70,7 +70,8 @@ async function createApp(opts = {}) {const agenda = createAgenda({ s, follows });const brief = createBrief({ config, s, agenda, fetchImpl, log });const notifier = createNotifier({ config, s, fetchImpl, log });-- const reminders = createReminders({ config, s, events, notifier, log });++ const mine = require('./my-reminders').createMyReminders({ s });++ const reminders = createReminders({ config, s, events, mine, notifier, log });const publisher = createPublisher({ config, s, fetchImpl, log });const imports = createImports({ config, s, fetchImpl, log, now: s.now });@@@ -83,7 +84,7 @@ async function createApp(opts = {}) {const consumer = createEventsConsumer({ config, s, accountData, accountSend, now: s.now, log });const ctx = {-- config, s, keys, sso, principal, events, feeds, follows, agenda, brief, notifier, reminders, publisher,++ config, s, keys, sso, principal, events, feeds, follows, agenda, brief, notifier, reminders, mine, publisher,imports, accountData, accountSend, consumer, log,};@@@ -15,6 +15,11 @@* GET /me/feed the private ICS URL, created on first call* POST /me/feed/rotate replace it (the old URL is 404 at once)* GET /me/brief today's brief, built on demand when missing++ * POST /reminders/parse what plain words mean: { text, tz } → { title, summary, next_at, rule }++ * GET /reminders the caller's own reminders (active first)++ * POST /reminders set one from plain words: { text, tz, url?, source? }++ * POST /reminders/:id/done · /snooze done, or later by { minutes }++ * DELETE /reminders/:id remove it** A person is the session cookie (today_at) or Authorization: Bearer <Network user token>, verified offline with* the Network JWKS. A write made with the session cookie must come from openvibe.today itself (403@@@ -26,7 +31,7 @@ const { sameOrigin } = require('./principal');const { parseTime, isIanaZone } = require('../events/validate');function createApi(ctx) {-- const { config, s, principal, events, feeds, follows, agenda, brief } = ctx;++ const { config, s, principal, events, feeds, follows, agenda, brief, mine } = ctx;const r = asyncRouter();const invalid = (res, field, message) => res.status(400).json({ code: 'today.invalid', field, message });@@@ -137,6 +142,33 @@ function createApi(ctx) {res.json({ reminders: await events.listReminders(req.principal.subject) });});++ // ── A person's own reminders (plan L48, R1) ────────────++ const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});++ r.post('/reminders/parse', requirePerson, (req, res) => {++ const b = body(req);++ const out = mine.preview({ text: b.text, tz: b.tz });++ return out.error ? res.status(422).json({ error: out.error }) : res.json(out);++ });++ r.get('/reminders', requirePerson, async (req, res) => {++ res.set('Cache-Control', 'private, no-store').json({ reminders: await mine.list(req.principal.subject) });++ });++ r.post('/reminders', requirePerson, async (req, res) => {++ const b = body(req);++ const out = await mine.create(req.principal.subject, { text: b.text, tz: b.tz, url: b.url, source: b.source });++ return out.error ? res.status(422).json({ error: out.error }) : res.status(201).json(out);++ });++ r.post('/reminders/:id/done', requirePerson, async (req, res) => {++ const out = await mine.done(req.principal.subject, String(req.params.id));++ return out ? res.json(out) : res.status(404).json({ error: 'No such reminder' });++ });++ r.post('/reminders/:id/snooze', requirePerson, async (req, res) => {++ const out = await mine.snooze(req.principal.subject, String(req.params.id), body(req).minutes);++ return out ? res.json(out) : res.status(404).json({ error: 'No such reminder' });++ });++ r.delete('/reminders/:id', requirePerson, async (req, res) => {++ return (await mine.remove(req.principal.subject, String(req.params.id))) ? res.status(204).end() : res.status(404).json({ error: 'No such reminder' });++ });++r.get('/me/agenda', requirePerson, async (req, res) => {const raw = req.query.days == null || req.query.days === '' ? 14 : Number.parseInt(req.query.days, 10);if (!Number.isFinite(raw) || raw < 1 || raw > 60) return invalid(res, 'days', 'days must be a whole number from 1 to 60');@@@ -115,7 +115,7 @@ function createPublicPageViews(ctx) {}function createPageRoutes(ctx) {-- const { config, s, events, feeds, agenda, brief } = ctx;++ const { config, s, events, feeds, agenda, brief, mine } = ctx;const r = asyncRouter();r.use(express.urlencoded({ extended: false, limit: '64kb' }));const base = () => String(config.baseUrl).replace(/\/+$/, '');@@@ -203,13 +203,35 @@ function createPageRoutes(ctx) {const a = await agenda.forSubject({ subject: p.subject, token: p.token, days: 14, now: s.now() });const reminders = await events.listReminders(p.subject);const token = await feeds.getOrCreate(p.subject);++ const own = mine ? await mine.list(p.subject) : [];return page(req, res, {title: 'Your agenda',description: 'Your own events, the reminders you set, the channels you follow and your private calendar feed.',-- body: raw(views.me({ name: req.viewer.displayName || req.viewer.username, agenda: a, reminders, feedUrl: feeds.url(config, token), now: s.now() })),++ body: raw(views.me({ name: req.viewer.displayName || req.viewer.username, agenda: a, reminders, feedUrl: feeds.url(config, token), now: s.now(), mine: own, problem: String(req.query.problem || '').slice(0, 200) || null, said: String(req.query.said || '').slice(0, 500) })),});});++ // ── A person's own reminders (plan L48, R1): plain forms, so the page works without its script ──++ const mineGuard = (req, res, next) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fme%23reminders');++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: raw(views.notFound()) }, 403);++ return next();++ };++ const toMine = (res, extra = '') => res.redirect(303, `/me${extra}#reminders`);++ r.post('/me/reminders', express.urlencoded({ extended: false, limit: '8kb' }), mineGuard, async (req, res) => {++ const b = req.body || {};++ const out = await mine.create(req.viewer.subject, { text: b.text, tz: b.tz, source: 'today' });++ if (out.error) return toMine(res, `?problem=${encodeURIComponent(out.error)}&said=${encodeURIComponent(String(b.text || '').slice(0, 500))}`);++ return toMine(res);++ });++ r.post('/me/reminders/:id/:act(done|snooze|remove)', express.urlencoded({ extended: false, limit: '2kb' }), mineGuard, async (req, res) => {++ const id = String(req.params.id || '');++ if (req.params.act === 'done') await mine.done(req.viewer.subject, id);++ else if (req.params.act === 'snooze') await mine.snooze(req.viewer.subject, id, (req.body || {}).minutes || 60);++ else await mine.remove(req.viewer.subject, id);++ return toMine(res);++ });++r.post('/me/feed/rotate', async (req, res) => {if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>${signInPrompt('A feed belongs to an account.', '/me')}` }, 401);if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: html`<h1>That came from somewhere else</h1><p class="muted">Replace a feed from <a href="/me">your agenda</a>.</p>` }, 403);@@@ -16,6 +16,7 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');const TABLES = [{ table: 'today_events', subject: 'owner_subject', value: (usr) => usr, file: 'events.json', erase: 'delete' },{ table: 'today_reminders', subject: 'subject', value: (usr) => usr, file: 'reminders.json', erase: 'delete' },++ { table: 'today_my_reminders', subject: 'subject', value: (usr) => usr, file: 'my-reminders.json', erase: 'delete' },// The token itself is never exported; it is a bearer capability for the person's calendar.{ table: 'today_feeds', subject: 'subject', value: (usr) => usr, file: 'feeds.json', columns: ['subject', 'created_at'], erase: 'delete' },{ table: 'today_briefs', subject: 'subject', value: (usr) => usr, file: 'briefs.json', erase: 'delete' },nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++/**++ * A person's own reminders (plan L48, R1). The words are parsed by server/when.js; this keeps them, lists them,++ * marks them done, snoozes them, and hands the job the ones that are due.++ *++ * createMyReminders({ s })++ * preview({ text, tz, now }) → { title, rule, tz, next_at, summary } or { error }++ * create(subject, { text, tz, url, source }) → the reminder, or { error } (a person keeps at most MAX_ACTIVE)++ * list(subject) → active first (soonest first), then the last done ones++ * done(subject, id) · snooze(subject, id, minutes) · remove(subject, id)++ * due(now) · claim(row, now) · release(row) the job's side: claim moves next_at on in one UPDATE++ */++const when = require('./when');++++const MAX_ACTIVE = 200;++const SOURCES = /^[a-z][a-z0-9-]{1,30}$/;++const safeUrl = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' && String(u).length <= 2000 ? x.toString() : null; } catch { return null; } };++++function wire(r, { now = Date.now() } = {}) {++ const rule = typeof r.rule === 'string' ? JSON.parse(r.rule) : r.rule;++ const iso = (v) => (v ? new Date(v).toISOString() : null);++ return {++ id: r.id, title: r.title, said: r.said, rule, tz: r.tz, url: r.url || null, source: r.source, status: r.status,++ next_at: iso(r.next_at), last_sent_at: iso(r.last_sent_at), sent_count: Number(r.sent_count) || 0,++ summary: when.describe(rule, { tz: r.tz, now: new Date(now) }), repeats: rule.kind !== 'once',++ created_at: iso(r.created_at),++ };++}++++function createMyReminders({ s }) {++ const db = s.db;++++ function preview({ text, tz, now = s.now() }) {++ return when.parse(String(text || '').slice(0, 500), { now: new Date(now), tz: when.validTz(tz) ? tz : 'UTC' });++ }++++ async function create(subject, { text, tz, url = null, source = 'today' }) {++ const p = preview({ text, tz });++ if (p.error) return { error: p.error };++ const active = Number(await db.value("SELECT count(*)::int FROM today_my_reminders WHERE subject = $1 AND status = 'active'", [subject]));++ if (active >= MAX_ACTIVE) return { error: `You have ${MAX_ACTIVE} reminders already. Mark some done first.` };++ const id = s.newId('rem');++ const row = await db.maybe(++ `INSERT INTO today_my_reminders (id, subject, title, said, rule, tz, url, source, next_at)++ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9) RETURNING *`,++ [id, subject, p.title, String(text).trim().slice(0, 500), JSON.stringify(p.rule), p.tz, safeUrl(url), SOURCES.test(String(source)) ? source : 'today', p.next_at],++ );++ return wire(row, { now: s.now() });++ }++++ async function list(subject) {++ const rows = await db.many(++ `(SELECT * FROM today_my_reminders WHERE subject = $1 AND status = 'active' ORDER BY next_at ASC LIMIT ${MAX_ACTIVE})++ UNION ALL++ (SELECT * FROM today_my_reminders WHERE subject = $1 AND status = 'done' ORDER BY updated_at DESC LIMIT 10)`,++ [subject],++ );++ return rows.map((r) => wire(r, { now: s.now() }));++ }++++ async function done(subject, id) {++ const r = await db.maybe("UPDATE today_my_reminders SET status = 'done', next_at = NULL, updated_at = now() WHERE id = $1 AND subject = $2 RETURNING *", [id, subject]);++ return r ? wire(r) : null;++ }++++ /** Later by `minutes` from now (5 minutes to a week). A repeating one carries on from its rule after that. */++ async function snooze(subject, id, minutes) {++ const m = Math.min(7 * 24 * 60, Math.max(5, Math.round(Number(minutes) || 0)));++ const at = new Date(s.now() + m * 60_000).toISOString();++ const r = await db.maybe("UPDATE today_my_reminders SET status = 'active', next_at = $3, updated_at = now() WHERE id = $1 AND subject = $2 RETURNING *", [id, subject, at]);++ return r ? wire(r) : null;++ }++++ async function remove(subject, id) {++ const r = await db.maybe('DELETE FROM today_my_reminders WHERE id = $1 AND subject = $2 RETURNING id', [id, subject]);++ return !!r;++ }++++ async function due(now = s.now(), limit = 500) {++ return db.many("SELECT * FROM today_my_reminders WHERE status = 'active' AND next_at <= $1 ORDER BY next_at ASC LIMIT $2", [new Date(now).toISOString(), limit]);++ }++++ /**++ * Claim one due reminder: next_at moves on to the rule's next time after now (or the reminder is done) in one++ * UPDATE that only matches the next_at the job read, so a second process gets nothing. → the claimed row or null.++ */++ async function claim(row, now = s.now()) {++ const rule = typeof row.rule === 'string' ? JSON.parse(row.rule) : row.rule;++ // A one-off is done once it has been sent, even when it was snoozed past its own time.++ const after = rule.kind === 'once' ? null : when.next(rule, { after: new Date(now), tz: row.tz });++ return db.maybe(++ `UPDATE today_my_reminders++ SET next_at = $3, status = CASE WHEN $3::timestamptz IS NULL THEN 'done' ELSE status END,++ last_sent_at = $4, sent_count = sent_count + 1, updated_at = now()++ WHERE id = $1 AND status = 'active'++ -- PostgreSQL keeps microseconds and a JS Date has milliseconds: compare at the coarser of the two.++ AND date_trunc('milliseconds', next_at) = date_trunc('milliseconds', $2::timestamptz) RETURNING *`,++ [row.id, row.next_at, after ? after.toISOString() : null, new Date(now).toISOString()],++ );++ }++++ /** A send that failed: the reminder is due again on the next tick. */++ async function release(row) {++ await db.exec("UPDATE today_my_reminders SET next_at = $2, status = 'active', sent_count = GREATEST(sent_count - 1, 0) WHERE id = $1", [row.id, row.next_at]);++ }++++ return { preview, create, list, done, snooze, remove, due, claim, release, wire, MAX_ACTIVE };++}++++module.exports = { createMyReminders };@@@ -87,9 +87,40 @@ function createNotifier({ config, s, fetchImpl = globalThis.fetch, log = consolereturn { ok: true, user_id: userId, title };}++ /** A person's own reminder: their words as the title, a link back to where it came from (or their reminders). */++ async function sendMine({ subject, reminder }) {++ if (!canSend) throw new Error('TODAY_OAUTH_CLIENT_SECRET is not set: Today cannot send a notification');++ const ids = await resolveUserIds([subject]);++ const userId = ids.get(subject);++ if (!userId) {++ log.warn(`[OpenVibe.Today] reminder for ${subject}: Network has no user id for that subject`);++ return { ok: false, reason: 'no_user' };++ }++ const base = String(config.baseUrl).replace(/\/+$/, '');++ const body = {++ user_id: userId,++ type: 'TODAY_REMINDER',++ category: 'system',++ priority: 'normal',++ icon: '⏰',++ title: String(reminder.title).slice(0, 200),++ ...(reminder.repeats ? { message: `${reminder.summary}. Snooze or stop it on openvibe.today.` } : {}),++ service: 'today',++ url: reminder.url || `${base}/me#reminders`,++ };++ const res = await fetchImpl(`${config.networkInternalUrl}/internal/notifications/push`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await pushTokens.authHeaders()) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(10_000),++ });++ if (!res.ok) throw new Error(`notification push ${res.status}`);++ return { ok: true, user_id: userId, title: body.title };++ }++function clear(subject = null) { if (subject) cache.delete(subject); else cache.clear(); }-- return { sendReminder, resolveUserIds, clear, titleFor, canSend };++ return { sendReminder, sendMine, resolveUserIds, clear, titleFor, canSend };}module.exports = { createNotifier };@@@ -5,7 +5,7 @@* `UPDATE … SET sent_at = now() WHERE … AND sent_at IS NULL RETURNING`, so two processes never send twice. A send* failure puts the claim back for the next tick; a cancelled or done event's reminder never sends.*/--function createReminders({ config, s, events, notifier, log = console, intervalMs = 60_000 }) {++function createReminders({ config, s, events, mine = null, notifier, log = console, intervalMs = 60_000 }) {let timer = null;const stats = { sent: 0, failed: 0, skipped: 0, last_error: null, last_run: null };@@@ -28,8 +28,28 @@ function createReminders({ config, s, events, notifier, log = console, intervalMawait events.releaseReminder(row.subject, row.event_id).catch(() => {});}}++ // A person's own reminders (plan L48): claimed by moving next_at on, so a repeat is never sent twice.++ let mineDue = 0;++ if (mine) {++ const rows = await mine.due(now);++ mineDue = rows.length;++ for (const row of rows) {++ const claimed = await mine.claim(row, now);++ if (!claimed) { skipped++; continue; }++ try {++ const out = await notifier.sendMine({ subject: row.subject, reminder: mine.wire(claimed, { now }) });++ if (out && out.ok) { sent++; stats.sent++; } else { skipped++; stats.skipped++; }++ } catch (err) {++ failed++;++ stats.failed++;++ stats.last_error = String((err && err.message) || err).slice(0, 300);++ log.warn(`[OpenVibe.Today] reminder ${row.id} not sent: ${stats.last_error}`);++ await mine.release(row).catch(() => {});++ }++ }++ }stats.last_run = new Date(now).toISOString();-- return { due: due.length, sent, failed, skipped };++ return { due: due.length + mineDue, sent, failed, skipped };}function start() {@@@ -221,16 +221,43 @@ ${own && !done ? `<form method="post" action="/e/${esc(e.id)}/cancel" class="td-</article></div>`;}--function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now = Date.now() } = {}) {++// A person's own reminders (plan L48, R1): one box in plain words with a preview of what it understood, and the list.++const SOURCE_NAMES = { today: '', vibe: 'Set by Vibe', live: 'From Live', work: 'From Work', deals: 'From Deals', coupons: 'From Coupons', watch: 'From Watch', chat: 'From Chat' };++function myReminders({ mine = [], problem = null, said = '' } = {}) {++ const active = mine.filter((r) => r.status === 'active');++ const done = mine.filter((r) => r.status === 'done');++ const act = (r, path, label, extra = '') => `<form method="post" action="/me/reminders/${esc(r.id)}/${path}" class="td-inline">${extra}<button class="td-chip-btn" type="submit">${esc(label)}</button></form>`;++ const row = (r) => `<li class="td-mine-row${r.status === 'done' ? ' is-done' : ''}">++<span class="td-mine-ico" aria-hidden="true">${kit.icon(r.repeats ? 'clock' : 'bell')}</span>++<span class="td-mine-main"><b>${r.url ? `<a href="${esc(r.url)}">${esc(r.title)}</a>` : esc(r.title)}</b>++<small>${esc(r.summary)}${r.status === 'active' && r.next_at ? ` · next <time datetime="${esc(r.next_at)}" data-local="when">${esc(r.next_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ''}${SOURCE_NAMES[r.source] ? ` · ${esc(SOURCE_NAMES[r.source])}` : ''}</small></span>++<span class="td-mine-acts">${r.status === 'active' ? `${act(r, 'snooze', 'Snooze 1 h', '<input type="hidden" name="minutes" value="60">')}${act(r, 'done', r.repeats ? 'Stop' : 'Done')}` : ''}${act(r, 'remove', 'Remove')}</span>++</li>`;++ return `<section class="td-card td-remind" id="reminders" aria-labelledby="td-mine-h">++<h2 id="td-mine-h">${kit.icon('bell')}Remind me</h2>++<form method="post" action="/me/reminders" class="td-remind-form" data-remind-preview="/api/v1/reminders/parse">++<input type="hidden" name="tz" value="UTC" data-tz>++<label class="td-sr" for="td-remind-text">What, and when, in your own words</label>++<input id="td-remind-text" name="text" required maxlength="500" autocomplete="off" value="${esc(said)}" placeholder="Stretch every 2 hours from 9 to 5">++<button class="ovk-btn ovk-btn--primary" type="submit">Remind me</button>++<p class="td-remind-out${problem ? ' is-bad' : ''}" data-remind-out aria-live="polite">${problem ? esc(problem) : 'Try “take my meds every day at 9 am and 9 pm”, “call mom on Sunday at 6 pm” or “pay rent on the 1st of every month”.'}</p>++</form>++${active.length ? `<ul class="td-mine">${active.map(row).join('')}</ul>` : '<p class="td-fine">Nothing set yet. Reminders arrive in your bell on every OpenVibe site, and as a push notification if you allowed them.</p>'}++${done.length ? `<details class="td-mine-done"><summary>Done (${done.length})</summary><ul class="td-mine">${done.map(row).join('')}</ul></details>` : ''}++</section>`;++}++++function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now = Date.now(), mine = [], problem = null, said = '' } = {}) {const webcal = String(feedUrl).replace(/^https?:/, 'webcal:');return `<div class="td-wrap td-page-pad" id="td-me"><header class="td-page-h"><p class="td-kicker">Your agenda</p><h1>The next two weeks</h1><p class="td-lede">Your own events, the ones you set reminders on, and the channels you follow, ${esc(name || 'you')}.</p><div class="td-acts">${kit.button({ href: '/schedule', label: 'Schedule a stream', kind: 'primary' })}${kit.button({ href: '/brief', label: 'Your brief', kind: 'quiet' })}</div></header>++${myReminders({ mine, problem, said })}${agenda.follows_unavailable ? '<p class="td-note" role="status">OpenVibe.Network did not answer, so the channels you follow are missing for a moment. Your own and reminded events are all here.</p>' : ''}<div class="td-me-grid"><div>${days(agenda.events || [], { now, empty: 'Nothing in the next two weeks. Follow a channel on Live, or set a reminder on something coming up.' })}</div><aside class="td-side">--<section class="td-card" aria-labelledby="td-rem-h"><h2 id="td-rem-h">${kit.icon('bell')}Reminders</h2>++<section class="td-card" aria-labelledby="td-rem-h"><h2 id="td-rem-h">${kit.icon('bell')}Before an event</h2>${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/e/${esc(x.event_id)}">${esc(x.event.title)}</a><small><time datetime="${esc(x.remind_at)}" data-local="time">${esc(timeText(parts(x.remind_at, x.event.tz)))} ${esc(parts(x.remind_at, x.event.tz).timeZoneName)}</time>, ${esc(dayLabel(x.remind_at, x.event.tz, now).toLowerCase())}</small></li>`).join('')}</ul>` : '<p class="td-fine">None yet. Open anything coming up and tap Remind me.</p>'}</section><section class="td-card" aria-labelledby="td-feed-h"><h2 id="td-feed-h">${kit.icon('calendar')}Your calendar feed</h2>nnew file mode 100644@@@ -0,0 +1,320 @@++'use strict';++/**++ * When a reminder fires, from plain words (plan L48, R1; the owner, 2026-11-01: "set reminders for some part of the++ * day or whatever timer or however often they want").++ *++ * parse(text, { now, tz, parts }) → { title, rule, next_at, summary } or { error }++ * next(rule, { after, tz, parts }) → the next time (a Date) strictly after `after`, or null when it is over++ * describe(rule, { parts }) → the rule in words ("Every 2 hours, 9:00–17:00")++ *++ * Everything is computed in the person's own time zone (IANA, from their browser), so "every day at 9" is 9 o'clock++ * where they are, across daylight-saving changes.++ *++ * A rule is one of:++ * { kind: 'once', at: ISO }++ * { kind: 'daily', times: ['09:00'], days?: [1..7] } days: ISO weekdays (1 Monday … 7 Sunday); none = every day++ * { kind: 'interval', minutes: N, from?: '09:00', to?: '17:00', days?: [1..7] }++ * { kind: 'monthly', day: 1..31, time: '09:00' } a month without that day uses its last day++ *++ * The words it understands (English): "in 20 minutes", "at 3pm", "at 15:30", "tomorrow", "tonight", "this evening",++ * "on Friday at 5", "every day at 9", "daily", "every morning", "weekdays at 8:30", "weekends", "every Monday and++ * Thursday at 7pm", "every 2 hours", "every 30 minutes from 9 to 5", "hourly", "on the 1st of every month",++ * "monthly on the 15th". What remains is the title ("remind me to" and "to" dropped).++ */++++const PARTS = { morning: '09:00', afternoon: '14:00', evening: '19:00', night: '21:30' };++const DAYS = { monday: 1, mon: 1, tuesday: 2, tue: 2, tues: 2, wednesday: 3, wed: 3, thursday: 4, thu: 4, thur: 4, thurs: 4, friday: 5, fri: 5, saturday: 6, sat: 6, sunday: 7, sun: 7 };++const DAY_NAMES = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];++const WEEKDAYS = [1, 2, 3, 4, 5];++const WEEKENDS = [6, 7];++const MAX_TITLE = 200;++++// ── Time zones ────────────────────────────────────────────────────────────────────────────────────────────────────++function validTz(tz) {++ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }++}++const fmtCache = new Map();++/** The wall clock in `tz` at instant `d`: { y, m, d, h, min, dow } (dow 1 Monday … 7 Sunday). */++function partsIn(date, tz) {++ let f = fmtCache.get(tz);++ if (!f) {++ f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', weekday: 'short' });++ fmtCache.set(tz, f);++ }++ const o = {};++ for (const p of f.formatToParts(date)) o[p.type] = p.value;++ const dow = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }[o.weekday];++ return { y: Number(o.year), m: Number(o.month), d: Number(o.day), h: Number(o.hour) % 24, min: Number(o.minute), dow };++}++/** The instant when the wall clock in `tz` reads y-m-d h:min (a time skipped by a clock change moves forward). */++function atLocal(y, m, d, h, min, tz) {++ const guess = Date.UTC(y, m - 1, d, h, min);++ let t = guess;++ for (let i = 0; i < 3; i++) {++ const p = partsIn(new Date(t), tz);++ const shown = Date.UTC(p.y, p.m - 1, p.d, p.h, p.min);++ const diff = shown - guess;++ if (diff === 0) return new Date(t);++ t -= diff;++ }++ return new Date(t);++}++/** y-m-d plus n days, as a calendar date (no time zone involved). */++function addDays(y, m, d, n) {++ const t = new Date(Date.UTC(y, m - 1, d + n));++ return { y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate(), dow: ((t.getUTCDay() + 6) % 7) + 1 };++}++const hm = (s) => { const [h, min] = String(s).split(':').map(Number); return { h, min }; };++const lastDay = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate();++++// ── The next time ─────────────────────────────────────────────────────────────────────────────────────────────────++function next(rule, { after = new Date(), tz = 'UTC', parts = PARTS } = {}) {++ void parts;++ const a = after instanceof Date ? after : new Date(after);++ if (!rule || typeof rule !== 'object') return null;++ if (rule.kind === 'once') { const t = new Date(rule.at); return t > a ? t : null; }++ const today = partsIn(a, tz);++ if (rule.kind === 'daily') {++ const times = (rule.times || []).slice().sort();++ for (let k = 0; k < 15; k++) {++ const day = addDays(today.y, today.m, today.d, k);++ if (rule.days && rule.days.length && !rule.days.includes(day.dow)) continue;++ for (const t of times) { const { h, min } = hm(t); const at = atLocal(day.y, day.m, day.d, h, min, tz); if (at > a) return at; }++ }++ return null;++ }++ if (rule.kind === 'interval') {++ const stepMin = Math.max(5, Number(rule.minutes) || 60);++ if (!rule.from) {++ // No window: every step from the moment it was set ("every 2 hours" set at 10:15 fires at 12:15, 14:15…),++ // counted in real time, so midnight and clock changes do not move it.++ const step = stepMin * 60000;++ const start = new Date(rule.start || a).getTime();++ let t = start + Math.max(1, Math.floor((a.getTime() - start) / step) + 1) * step;++ for (let i = 0; i < 4000; i++, t += step) {++ const d = new Date(t);++ if (!rule.days || !rule.days.length || rule.days.includes(partsIn(d, tz).dow)) return d;++ }++ return null;++ }++ const from = hm(rule.from);++ const to = rule.to ? hm(rule.to) : { h: 23, min: 59 };++ const startMin = from.h * 60 + from.min;++ const endMin = to.h * 60 + to.min;++ for (let k = 0; k < 15; k++) {++ const day = addDays(today.y, today.m, today.d, k);++ if (rule.days && rule.days.length && !rule.days.includes(day.dow)) continue;++ for (let mins = startMin; mins <= endMin; mins += stepMin) {++ const at = atLocal(day.y, day.m, day.d, Math.floor(mins / 60), mins % 60, tz);++ if (at > a) return at;++ }++ }++ return null;++ }++ if (rule.kind === 'monthly') {++ const { h, min } = hm(rule.time || '09:00');++ for (let k = 0; k < 14; k++) {++ const mm = ((today.m - 1 + k) % 12) + 1;++ const yy = today.y + Math.floor((today.m - 1 + k) / 12);++ const dd = Math.min(Number(rule.day) || 1, lastDay(yy, mm));++ const at = atLocal(yy, mm, dd, h, min, tz);++ if (at > a) return at;++ }++ return null;++ }++ return null;++}++++// ── Words ─────────────────────────────────────────────────────────────────────────────────────────────────────────++const two = (n) => String(n).padStart(2, '0');++/** 9 am, 9:30 am, 6 pm, 12 pm (noon), 12 am (midnight). */++const h12 = (h, min) => `${h % 12 || 12}${min ? `:${two(min)}` : ''} ${h < 12 ? 'am' : 'pm'}`;++const clock = (s) => { const { h, min } = hm(s); return h12(h, min); };++const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];++const listOf = (a) => (a.length < 2 ? a.join('') : `${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}`);++function daysWords(days) {++ if (!days || !days.length || days.length === 7) return '';++ const set = days.slice().sort().join(',');++ if (set === WEEKDAYS.join(',')) return 'weekdays';++ if (set === WEEKENDS.join(',')) return 'weekends';++ return listOf(days.slice().sort().map((d) => `${DAY_NAMES[d]}s`));++}++function everyMinutes(n) {++ if (n % 1440 === 0) return n === 1440 ? 'Every day' : `Every ${n / 1440} days`;++ if (n % 60 === 0) return n === 60 ? 'Every hour' : `Every ${n / 60} hours`;++ return `Every ${n} minutes`;++}++function describe(rule, { tz = 'UTC', now = new Date() } = {}) {++ if (!rule) return '';++ if (rule.kind === 'once') {++ const t = new Date(rule.at);++ const p = partsIn(t, tz), n = partsIn(now, tz);++ const tomorrow = addDays(n.y, n.m, n.d, 1);++ const day = p.y === n.y && p.m === n.m && p.d === n.d ? 'Today' : (p.y === tomorrow.y && p.m === tomorrow.m && p.d === tomorrow.d ? 'Tomorrow' : `${DAY_NAMES[p.dow].slice(0, 3)}, ${MONTHS[p.m]} ${p.d}`);++ return `${day} at ${h12(p.h, p.min)}`;++ }++ if (rule.kind === 'daily') {++ const d = daysWords(rule.days);++ const at = listOf((rule.times || []).map(clock));++ return d ? `${d[0].toUpperCase()}${d.slice(1)} at ${at}` : `Every day at ${at}`;++ }++ if (rule.kind === 'interval') {++ const d = daysWords(rule.days);++ const win = rule.from || rule.to ? `, ${clock(rule.from || '00:00')}–${clock(rule.to || '23:59')}` : '';++ return `${everyMinutes(rule.minutes)}${win}${d ? `, ${d}` : ''}`;++ }++ if (rule.kind === 'monthly') {++ const n = Number(rule.day);++ const th = n % 10 === 1 && n !== 11 ? 'st' : n % 10 === 2 && n !== 12 ? 'nd' : n % 10 === 3 && n !== 13 ? 'rd' : 'th';++ return `Every month on the ${n}${th} at ${clock(rule.time || '09:00')}`;++ }++ return '';++}++++// A time of day in words: "3pm", "3:30 pm", "15:30", "noon", "midnight", or a part of the day.++const TIME = String.raw`(?:(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.)?|noon|midday|midnight)`;++function toClock(m, parts, fallbackPart) {++ if (!m) return null;++ const word = String(m[0]).toLowerCase();++ if (/noon|midday/.test(word)) return '12:00';++ if (/midnight/.test(word)) return '00:00';++ let h = Number(m[1]);++ const min = m[2] ? Number(m[2]) : 0;++ const ap = m[3] ? m[3].replace(/\./g, '').toLowerCase() : null;++ if (ap === 'pm' && h < 12) h += 12;++ if (ap === 'am' && h === 12) h = 0;++ // "at 5" with no am/pm in the afternoon or evening part of a sentence reads as 5 pm; plain hours under 7 do too.++ if (!ap && h >= 1 && h <= 6 && !m[2] && fallbackPart !== 'morning') h += 12;++ if (h > 23 || min > 59) return null;++ return `${two(h)}:${two(min)}`;++}++++function parse(text, { now = new Date(), tz = 'UTC', parts = PARTS } = {}) {++ const zone = validTz(tz) ? tz : 'UTC';++ const P = { ...PARTS, ...(parts || {}) };++ let s = ` ${String(text || '').replace(/\s+/g, ' ').trim()} `;++ if (!s.trim()) return { error: 'Say what to remind you of, and when: "stretch every 2 hours from 9 to 5".' };++ const lower = () => s.toLowerCase();++ const cut = (re) => { const m = re.exec(s); if (m) s = `${s.slice(0, m.index)} ${s.slice(m.index + m[0].length)}`; return m; };++ let rule = null;++ let days = null;++ let times = [];++ let partWord = null;++++ // Days of the week: "weekdays", "weekends", "every Monday and Thursday", "on Friday".++ let m;++ if ((m = cut(/\b(?:on\s+)?(?:every\s+)?week ?days\b/i))) days = WEEKDAYS;++ else if ((m = cut(/\b(?:on\s+)?(?:every\s+)?week ?ends?\b/i))) days = WEEKENDS;++ const dayRe = /\b(?:(every|each|on)\s+)?((?:mon|tues?|wed|thu(?:rs?)?|fri|sat|sun)(?:day)?s?)((?:\s*(?:,|and|&)\s*(?:mon|tues?|wed|thu(?:rs?)?|fri|sat|sun)(?:day)?s?)*)\b/i;++ let onceDay = null;++ if (!days && (m = dayRe.exec(s))) {++ const names = `${m[2]} ${m[3] || ''}`.toLowerCase().split(/[\s,&]+|and/).map((w) => w.replace(/s$/, '')).filter(Boolean);++ const list = [...new Set(names.map((w) => DAYS[w] || DAYS[`${w}day`] || DAYS[w.replace(/day$/, '')]).filter(Boolean))];++ const repeating = /every|each/i.test(m[1] || '') || /s$/i.test(m[2]) || list.length > 1;++ if (list.length) {++ cut(dayRe);++ if (repeating) days = list; else onceDay = list[0];++ }++ }++++ // How often.++ if ((m = cut(/\b(?:every|each)\s+(\d+|an?|one|two|three|four|five|six|ten|fifteen|twenty|thirty|forty-five|ninety)?\s*(minutes?|mins?|hours?|hrs?)\b/i))) {++ const words = { a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, ten: 10, fifteen: 15, twenty: 20, thirty: 30, 'forty-five': 45, ninety: 90 };++ const n = m[1] ? (Number(m[1]) || words[m[1].toLowerCase()] || 1) : 1;++ const minutes = /^h/i.test(m[2]) ? n * 60 : n;++ if (minutes < 5) return { error: 'The shortest repeat is every 5 minutes.' };++ rule = { kind: 'interval', minutes };++ } else if ((m = cut(/\bhourly\b/i))) {++ rule = { kind: 'interval', minutes: 60 };++ } else if ((m = cut(/\b(?:on\s+the\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(?:of\s+)?(?:every|each)\s+month\b|\b(?:every\s+month|monthly)\s+on\s+the\s+(\d{1,2})(?:st|nd|rd|th)?\b|\bmonthly\b/i))) {++ const day = Number(m[1] || m[2] || 1);++ if (day < 1 || day > 31) return { error: 'A day of the month is 1 to 31.' };++ rule = { kind: 'monthly', day, time: null };++ } else if ((m = cut(/\b(?:every\s*day|daily|each\s+day|every\s+(morning|afternoon|evening|night))\b/i))) {++ rule = { kind: 'daily', times: [] };++ if (m[1]) partWord = m[1].toLowerCase();++ }++++ // A window: "from 9 to 5", "between 9am and 6pm".++ let from = null, to = null;++ const winRe = new RegExp(String.raw`\b(?:from|between)\s+${TIME}\s*(?:to|until|till|and|-|–)\s*${TIME}`, 'i');++ if ((m = winRe.exec(s))) {++ const f = toClock([m[0], m[1], m[2], m[3]], P, 'morning');++ let tt = toClock([m[0].slice(m[0].search(/(?:to|until|till|and|-|–)\s*/i)).replace(/^(?:to|until|till|and|-|–)\s*/i, ''), m[4], m[5], m[6]], P, 'afternoon');++ if (f && tt) {++ if (tt <= f && !m[6]) tt = `${two((hm(tt).h + 12) % 24)}:${two(hm(tt).min)}`;++ from = f; to = tt;++ cut(winRe);++ }++ }++++ // A relative time: "in 20 minutes", "in an hour", "in 2 days".++ if (!rule && (m = cut(/\bin\s+(\d+|an?|one|two|three|half an?)\s*(minutes?|mins?|hours?|hrs?|days?)\b/i))) {++ const words = { a: 1, an: 1, one: 1, two: 2, three: 3 };++ let n = /half/i.test(m[1]) ? 0.5 : (Number(m[1]) || words[m[1].toLowerCase()] || 1);++ const unit = m[2].toLowerCase();++ const ms = /^d/.test(unit) ? n * 86400000 : /^h/.test(unit) ? n * 3600000 : n * 60000;++ if (/half/i.test(m[1]) && /^m/.test(unit)) n = 30;++ rule = { kind: 'once', at: new Date(now.getTime() + Math.round(ms / 60000) * 60000).toISOString() };++ }++++ // Times of day: "at 3pm", "at 9 and 5", "noon", parts of the day ("tonight", "this evening", "in the morning").++ const atRe = new RegExp(String.raw`\b(?:at|@)\s+${TIME}(?:\s*(?:and|&|,)\s*${TIME})*`, 'i');++ if ((m = atRe.exec(s))) {++ const all = [...m[0].matchAll(new RegExp(TIME, 'gi'))].map((x) => toClock(x, P, partWord)).filter(Boolean);++ if (all.length) { times = all; cut(atRe); }++ } else if ((m = cut(/\b(noon|midday|midnight)\b/i))) {++ times = [toClock([m[1]], P)];++ }++ const partRe = /\b(?:this|in the|every|tomorrow|tonight)?\s*(morning|afternoon|evening|night|tonight)\b/i;++ let dayShift = 0;++ if ((m = cut(/\btomorrow\b/i))) dayShift = 1;++ if ((m = cut(/\b(?:today)\b/i))) dayShift = 0;++ if ((m = partRe.exec(s))) {++ const w = m[1].toLowerCase() === 'tonight' ? 'night' : m[1].toLowerCase();++ partWord = partWord || w;++ cut(partRe);++ }++ if (!times.length && partWord) times = [P[partWord]];++++ // Build the rule.++ if (rule && rule.kind === 'interval') {++ if (from) { rule.from = from; rule.to = to; }++ if (days) rule.days = days;++ if (!from) rule.start = new Date(now).toISOString();++ } else if (rule && rule.kind === 'monthly') {++ rule.time = times[0] || P.morning;++ } else if (rule && rule.kind === 'daily') {++ rule.times = times.length ? times : [P.morning];++ if (days) rule.days = days;++ } else if (!rule && (days || (from && to))) {++ if (from && to) return { error: 'Say how often in that window: "every hour from 9 to 5".' };++ rule = { kind: 'daily', times: times.length ? times : [P.morning], days };++ } else if (!rule) {++ // Once: a day (today, tomorrow, a weekday) and a time (or a part of the day).++ if (!times.length && !onceDay && !dayShift) return { error: 'Say when: "at 3pm", "tomorrow morning", "every day at 9" or "in 20 minutes".' };++ const n = partsIn(now, zone);++ let k = dayShift;++ if (onceDay) { k = (onceDay - n.dow + 7) % 7; }++ const t = hm(times[0] || P.morning);++ let day = addDays(n.y, n.m, n.d, k);++ let at = atLocal(day.y, day.m, day.d, t.h, t.min, zone);++ if (at <= now) { day = addDays(n.y, n.m, n.d, k + (onceDay ? 7 : 1)); at = atLocal(day.y, day.m, day.d, t.h, t.min, zone); }++ rule = { kind: 'once', at: at.toISOString() };++ }++++ // What is left is the title.++ let title = s.replace(/\s+/g, ' ').trim()++ .replace(/^(?:please\s+)?(?:remind\s+me|reminder|remember|set\s+a\s+reminder)(?:\s+(?:to|that|about|of))?\s*/i, '')++ .replace(/^(?:to|that)\s+/i, '')++ .replace(/\s+(?:at|on|every|from|in|for|the)$/i, '')++ .replace(/[,.;:\s]+$/, '')++ .trim();++ if (!title) return { error: 'Say what to remind you of, as well as when.' };++ title = `${title[0].toUpperCase()}${title.slice(1)}`.slice(0, MAX_TITLE);++ const nextAt = next(rule, { after: now, tz: zone, parts: P });++ if (!nextAt) return { error: 'That time has already passed.' };++ return { title, rule, tz: zone, next_at: nextAt.toISOString(), summary: describe(rule, { tz: zone, now }) };++}++++module.exports = { parse, next, describe, partsIn, atLocal, validTz, h12, PARTS };nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * A person's own reminders (plan L48, R1): plain words become a rule (server/when.js) that fires in the person's own++ * time zone, across a clock change; the API sets, lists, snoozes, stops and removes them, only for their owner; the++ * job sends one notification per time and moves the reminder on (a one-off is then done), never twice; /me has the++ * box and the list, and its forms work without the script.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const when = require('../server/when');++++(async () => {++ await check('plain words become a rule, a title and the next time, in the person\'s zone', () => {++ const now = new Date('2026-11-02T14:20:00Z'); // Monday 6:20 am in Los Angeles++ const tz = 'America/Los_Angeles';++ const p = (t) => when.parse(t, { now, tz });++ const a = p('remind me to stretch every 2 hours from 9 to 5');++ assert.deepStrictEqual([a.title, a.summary], ['Stretch', 'Every 2 hours, 9 am–5 pm']);++ assert.strictEqual(a.next_at, '2026-11-02T17:00:00.000Z', '9 am in Los Angeles');++ assert.strictEqual(p('take my meds every day at 9am and 9pm').summary, 'Every day at 9 am and 9 pm');++ assert.strictEqual(p('standup weekdays at 9:30').summary, 'Weekdays at 9:30 am');++ assert.strictEqual(p('stream every Monday and Thursday at 7pm').summary, 'Mondays and Thursdays at 7 pm');++ assert.strictEqual(p('pay rent on the 1st of every month').next_at, '2026-12-01T17:00:00.000Z');++ assert.strictEqual(p('check the oven in 20 minutes').next_at, '2026-11-02T14:40:00.000Z');++ assert.strictEqual(p('call mom on Sunday at 6pm').summary, 'Sun, Nov 8 at 6 pm');++ assert.strictEqual(p('walk the dog tomorrow morning').summary, 'Tomorrow at 9 am');++ assert.strictEqual(p('drink water every 30 minutes').next_at, '2026-11-02T14:50:00.000Z', 'every 30 minutes from now');++ assert.ok(p('water plants').error, 'no time: asked');++ assert.ok(p('at 3pm').error, 'no what: asked');++ assert.ok(p('ping every 2 minutes').error, 'too often: refused');++ });++++ await check('"every day at 9" stays 9 o\'clock across the clock change', () => {++ const tz = 'America/New_York'; // clocks go back on Sunday 1 November 2026++ const rule = { kind: 'daily', times: ['09:00'] };++ const sat = when.next(rule, { after: new Date('2026-10-31T13:30:00Z'), tz }); // after 9:30 am EDT++ assert.strictEqual(sat.toISOString(), '2026-11-01T14:00:00.000Z', '9 am EST on Sunday is 14:00 UTC');++ const sun = when.next(rule, { after: sat, tz });++ assert.strictEqual(sun.toISOString(), '2026-11-02T14:00:00.000Z');++ assert.strictEqual(when.next({ kind: 'monthly', day: 31, time: '09:00' }, { after: new Date('2026-11-05T00:00:00Z'), tz: 'UTC' }).toISOString(), '2026-11-30T09:00:00.000Z', 'a month without the 31st uses its last day');++ });++++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const db = t.ctx.s.db;++ try {++ await check('the API sets, lists, snoozes, stops and removes a person\'s own reminders', async () => {++ const parsed = await t.get('/api/v1/reminders/parse', { as: alice, json: { text: 'stretch every 2 hours from 9 to 5', tz: 'Europe/Berlin' } });++ assert.strictEqual(parsed.status, 200, parsed.text);++ assert.strictEqual(parsed.json().summary, 'Every 2 hours, 9 am–5 pm');++ const vague = await t.get('/api/v1/reminders/parse', { as: alice, json: { text: 'water plants', tz: 'Europe/Berlin' } });++ assert.strictEqual(vague.status, 422);++ const made = await t.get('/api/v1/reminders', { as: alice, json: { text: 'stretch every 2 hours from 9 to 5', tz: 'Europe/Berlin', url: 'https://openvibe.live/@alice', source: 'live' } });++ assert.strictEqual(made.status, 201, made.text);++ const r = made.json();++ assert.ok(/^rem_/.test(r.id) && r.repeats && r.url === 'https://openvibe.live/@alice' && r.source === 'live');++ const unsafe = await t.get('/api/v1/reminders', { as: alice, json: { text: 'x at 3pm', tz: 'UTC', url: 'javascript:alert(1)' } });++ assert.strictEqual(unsafe.json().url, null, 'only an https link is kept');++ const list = (await t.get('/api/v1/reminders', { as: alice })).json().reminders;++ assert.ok(list.some((x) => x.id === r.id));++ assert.strictEqual((await t.get(`/api/v1/reminders/${r.id}/done`, { as: bob, json: {} })).status, 404, 'another person cannot touch it');++ const snoozed = (await t.get(`/api/v1/reminders/${r.id}/snooze`, { as: alice, json: { minutes: 60 } })).json();++ assert.ok(Math.abs(new Date(snoozed.next_at).getTime() - (Date.now() + 3600000)) < 120000, 'an hour from now');++ const stopped = (await t.get(`/api/v1/reminders/${r.id}/done`, { as: alice, json: {} })).json();++ assert.strictEqual(stopped.status, 'done');++ assert.strictEqual((await t.get(`/api/v1/reminders/${r.id}`, { as: alice, method: 'DELETE' })).status, 204);++ assert.strictEqual(await db.value('SELECT count(*)::int FROM today_my_reminders WHERE id = $1', [r.id]), 0);++ });++++ await check('the job sends one notification per time and moves the reminder on; a one-off is then done', async () => {++ const rep = (await t.get('/api/v1/reminders', { as: alice, json: { text: 'drink water every 30 minutes', tz: 'UTC' } })).json();++ const once = (await t.get('/api/v1/reminders', { as: alice, json: { text: 'check the oven in 20 minutes', tz: 'UTC' } })).json();++ await db.exec("UPDATE today_my_reminders SET next_at = now() - interval '1 minute' WHERE id = ANY($1)", [[rep.id, once.id]]);++ const before = t.network.notifications.length;++ const [a, b] = await Promise.all([t.ctx.reminders.runOnce(), t.ctx.reminders.runOnce()]);++ assert.strictEqual(a.sent + b.sent, 2, 'two reminders, two notifications, never four');++ const sent = t.network.notifications.slice(before);++ assert.deepStrictEqual(sent.map((n) => n.title).sort(), ['Check the oven', 'Drink water']);++ assert.ok(sent.every((n) => n.type === 'TODAY_REMINDER' && n.service === 'today' && n.user_id === alice.id));++ assert.match(sent.find((n) => n.title === 'Drink water').message, /Every 30 minutes/);++ const after = await db.many('SELECT id, status, next_at, sent_count FROM today_my_reminders WHERE id = ANY($1)', [[rep.id, once.id]]);++ const repRow = after.find((x) => x.id === rep.id), onceRow = after.find((x) => x.id === once.id);++ assert.ok(repRow.status === 'active' && new Date(repRow.next_at) > new Date(), 'the repeat moved on');++ assert.ok(onceRow.status === 'done' && onceRow.next_at === null, 'the one-off is done');++ assert.strictEqual(Number(repRow.sent_count), 1);++ });++++ await check('/me has the box and the list, and its forms work without the script', async () => {++ const me = await t.get('/me', { as: bob });++ assert.match(me.text, /<section class="td-card td-remind" id="reminders"/);++ assert.match(me.text, /<form method="post" action="\/me\/reminders" class="td-remind-form" data-remind-preview="\/api\/v1\/reminders\/parse">/);++ const add = await t.get('/me/reminders', { as: bob, form: { text: 'call mom on Sunday at 6pm', tz: 'America/Chicago' } });++ assert.strictEqual(add.status, 303);++ assert.strictEqual(add.headers.get('location'), '/me#reminders');++ const page = await t.get('/me', { as: bob });++ assert.match(page.text, /<b>Call mom<\/b>[\s\S]*?at 6 pm/);++ const bad = await t.get('/me/reminders', { as: bob, form: { text: 'water plants', tz: 'UTC' } });++ assert.match(decodeURIComponent(bad.headers.get('location')), /problem=Say when/);++ const id = await db.value("SELECT id FROM today_my_reminders WHERE subject = $1 AND title = 'Call mom'", [bob.subject]);++ const foreign = await t.get(`/me/reminders/${id}/remove`, { as: bob, form: { x: '1' }, headers: { origin: 'https://evil.example' } });++ assert.strictEqual(foreign.status, 403, 'a post from another site is refused');++ const rm = await t.get(`/me/reminders/${id}/remove`, { as: bob, form: { x: '1' } });++ assert.strictEqual(rm.status, 303);++ assert.strictEqual(await db.value('SELECT count(*)::int FROM today_my_reminders WHERE id = $1', [id]), 0);++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+-- phase: expand
+-- A person's own reminders (plan L48, R1): "stretch every 2 hours from 9 to 5", "take my meds every day at 9 am and
+-- 9 pm", "call mom on Sunday at 6 pm". The words are parsed once (server/when.js) into a rule; next_at is the next
+-- time it fires, in the person's own time zone, and the job claims a due row by moving next_at on in one UPDATE, so
+-- two processes never send twice. Private to the person: exported and erased with the account.
+CREATE TABLE IF NOT EXISTS today_my_reminders (
+ id text COLLATE "C" PRIMARY KEY, -- rem_ + ULID
+ subject text COLLATE "C" NOT NULL, -- usr_… the person
+ title text NOT NULL, -- what to remind them of, in their words
+ said text NOT NULL, -- what they typed or said, kept to show and edit
+ rule jsonb NOT NULL, -- server/when.js: once | daily | interval | monthly
+ tz text NOT NULL, -- IANA zone, validated with Intl
+ url text, -- where it came from (a stream, a job, a page), https only
+ source text COLLATE "C" NOT NULL DEFAULT 'today', -- today | vibe | live | work | … (the site that set it)
+ status text COLLATE "C" NOT NULL DEFAULT 'active', -- active | done
+ next_at timestamptz, -- null when it is done
+ last_sent_at timestamptz,
+ sent_count integer NOT NULL DEFAULT 0,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT today_my_reminders_status CHECK (status IN ('active', 'done'))
+);
+CREATE INDEX IF NOT EXISTS today_my_reminders_due ON today_my_reminders (next_at) WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS today_my_reminders_by_subject ON today_my_reminders (subject, created_at);
@@ -195,3 +195,31 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
@@ -195,3 +195,31 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
.td-hol-day a:hover { background: var(--bg-hover, var(--ovk-panel-2, #1a2233)); }
.td-hol-day a:hover { background: var(--bg-hover, var(--ovk-panel-2, #1a2233)); }
.td-hol-day span { color: var(--text-secondary, #a8b3c4); font-size: 14px; }
.td-hol-day span { color: var(--text-secondary, #a8b3c4); font-size: 14px; }
@media (max-width: 560px) { .td-hol-day { grid-template-columns: 1fr; gap: 4px; } .td-hol-date { padding-top: 0; } }
@media (max-width: 560px) { .td-hol-day { grid-template-columns: 1fr; gap: 4px; } .td-hol-date { padding-top: 0; } }
+
+/* Remind me (plan L48, R1): one box in plain words, what it understood under it, the list with its few actions. */
+.td-remind { margin: 0 0 18px; }
+.td-remind-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; margin-top: 6px; }
+.td-remind-form input[name="text"] { min-width: 0; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: inherit; font: inherit; font-size: 1rem; }
+.td-remind-form input[name="text"]:focus { outline: 2px solid var(--room-strong, var(--accent, #4f8cff)); outline-offset: 1px; }
+.td-remind-out { grid-column: 1 / -1; margin: 0; font-size: .86rem; color: var(--text-muted, #7386a3); min-height: 1.3em; }
+.td-remind-out.is-ok { color: var(--text-primary, #e8edf3); }
+.td-remind-out.is-bad { color: #f87171; }
+.td-mine { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }
+.td-mine-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--room-strong, #4f8cff) 6%, transparent); }
+.td-mine-row.is-done { opacity: .65; }
+.td-mine-ico .ovk-i { width: 18px; height: 18px; color: var(--room-strong); }
+.td-mine-main { display: grid; gap: 2px; min-width: 0; }
+.td-mine-main b { overflow-wrap: anywhere; }
+.td-mine-main a { color: inherit; }
+.td-mine-main small { color: var(--text-muted, #7386a3); }
+.td-mine-acts { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
+.td-chip-btn { padding: 6px 10px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); background: transparent; color: inherit; font: inherit; font-size: .8rem; cursor: pointer; min-height: 32px; }
+.td-chip-btn:hover { background: var(--bg-hover, rgba(127,140,160,.12)); }
+.td-mine-done { margin-top: 10px; }
+.td-mine-done summary { cursor: pointer; color: var(--text-muted, #7386a3); font-size: .86rem; }
+.td-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+@media (max-width: 560px) {
+ .td-remind-form { grid-template-columns: minmax(0, 1fr); }
+ .td-mine-row { grid-template-columns: auto minmax(0, 1fr); }
+ .td-mine-acts { grid-column: 1 / -1; justify-content: flex-start; }
+}
@@ -62,5 +62,36 @@
@@ -62,5 +62,36 @@
});
});
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();
+ // Remind me: the form's time zone is the device's, and the words are read back as they are typed.
+ function remindForm() {
+ document.querySelectorAll('[data-tz]').forEach(function (el) { el.value = zone; });
+ var form = document.querySelector('[data-remind-preview]');
+ if (!form || form.getAttribute('data-ready')) return;
+ form.setAttribute('data-ready', '1');
+ var input = form.querySelector('input[name="text"]'), out = form.querySelector('[data-remind-out]');
+ var hint = out ? out.textContent : '', timer = 0, seq = 0;
+ input.addEventListener('input', function () {
+ clearTimeout(timer);
+ var text = input.value.trim();
+ if (!text) { out.textContent = hint; out.classList.remove('is-bad', 'is-ok'); return; }
+ timer = setTimeout(function () {
+ var mine = ++seq;
+ fetch(form.getAttribute('data-remind-preview'), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ text: text, tz: zone }) })
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
+ .then(function (x) {
+ if (mine !== seq) return;
+ if (!x.ok) { out.textContent = x.d.error || 'Say what, and when.'; out.classList.add('is-bad'); out.classList.remove('is-ok'); return; }
+ out.textContent = '“' + x.d.title + '”: ' + x.d.summary + ' · first ' + fmt(new Date(x.d.next_at), { weekday: 'short', hour: 'numeric', minute: '2-digit' });
+ out.classList.add('is-ok'); out.classList.remove('is-bad');
+ }).catch(function () { /* the form still works */ });
+ }, 300);
+ });
+ document.querySelectorAll('time[data-local="when"]').forEach(function (el) {
+ var d = new Date(el.getAttribute('datetime'));
+ if (!isNaN(d)) el.textContent = fmt(d, { weekday: 'short', hour: 'numeric', minute: '2-digit' });
+ });
+ }
+ document.addEventListener('ov:boost:load', remindForm);
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', remindForm); else remindForm();
document.addEventListener('ov:boost:load', run);
document.addEventListener('ov:boost:load', run);
})();
})();
@@ -70,7 +70,8 @@ async function createApp(opts = {}) {
@@ -70,7 +70,8 @@ async function createApp(opts = {}) {
const agenda = createAgenda({ s, follows });
const agenda = createAgenda({ s, follows });
const brief = createBrief({ config, s, agenda, fetchImpl, log });
const brief = createBrief({ config, s, agenda, fetchImpl, log });
const notifier = createNotifier({ config, s, fetchImpl, log });
const notifier = createNotifier({ config, s, fetchImpl, log });
- const reminders = createReminders({ config, s, events, notifier, log });
+ const mine = require('./my-reminders').createMyReminders({ s });
+ const reminders = createReminders({ config, s, events, mine, notifier, log });
const publisher = createPublisher({ config, s, fetchImpl, log });
const publisher = createPublisher({ config, s, fetchImpl, log });
const imports = createImports({ config, s, fetchImpl, log, now: s.now });
const imports = createImports({ config, s, fetchImpl, log, now: s.now });
@@ -83,7 +84,7 @@ async function createApp(opts = {}) {
@@ -83,7 +84,7 @@ async function createApp(opts = {}) {
const consumer = createEventsConsumer({ config, s, accountData, accountSend, now: s.now, log });
const consumer = createEventsConsumer({ config, s, accountData, accountSend, now: s.now, log });
const ctx = {
const ctx = {
- config, s, keys, sso, principal, events, feeds, follows, agenda, brief, notifier, reminders, publisher,
+ config, s, keys, sso, principal, events, feeds, follows, agenda, brief, notifier, reminders, mine, publisher,
imports, accountData, accountSend, consumer, log,
imports, accountData, accountSend, consumer, log,
};
};
@@ -15,6 +15,11 @@
@@ -15,6 +15,11 @@
* GET /me/feed the private ICS URL, created on first call
* GET /me/feed the private ICS URL, created on first call
* POST /me/feed/rotate replace it (the old URL is 404 at once)
* POST /me/feed/rotate replace it (the old URL is 404 at once)
* GET /me/brief today's brief, built on demand when missing
* GET /me/brief today's brief, built on demand when missing
+ * POST /reminders/parse what plain words mean: { text, tz } → { title, summary, next_at, rule }
+ * GET /reminders the caller's own reminders (active first)
+ * POST /reminders set one from plain words: { text, tz, url?, source? }
+ * POST /reminders/:id/done · /snooze done, or later by { minutes }
+ * DELETE /reminders/:id remove it
*
*
* A person is the session cookie (today_at) or Authorization: Bearer <Network user token>, verified offline with
* A person is the session cookie (today_at) or Authorization: Bearer <Network user token>, verified offline with
* the Network JWKS. A write made with the session cookie must come from openvibe.today itself (403
* the Network JWKS. A write made with the session cookie must come from openvibe.today itself (403
@@ -26,7 +31,7 @@ const { sameOrigin } = require('./principal');
@@ -26,7 +31,7 @@ const { sameOrigin } = require('./principal');
const { parseTime, isIanaZone } = require('../events/validate');
const { parseTime, isIanaZone } = require('../events/validate');
function createApi(ctx) {
function createApi(ctx) {
- const { config, s, principal, events, feeds, follows, agenda, brief } = ctx;
+ const { config, s, principal, events, feeds, follows, agenda, brief, mine } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const invalid = (res, field, message) => res.status(400).json({ code: 'today.invalid', field, message });
const invalid = (res, field, message) => res.status(400).json({ code: 'today.invalid', field, message });
@@ -137,6 +142,33 @@ function createApi(ctx) {
@@ -137,6 +142,33 @@ function createApi(ctx) {
res.json({ reminders: await events.listReminders(req.principal.subject) });
res.json({ reminders: await events.listReminders(req.principal.subject) });
});
});
+ // ── A person's own reminders (plan L48, R1) ────────────
+ const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
+ r.post('/reminders/parse', requirePerson, (req, res) => {
+ const b = body(req);
+ const out = mine.preview({ text: b.text, tz: b.tz });
+ return out.error ? res.status(422).json({ error: out.error }) : res.json(out);
+ });
+ r.get('/reminders', requirePerson, async (req, res) => {
+ res.set('Cache-Control', 'private, no-store').json({ reminders: await mine.list(req.principal.subject) });
+ });
+ r.post('/reminders', requirePerson, async (req, res) => {
+ const b = body(req);
+ const out = await mine.create(req.principal.subject, { text: b.text, tz: b.tz, url: b.url, source: b.source });
+ return out.error ? res.status(422).json({ error: out.error }) : res.status(201).json(out);
+ });
+ r.post('/reminders/:id/done', requirePerson, async (req, res) => {
+ const out = await mine.done(req.principal.subject, String(req.params.id));
+ return out ? res.json(out) : res.status(404).json({ error: 'No such reminder' });
+ });
+ r.post('/reminders/:id/snooze', requirePerson, async (req, res) => {
+ const out = await mine.snooze(req.principal.subject, String(req.params.id), body(req).minutes);
+ return out ? res.json(out) : res.status(404).json({ error: 'No such reminder' });
+ });
+ r.delete('/reminders/:id', requirePerson, async (req, res) => {
+ return (await mine.remove(req.principal.subject, String(req.params.id))) ? res.status(204).end() : res.status(404).json({ error: 'No such reminder' });
+ });
+
r.get('/me/agenda', requirePerson, async (req, res) => {
r.get('/me/agenda', requirePerson, async (req, res) => {
const raw = req.query.days == null || req.query.days === '' ? 14 : Number.parseInt(req.query.days, 10);
const raw = req.query.days == null || req.query.days === '' ? 14 : Number.parseInt(req.query.days, 10);
if (!Number.isFinite(raw) || raw < 1 || raw > 60) return invalid(res, 'days', 'days must be a whole number from 1 to 60');
if (!Number.isFinite(raw) || raw < 1 || raw > 60) return invalid(res, 'days', 'days must be a whole number from 1 to 60');
@@ -115,7 +115,7 @@ function createPublicPageViews(ctx) {
@@ -115,7 +115,7 @@ function createPublicPageViews(ctx) {
}
}
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, s, events, feeds, agenda, brief } = ctx;
+ const { config, s, events, feeds, agenda, brief, mine } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const base = () => String(config.baseUrl).replace(/\/+$/, '');
const base = () => String(config.baseUrl).replace(/\/+$/, '');
@@ -203,13 +203,35 @@ function createPageRoutes(ctx) {
@@ -203,13 +203,35 @@ function createPageRoutes(ctx) {
const a = await agenda.forSubject({ subject: p.subject, token: p.token, days: 14, now: s.now() });
const a = await agenda.forSubject({ subject: p.subject, token: p.token, days: 14, now: s.now() });
const reminders = await events.listReminders(p.subject);
const reminders = await events.listReminders(p.subject);
const token = await feeds.getOrCreate(p.subject);
const token = await feeds.getOrCreate(p.subject);
+ const own = mine ? await mine.list(p.subject) : [];
return page(req, res, {
return page(req, res, {
title: 'Your agenda',
title: 'Your agenda',
description: 'Your own events, the reminders you set, the channels you follow and your private calendar feed.',
description: 'Your own events, the reminders you set, the channels you follow and your private calendar feed.',
- body: raw(views.me({ name: req.viewer.displayName || req.viewer.username, agenda: a, reminders, feedUrl: feeds.url(config, token), now: s.now() })),
+ body: raw(views.me({ name: req.viewer.displayName || req.viewer.username, agenda: a, reminders, feedUrl: feeds.url(config, token), now: s.now(), mine: own, problem: String(req.query.problem || '').slice(0, 200) || null, said: String(req.query.said || '').slice(0, 500) })),
});
});
});
});
+ // ── A person's own reminders (plan L48, R1): plain forms, so the page works without its script ──
+ const mineGuard = (req, res, next) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fme%23reminders');
+ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: raw(views.notFound()) }, 403);
+ return next();
+ };
+ const toMine = (res, extra = '') => res.redirect(303, `/me${extra}#reminders`);
+ r.post('/me/reminders', express.urlencoded({ extended: false, limit: '8kb' }), mineGuard, async (req, res) => {
+ const b = req.body || {};
+ const out = await mine.create(req.viewer.subject, { text: b.text, tz: b.tz, source: 'today' });
+ if (out.error) return toMine(res, `?problem=${encodeURIComponent(out.error)}&said=${encodeURIComponent(String(b.text || '').slice(0, 500))}`);
+ return toMine(res);
+ });
+ r.post('/me/reminders/:id/:act(done|snooze|remove)', express.urlencoded({ extended: false, limit: '2kb' }), mineGuard, async (req, res) => {
+ const id = String(req.params.id || '');
+ if (req.params.act === 'done') await mine.done(req.viewer.subject, id);
+ else if (req.params.act === 'snooze') await mine.snooze(req.viewer.subject, id, (req.body || {}).minutes || 60);
+ else await mine.remove(req.viewer.subject, id);
+ return toMine(res);
+ });
+
r.post('/me/feed/rotate', async (req, res) => {
r.post('/me/feed/rotate', async (req, res) => {
if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>${signInPrompt('A feed belongs to an account.', '/me')}` }, 401);
if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>${signInPrompt('A feed belongs to an account.', '/me')}` }, 401);
if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: html`<h1>That came from somewhere else</h1><p class="muted">Replace a feed from <a href="/me">your agenda</a>.</p>` }, 403);
if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: html`<h1>That came from somewhere else</h1><p class="muted">Replace a feed from <a href="/me">your agenda</a>.</p>` }, 403);
@@ -16,6 +16,7 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
@@ -16,6 +16,7 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
const TABLES = [
{ table: 'today_events', subject: 'owner_subject', value: (usr) => usr, file: 'events.json', erase: 'delete' },
{ table: 'today_events', subject: 'owner_subject', value: (usr) => usr, file: 'events.json', erase: 'delete' },
{ table: 'today_reminders', subject: 'subject', value: (usr) => usr, file: 'reminders.json', erase: 'delete' },
{ table: 'today_reminders', subject: 'subject', value: (usr) => usr, file: 'reminders.json', erase: 'delete' },
+ { table: 'today_my_reminders', subject: 'subject', value: (usr) => usr, file: 'my-reminders.json', erase: 'delete' },
// The token itself is never exported; it is a bearer capability for the person's calendar.
// The token itself is never exported; it is a bearer capability for the person's calendar.
{ table: 'today_feeds', subject: 'subject', value: (usr) => usr, file: 'feeds.json', columns: ['subject', 'created_at'], erase: 'delete' },
{ table: 'today_feeds', subject: 'subject', value: (usr) => usr, file: 'feeds.json', columns: ['subject', 'created_at'], erase: 'delete' },
{ table: 'today_briefs', subject: 'subject', value: (usr) => usr, file: 'briefs.json', erase: 'delete' },
{ table: 'today_briefs', subject: 'subject', value: (usr) => usr, file: 'briefs.json', erase: 'delete' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+/**
+ * A person's own reminders (plan L48, R1). The words are parsed by server/when.js; this keeps them, lists them,
+ * marks them done, snoozes them, and hands the job the ones that are due.
+ *
+ * createMyReminders({ s })
+ * preview({ text, tz, now }) → { title, rule, tz, next_at, summary } or { error }
+ * create(subject, { text, tz, url, source }) → the reminder, or { error } (a person keeps at most MAX_ACTIVE)
+ * list(subject) → active first (soonest first), then the last done ones
+ * done(subject, id) · snooze(subject, id, minutes) · remove(subject, id)
+ * due(now) · claim(row, now) · release(row) the job's side: claim moves next_at on in one UPDATE
+ */
+const when = require('./when');
+
+const MAX_ACTIVE = 200;
+const SOURCES = /^[a-z][a-z0-9-]{1,30}$/;
+const safeUrl = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' && String(u).length <= 2000 ? x.toString() : null; } catch { return null; } };
+
+function wire(r, { now = Date.now() } = {}) {
+ const rule = typeof r.rule === 'string' ? JSON.parse(r.rule) : r.rule;
+ const iso = (v) => (v ? new Date(v).toISOString() : null);
+ return {
+ id: r.id, title: r.title, said: r.said, rule, tz: r.tz, url: r.url || null, source: r.source, status: r.status,
+ next_at: iso(r.next_at), last_sent_at: iso(r.last_sent_at), sent_count: Number(r.sent_count) || 0,
+ summary: when.describe(rule, { tz: r.tz, now: new Date(now) }), repeats: rule.kind !== 'once',
+ created_at: iso(r.created_at),
+ };
+}
+
+function createMyReminders({ s }) {
+ const db = s.db;
+
+ function preview({ text, tz, now = s.now() }) {
+ return when.parse(String(text || '').slice(0, 500), { now: new Date(now), tz: when.validTz(tz) ? tz : 'UTC' });
+ }
+
+ async function create(subject, { text, tz, url = null, source = 'today' }) {
+ const p = preview({ text, tz });
+ if (p.error) return { error: p.error };
+ const active = Number(await db.value("SELECT count(*)::int FROM today_my_reminders WHERE subject = $1 AND status = 'active'", [subject]));
+ if (active >= MAX_ACTIVE) return { error: `You have ${MAX_ACTIVE} reminders already. Mark some done first.` };
+ const id = s.newId('rem');
+ const row = await db.maybe(
+ `INSERT INTO today_my_reminders (id, subject, title, said, rule, tz, url, source, next_at)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9) RETURNING *`,
+ [id, subject, p.title, String(text).trim().slice(0, 500), JSON.stringify(p.rule), p.tz, safeUrl(url), SOURCES.test(String(source)) ? source : 'today', p.next_at],
+ );
+ return wire(row, { now: s.now() });
+ }
+
+ async function list(subject) {
+ const rows = await db.many(
+ `(SELECT * FROM today_my_reminders WHERE subject = $1 AND status = 'active' ORDER BY next_at ASC LIMIT ${MAX_ACTIVE})
+ UNION ALL
+ (SELECT * FROM today_my_reminders WHERE subject = $1 AND status = 'done' ORDER BY updated_at DESC LIMIT 10)`,
+ [subject],
+ );
+ return rows.map((r) => wire(r, { now: s.now() }));
+ }
+
+ async function done(subject, id) {
+ const r = await db.maybe("UPDATE today_my_reminders SET status = 'done', next_at = NULL, updated_at = now() WHERE id = $1 AND subject = $2 RETURNING *", [id, subject]);
+ return r ? wire(r) : null;
+ }
+
+ /** Later by `minutes` from now (5 minutes to a week). A repeating one carries on from its rule after that. */
+ async function snooze(subject, id, minutes) {
+ const m = Math.min(7 * 24 * 60, Math.max(5, Math.round(Number(minutes) || 0)));
+ const at = new Date(s.now() + m * 60_000).toISOString();
+ const r = await db.maybe("UPDATE today_my_reminders SET status = 'active', next_at = $3, updated_at = now() WHERE id = $1 AND subject = $2 RETURNING *", [id, subject, at]);
+ return r ? wire(r) : null;
+ }
+
+ async function remove(subject, id) {
+ const r = await db.maybe('DELETE FROM today_my_reminders WHERE id = $1 AND subject = $2 RETURNING id', [id, subject]);
+ return !!r;
+ }
+
+ async function due(now = s.now(), limit = 500) {
+ return db.many("SELECT * FROM today_my_reminders WHERE status = 'active' AND next_at <= $1 ORDER BY next_at ASC LIMIT $2", [new Date(now).toISOString(), limit]);
+ }
+
+ /**
+ * Claim one due reminder: next_at moves on to the rule's next time after now (or the reminder is done) in one
+ * UPDATE that only matches the next_at the job read, so a second process gets nothing. → the claimed row or null.
+ */
+ async function claim(row, now = s.now()) {
+ const rule = typeof row.rule === 'string' ? JSON.parse(row.rule) : row.rule;
+ // A one-off is done once it has been sent, even when it was snoozed past its own time.
+ const after = rule.kind === 'once' ? null : when.next(rule, { after: new Date(now), tz: row.tz });
+ return db.maybe(
+ `UPDATE today_my_reminders
+ SET next_at = $3, status = CASE WHEN $3::timestamptz IS NULL THEN 'done' ELSE status END,
+ last_sent_at = $4, sent_count = sent_count + 1, updated_at = now()
+ WHERE id = $1 AND status = 'active'
+ -- PostgreSQL keeps microseconds and a JS Date has milliseconds: compare at the coarser of the two.
+ AND date_trunc('milliseconds', next_at) = date_trunc('milliseconds', $2::timestamptz) RETURNING *`,
+ [row.id, row.next_at, after ? after.toISOString() : null, new Date(now).toISOString()],
+ );
+ }
+
+ /** A send that failed: the reminder is due again on the next tick. */
+ async function release(row) {
+ await db.exec("UPDATE today_my_reminders SET next_at = $2, status = 'active', sent_count = GREATEST(sent_count - 1, 0) WHERE id = $1", [row.id, row.next_at]);
+ }
+
+ return { preview, create, list, done, snooze, remove, due, claim, release, wire, MAX_ACTIVE };
+}
+
+module.exports = { createMyReminders };
@@ -87,9 +87,40 @@ function createNotifier({ config, s, fetchImpl = globalThis.fetch, log = console
@@ -87,9 +87,40 @@ function createNotifier({ config, s, fetchImpl = globalThis.fetch, log = console
return { ok: true, user_id: userId, title };
return { ok: true, user_id: userId, title };
}
}
+ /** A person's own reminder: their words as the title, a link back to where it came from (or their reminders). */
+ async function sendMine({ subject, reminder }) {
+ if (!canSend) throw new Error('TODAY_OAUTH_CLIENT_SECRET is not set: Today cannot send a notification');
+ const ids = await resolveUserIds([subject]);
+ const userId = ids.get(subject);
+ if (!userId) {
+ log.warn(`[OpenVibe.Today] reminder for ${subject}: Network has no user id for that subject`);
+ return { ok: false, reason: 'no_user' };
+ }
+ const base = String(config.baseUrl).replace(/\/+$/, '');
+ const body = {
+ user_id: userId,
+ type: 'TODAY_REMINDER',
+ category: 'system',
+ priority: 'normal',
+ icon: '⏰',
+ title: String(reminder.title).slice(0, 200),
+ ...(reminder.repeats ? { message: `${reminder.summary}. Snooze or stop it on openvibe.today.` } : {}),
+ service: 'today',
+ url: reminder.url || `${base}/me#reminders`,
+ };
+ const res = await fetchImpl(`${config.networkInternalUrl}/internal/notifications/push`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await pushTokens.authHeaders()) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!res.ok) throw new Error(`notification push ${res.status}`);
+ return { ok: true, user_id: userId, title: body.title };
+ }
+
function clear(subject = null) { if (subject) cache.delete(subject); else cache.clear(); }
function clear(subject = null) { if (subject) cache.delete(subject); else cache.clear(); }
- return { sendReminder, resolveUserIds, clear, titleFor, canSend };
+ return { sendReminder, sendMine, resolveUserIds, clear, titleFor, canSend };
}
}
module.exports = { createNotifier };
module.exports = { createNotifier };
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* `UPDATE … SET sent_at = now() WHERE … AND sent_at IS NULL RETURNING`, so two processes never send twice. A send
* `UPDATE … SET sent_at = now() WHERE … AND sent_at IS NULL RETURNING`, so two processes never send twice. A send
* failure puts the claim back for the next tick; a cancelled or done event's reminder never sends.
* failure puts the claim back for the next tick; a cancelled or done event's reminder never sends.
*/
*/
-function createReminders({ config, s, events, notifier, log = console, intervalMs = 60_000 }) {
+function createReminders({ config, s, events, mine = null, notifier, log = console, intervalMs = 60_000 }) {
let timer = null;
let timer = null;
const stats = { sent: 0, failed: 0, skipped: 0, last_error: null, last_run: null };
const stats = { sent: 0, failed: 0, skipped: 0, last_error: null, last_run: null };
@@ -28,8 +28,28 @@ function createReminders({ config, s, events, notifier, log = console, intervalM
@@ -28,8 +28,28 @@ function createReminders({ config, s, events, notifier, log = console, intervalM
await events.releaseReminder(row.subject, row.event_id).catch(() => {});
await events.releaseReminder(row.subject, row.event_id).catch(() => {});
}
}
}
}
+ // A person's own reminders (plan L48): claimed by moving next_at on, so a repeat is never sent twice.
+ let mineDue = 0;
+ if (mine) {
+ const rows = await mine.due(now);
+ mineDue = rows.length;
+ for (const row of rows) {
+ const claimed = await mine.claim(row, now);
+ if (!claimed) { skipped++; continue; }
+ try {
+ const out = await notifier.sendMine({ subject: row.subject, reminder: mine.wire(claimed, { now }) });
+ if (out && out.ok) { sent++; stats.sent++; } else { skipped++; stats.skipped++; }
+ } catch (err) {
+ failed++;
+ stats.failed++;
+ stats.last_error = String((err && err.message) || err).slice(0, 300);
+ log.warn(`[OpenVibe.Today] reminder ${row.id} not sent: ${stats.last_error}`);
+ await mine.release(row).catch(() => {});
+ }
+ }
+ }
stats.last_run = new Date(now).toISOString();
stats.last_run = new Date(now).toISOString();
- return { due: due.length, sent, failed, skipped };
+ return { due: due.length + mineDue, sent, failed, skipped };
}
}
function start() {
function start() {
@@ -221,16 +221,43 @@ ${own && !done ? `<form method="post" action="/e/${esc(e.id)}/cancel" class="td-
@@ -221,16 +221,43 @@ ${own && !done ? `<form method="post" action="/e/${esc(e.id)}/cancel" class="td-
</article></div>`;
</article></div>`;
}
}
-function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now = Date.now() } = {}) {
+// A person's own reminders (plan L48, R1): one box in plain words with a preview of what it understood, and the list.
+const SOURCE_NAMES = { today: '', vibe: 'Set by Vibe', live: 'From Live', work: 'From Work', deals: 'From Deals', coupons: 'From Coupons', watch: 'From Watch', chat: 'From Chat' };
+function myReminders({ mine = [], problem = null, said = '' } = {}) {
+ const active = mine.filter((r) => r.status === 'active');
+ const done = mine.filter((r) => r.status === 'done');
+ const act = (r, path, label, extra = '') => `<form method="post" action="/me/reminders/${esc(r.id)}/${path}" class="td-inline">${extra}<button class="td-chip-btn" type="submit">${esc(label)}</button></form>`;
+ const row = (r) => `<li class="td-mine-row${r.status === 'done' ? ' is-done' : ''}">
+<span class="td-mine-ico" aria-hidden="true">${kit.icon(r.repeats ? 'clock' : 'bell')}</span>
+<span class="td-mine-main"><b>${r.url ? `<a href="${esc(r.url)}">${esc(r.title)}</a>` : esc(r.title)}</b>
+<small>${esc(r.summary)}${r.status === 'active' && r.next_at ? ` · next <time datetime="${esc(r.next_at)}" data-local="when">${esc(r.next_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ''}${SOURCE_NAMES[r.source] ? ` · ${esc(SOURCE_NAMES[r.source])}` : ''}</small></span>
+<span class="td-mine-acts">${r.status === 'active' ? `${act(r, 'snooze', 'Snooze 1 h', '<input type="hidden" name="minutes" value="60">')}${act(r, 'done', r.repeats ? 'Stop' : 'Done')}` : ''}${act(r, 'remove', 'Remove')}</span>
+</li>`;
+ return `<section class="td-card td-remind" id="reminders" aria-labelledby="td-mine-h">
+<h2 id="td-mine-h">${kit.icon('bell')}Remind me</h2>
+<form method="post" action="/me/reminders" class="td-remind-form" data-remind-preview="/api/v1/reminders/parse">
+<input type="hidden" name="tz" value="UTC" data-tz>
+<label class="td-sr" for="td-remind-text">What, and when, in your own words</label>
+<input id="td-remind-text" name="text" required maxlength="500" autocomplete="off" value="${esc(said)}" placeholder="Stretch every 2 hours from 9 to 5">
+<button class="ovk-btn ovk-btn--primary" type="submit">Remind me</button>
+<p class="td-remind-out${problem ? ' is-bad' : ''}" data-remind-out aria-live="polite">${problem ? esc(problem) : 'Try “take my meds every day at 9 am and 9 pm”, “call mom on Sunday at 6 pm” or “pay rent on the 1st of every month”.'}</p>
+</form>
+${active.length ? `<ul class="td-mine">${active.map(row).join('')}</ul>` : '<p class="td-fine">Nothing set yet. Reminders arrive in your bell on every OpenVibe site, and as a push notification if you allowed them.</p>'}
+${done.length ? `<details class="td-mine-done"><summary>Done (${done.length})</summary><ul class="td-mine">${done.map(row).join('')}</ul></details>` : ''}
+</section>`;
+}
+
+function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now = Date.now(), mine = [], problem = null, said = '' } = {}) {
const webcal = String(feedUrl).replace(/^https?:/, 'webcal:');
const webcal = String(feedUrl).replace(/^https?:/, 'webcal:');
return `<div class="td-wrap td-page-pad" id="td-me">
return `<div class="td-wrap td-page-pad" id="td-me">
<header class="td-page-h"><p class="td-kicker">Your agenda</p><h1>The next two weeks</h1>
<header class="td-page-h"><p class="td-kicker">Your agenda</p><h1>The next two weeks</h1>
<p class="td-lede">Your own events, the ones you set reminders on, and the channels you follow, ${esc(name || 'you')}.</p>
<p class="td-lede">Your own events, the ones you set reminders on, and the channels you follow, ${esc(name || 'you')}.</p>
<div class="td-acts">${kit.button({ href: '/schedule', label: 'Schedule a stream', kind: 'primary' })}${kit.button({ href: '/brief', label: 'Your brief', kind: 'quiet' })}</div></header>
<div class="td-acts">${kit.button({ href: '/schedule', label: 'Schedule a stream', kind: 'primary' })}${kit.button({ href: '/brief', label: 'Your brief', kind: 'quiet' })}</div></header>
+${myReminders({ mine, problem, said })}
${agenda.follows_unavailable ? '<p class="td-note" role="status">OpenVibe.Network did not answer, so the channels you follow are missing for a moment. Your own and reminded events are all here.</p>' : ''}
${agenda.follows_unavailable ? '<p class="td-note" role="status">OpenVibe.Network did not answer, so the channels you follow are missing for a moment. Your own and reminded events are all here.</p>' : ''}
<div class="td-me-grid"><div>${days(agenda.events || [], { now, empty: 'Nothing in the next two weeks. Follow a channel on Live, or set a reminder on something coming up.' })}</div>
<div class="td-me-grid"><div>${days(agenda.events || [], { now, empty: 'Nothing in the next two weeks. Follow a channel on Live, or set a reminder on something coming up.' })}</div>
<aside class="td-side">
<aside class="td-side">
-<section class="td-card" aria-labelledby="td-rem-h"><h2 id="td-rem-h">${kit.icon('bell')}Reminders</h2>
+<section class="td-card" aria-labelledby="td-rem-h"><h2 id="td-rem-h">${kit.icon('bell')}Before an event</h2>
${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/e/${esc(x.event_id)}">${esc(x.event.title)}</a><small><time datetime="${esc(x.remind_at)}" data-local="time">${esc(timeText(parts(x.remind_at, x.event.tz)))} ${esc(parts(x.remind_at, x.event.tz).timeZoneName)}</time>, ${esc(dayLabel(x.remind_at, x.event.tz, now).toLowerCase())}</small></li>`).join('')}</ul>` : '<p class="td-fine">None yet. Open anything coming up and tap Remind me.</p>'}
${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/e/${esc(x.event_id)}">${esc(x.event.title)}</a><small><time datetime="${esc(x.remind_at)}" data-local="time">${esc(timeText(parts(x.remind_at, x.event.tz)))} ${esc(parts(x.remind_at, x.event.tz).timeZoneName)}</time>, ${esc(dayLabel(x.remind_at, x.event.tz, now).toLowerCase())}</small></li>`).join('')}</ul>` : '<p class="td-fine">None yet. Open anything coming up and tap Remind me.</p>'}
</section>
</section>
<section class="td-card" aria-labelledby="td-feed-h"><h2 id="td-feed-h">${kit.icon('calendar')}Your calendar feed</h2>
<section class="td-card" aria-labelledby="td-feed-h"><h2 id="td-feed-h">${kit.icon('calendar')}Your calendar feed</h2>
new file mode 100644
new file mode 100644
@@ -0,0 +1,320 @@
@@ -0,0 +1,320 @@
+'use strict';
+/**
+ * When a reminder fires, from plain words (plan L48, R1; the owner, 2026-11-01: "set reminders for some part of the
+ * day or whatever timer or however often they want").
+ *
+ * parse(text, { now, tz, parts }) → { title, rule, next_at, summary } or { error }
+ * next(rule, { after, tz, parts }) → the next time (a Date) strictly after `after`, or null when it is over
+ * describe(rule, { parts }) → the rule in words ("Every 2 hours, 9:00–17:00")
+ *
+ * Everything is computed in the person's own time zone (IANA, from their browser), so "every day at 9" is 9 o'clock
+ * where they are, across daylight-saving changes.
+ *
+ * A rule is one of:
+ * { kind: 'once', at: ISO }
+ * { kind: 'daily', times: ['09:00'], days?: [1..7] } days: ISO weekdays (1 Monday … 7 Sunday); none = every day
+ * { kind: 'interval', minutes: N, from?: '09:00', to?: '17:00', days?: [1..7] }
+ * { kind: 'monthly', day: 1..31, time: '09:00' } a month without that day uses its last day
+ *
+ * The words it understands (English): "in 20 minutes", "at 3pm", "at 15:30", "tomorrow", "tonight", "this evening",
+ * "on Friday at 5", "every day at 9", "daily", "every morning", "weekdays at 8:30", "weekends", "every Monday and
+ * Thursday at 7pm", "every 2 hours", "every 30 minutes from 9 to 5", "hourly", "on the 1st of every month",
+ * "monthly on the 15th". What remains is the title ("remind me to" and "to" dropped).
+ */
+
+const PARTS = { morning: '09:00', afternoon: '14:00', evening: '19:00', night: '21:30' };
+const DAYS = { monday: 1, mon: 1, tuesday: 2, tue: 2, tues: 2, wednesday: 3, wed: 3, thursday: 4, thu: 4, thur: 4, thurs: 4, friday: 5, fri: 5, saturday: 6, sat: 6, sunday: 7, sun: 7 };
+const DAY_NAMES = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
+const WEEKDAYS = [1, 2, 3, 4, 5];
+const WEEKENDS = [6, 7];
+const MAX_TITLE = 200;
+
+// ── Time zones ────────────────────────────────────────────────────────────────────────────────────────────────────
+function validTz(tz) {
+ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
+}
+const fmtCache = new Map();
+/** The wall clock in `tz` at instant `d`: { y, m, d, h, min, dow } (dow 1 Monday … 7 Sunday). */
+function partsIn(date, tz) {
+ let f = fmtCache.get(tz);
+ if (!f) {
+ f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', weekday: 'short' });
+ fmtCache.set(tz, f);
+ }
+ const o = {};
+ for (const p of f.formatToParts(date)) o[p.type] = p.value;
+ const dow = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }[o.weekday];
+ return { y: Number(o.year), m: Number(o.month), d: Number(o.day), h: Number(o.hour) % 24, min: Number(o.minute), dow };
+}
+/** The instant when the wall clock in `tz` reads y-m-d h:min (a time skipped by a clock change moves forward). */
+function atLocal(y, m, d, h, min, tz) {
+ const guess = Date.UTC(y, m - 1, d, h, min);
+ let t = guess;
+ for (let i = 0; i < 3; i++) {
+ const p = partsIn(new Date(t), tz);
+ const shown = Date.UTC(p.y, p.m - 1, p.d, p.h, p.min);
+ const diff = shown - guess;
+ if (diff === 0) return new Date(t);
+ t -= diff;
+ }
+ return new Date(t);
+}
+/** y-m-d plus n days, as a calendar date (no time zone involved). */
+function addDays(y, m, d, n) {
+ const t = new Date(Date.UTC(y, m - 1, d + n));
+ return { y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate(), dow: ((t.getUTCDay() + 6) % 7) + 1 };
+}
+const hm = (s) => { const [h, min] = String(s).split(':').map(Number); return { h, min }; };
+const lastDay = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate();
+
+// ── The next time ─────────────────────────────────────────────────────────────────────────────────────────────────
+function next(rule, { after = new Date(), tz = 'UTC', parts = PARTS } = {}) {
+ void parts;
+ const a = after instanceof Date ? after : new Date(after);
+ if (!rule || typeof rule !== 'object') return null;
+ if (rule.kind === 'once') { const t = new Date(rule.at); return t > a ? t : null; }
+ const today = partsIn(a, tz);
+ if (rule.kind === 'daily') {
+ const times = (rule.times || []).slice().sort();
+ for (let k = 0; k < 15; k++) {
+ const day = addDays(today.y, today.m, today.d, k);
+ if (rule.days && rule.days.length && !rule.days.includes(day.dow)) continue;
+ for (const t of times) { const { h, min } = hm(t); const at = atLocal(day.y, day.m, day.d, h, min, tz); if (at > a) return at; }
+ }
+ return null;
+ }
+ if (rule.kind === 'interval') {
+ const stepMin = Math.max(5, Number(rule.minutes) || 60);
+ if (!rule.from) {
+ // No window: every step from the moment it was set ("every 2 hours" set at 10:15 fires at 12:15, 14:15…),
+ // counted in real time, so midnight and clock changes do not move it.
+ const step = stepMin * 60000;
+ const start = new Date(rule.start || a).getTime();
+ let t = start + Math.max(1, Math.floor((a.getTime() - start) / step) + 1) * step;
+ for (let i = 0; i < 4000; i++, t += step) {
+ const d = new Date(t);
+ if (!rule.days || !rule.days.length || rule.days.includes(partsIn(d, tz).dow)) return d;
+ }
+ return null;
+ }
+ const from = hm(rule.from);
+ const to = rule.to ? hm(rule.to) : { h: 23, min: 59 };
+ const startMin = from.h * 60 + from.min;
+ const endMin = to.h * 60 + to.min;
+ for (let k = 0; k < 15; k++) {
+ const day = addDays(today.y, today.m, today.d, k);
+ if (rule.days && rule.days.length && !rule.days.includes(day.dow)) continue;
+ for (let mins = startMin; mins <= endMin; mins += stepMin) {
+ const at = atLocal(day.y, day.m, day.d, Math.floor(mins / 60), mins % 60, tz);
+ if (at > a) return at;
+ }
+ }
+ return null;
+ }
+ if (rule.kind === 'monthly') {
+ const { h, min } = hm(rule.time || '09:00');
+ for (let k = 0; k < 14; k++) {
+ const mm = ((today.m - 1 + k) % 12) + 1;
+ const yy = today.y + Math.floor((today.m - 1 + k) / 12);
+ const dd = Math.min(Number(rule.day) || 1, lastDay(yy, mm));
+ const at = atLocal(yy, mm, dd, h, min, tz);
+ if (at > a) return at;
+ }
+ return null;
+ }
+ return null;
+}
+
+// ── Words ─────────────────────────────────────────────────────────────────────────────────────────────────────────
+const two = (n) => String(n).padStart(2, '0');
+/** 9 am, 9:30 am, 6 pm, 12 pm (noon), 12 am (midnight). */
+const h12 = (h, min) => `${h % 12 || 12}${min ? `:${two(min)}` : ''} ${h < 12 ? 'am' : 'pm'}`;
+const clock = (s) => { const { h, min } = hm(s); return h12(h, min); };
+const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+const listOf = (a) => (a.length < 2 ? a.join('') : `${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}`);
+function daysWords(days) {
+ if (!days || !days.length || days.length === 7) return '';
+ const set = days.slice().sort().join(',');
+ if (set === WEEKDAYS.join(',')) return 'weekdays';
+ if (set === WEEKENDS.join(',')) return 'weekends';
+ return listOf(days.slice().sort().map((d) => `${DAY_NAMES[d]}s`));
+}
+function everyMinutes(n) {
+ if (n % 1440 === 0) return n === 1440 ? 'Every day' : `Every ${n / 1440} days`;
+ if (n % 60 === 0) return n === 60 ? 'Every hour' : `Every ${n / 60} hours`;
+ return `Every ${n} minutes`;
+}
+function describe(rule, { tz = 'UTC', now = new Date() } = {}) {
+ if (!rule) return '';
+ if (rule.kind === 'once') {
+ const t = new Date(rule.at);
+ const p = partsIn(t, tz), n = partsIn(now, tz);
+ const tomorrow = addDays(n.y, n.m, n.d, 1);
+ const day = p.y === n.y && p.m === n.m && p.d === n.d ? 'Today' : (p.y === tomorrow.y && p.m === tomorrow.m && p.d === tomorrow.d ? 'Tomorrow' : `${DAY_NAMES[p.dow].slice(0, 3)}, ${MONTHS[p.m]} ${p.d}`);
+ return `${day} at ${h12(p.h, p.min)}`;
+ }
+ if (rule.kind === 'daily') {
+ const d = daysWords(rule.days);
+ const at = listOf((rule.times || []).map(clock));
+ return d ? `${d[0].toUpperCase()}${d.slice(1)} at ${at}` : `Every day at ${at}`;
+ }
+ if (rule.kind === 'interval') {
+ const d = daysWords(rule.days);
+ const win = rule.from || rule.to ? `, ${clock(rule.from || '00:00')}–${clock(rule.to || '23:59')}` : '';
+ return `${everyMinutes(rule.minutes)}${win}${d ? `, ${d}` : ''}`;
+ }
+ if (rule.kind === 'monthly') {
+ const n = Number(rule.day);
+ const th = n % 10 === 1 && n !== 11 ? 'st' : n % 10 === 2 && n !== 12 ? 'nd' : n % 10 === 3 && n !== 13 ? 'rd' : 'th';
+ return `Every month on the ${n}${th} at ${clock(rule.time || '09:00')}`;
+ }
+ return '';
+}
+
+// A time of day in words: "3pm", "3:30 pm", "15:30", "noon", "midnight", or a part of the day.
+const TIME = String.raw`(?:(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.)?|noon|midday|midnight)`;
+function toClock(m, parts, fallbackPart) {
+ if (!m) return null;
+ const word = String(m[0]).toLowerCase();
+ if (/noon|midday/.test(word)) return '12:00';
+ if (/midnight/.test(word)) return '00:00';
+ let h = Number(m[1]);
+ const min = m[2] ? Number(m[2]) : 0;
+ const ap = m[3] ? m[3].replace(/\./g, '').toLowerCase() : null;
+ if (ap === 'pm' && h < 12) h += 12;
+ if (ap === 'am' && h === 12) h = 0;
+ // "at 5" with no am/pm in the afternoon or evening part of a sentence reads as 5 pm; plain hours under 7 do too.
+ if (!ap && h >= 1 && h <= 6 && !m[2] && fallbackPart !== 'morning') h += 12;
+ if (h > 23 || min > 59) return null;
+ return `${two(h)}:${two(min)}`;
+}
+
+function parse(text, { now = new Date(), tz = 'UTC', parts = PARTS } = {}) {
+ const zone = validTz(tz) ? tz : 'UTC';
+ const P = { ...PARTS, ...(parts || {}) };
+ let s = ` ${String(text || '').replace(/\s+/g, ' ').trim()} `;
+ if (!s.trim()) return { error: 'Say what to remind you of, and when: "stretch every 2 hours from 9 to 5".' };
+ const lower = () => s.toLowerCase();
+ const cut = (re) => { const m = re.exec(s); if (m) s = `${s.slice(0, m.index)} ${s.slice(m.index + m[0].length)}`; return m; };
+ let rule = null;
+ let days = null;
+ let times = [];
+ let partWord = null;
+
+ // Days of the week: "weekdays", "weekends", "every Monday and Thursday", "on Friday".
+ let m;
+ if ((m = cut(/\b(?:on\s+)?(?:every\s+)?week ?days\b/i))) days = WEEKDAYS;
+ else if ((m = cut(/\b(?:on\s+)?(?:every\s+)?week ?ends?\b/i))) days = WEEKENDS;
+ const dayRe = /\b(?:(every|each|on)\s+)?((?:mon|tues?|wed|thu(?:rs?)?|fri|sat|sun)(?:day)?s?)((?:\s*(?:,|and|&)\s*(?:mon|tues?|wed|thu(?:rs?)?|fri|sat|sun)(?:day)?s?)*)\b/i;
+ let onceDay = null;
+ if (!days && (m = dayRe.exec(s))) {
+ const names = `${m[2]} ${m[3] || ''}`.toLowerCase().split(/[\s,&]+|and/).map((w) => w.replace(/s$/, '')).filter(Boolean);
+ const list = [...new Set(names.map((w) => DAYS[w] || DAYS[`${w}day`] || DAYS[w.replace(/day$/, '')]).filter(Boolean))];
+ const repeating = /every|each/i.test(m[1] || '') || /s$/i.test(m[2]) || list.length > 1;
+ if (list.length) {
+ cut(dayRe);
+ if (repeating) days = list; else onceDay = list[0];
+ }
+ }
+
+ // How often.
+ if ((m = cut(/\b(?:every|each)\s+(\d+|an?|one|two|three|four|five|six|ten|fifteen|twenty|thirty|forty-five|ninety)?\s*(minutes?|mins?|hours?|hrs?)\b/i))) {
+ const words = { a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, ten: 10, fifteen: 15, twenty: 20, thirty: 30, 'forty-five': 45, ninety: 90 };
+ const n = m[1] ? (Number(m[1]) || words[m[1].toLowerCase()] || 1) : 1;
+ const minutes = /^h/i.test(m[2]) ? n * 60 : n;
+ if (minutes < 5) return { error: 'The shortest repeat is every 5 minutes.' };
+ rule = { kind: 'interval', minutes };
+ } else if ((m = cut(/\bhourly\b/i))) {
+ rule = { kind: 'interval', minutes: 60 };
+ } else if ((m = cut(/\b(?:on\s+the\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(?:of\s+)?(?:every|each)\s+month\b|\b(?:every\s+month|monthly)\s+on\s+the\s+(\d{1,2})(?:st|nd|rd|th)?\b|\bmonthly\b/i))) {
+ const day = Number(m[1] || m[2] || 1);
+ if (day < 1 || day > 31) return { error: 'A day of the month is 1 to 31.' };
+ rule = { kind: 'monthly', day, time: null };
+ } else if ((m = cut(/\b(?:every\s*day|daily|each\s+day|every\s+(morning|afternoon|evening|night))\b/i))) {
+ rule = { kind: 'daily', times: [] };
+ if (m[1]) partWord = m[1].toLowerCase();
+ }
+
+ // A window: "from 9 to 5", "between 9am and 6pm".
+ let from = null, to = null;
+ const winRe = new RegExp(String.raw`\b(?:from|between)\s+${TIME}\s*(?:to|until|till|and|-|–)\s*${TIME}`, 'i');
+ if ((m = winRe.exec(s))) {
+ const f = toClock([m[0], m[1], m[2], m[3]], P, 'morning');
+ let tt = toClock([m[0].slice(m[0].search(/(?:to|until|till|and|-|–)\s*/i)).replace(/^(?:to|until|till|and|-|–)\s*/i, ''), m[4], m[5], m[6]], P, 'afternoon');
+ if (f && tt) {
+ if (tt <= f && !m[6]) tt = `${two((hm(tt).h + 12) % 24)}:${two(hm(tt).min)}`;
+ from = f; to = tt;
+ cut(winRe);
+ }
+ }
+
+ // A relative time: "in 20 minutes", "in an hour", "in 2 days".
+ if (!rule && (m = cut(/\bin\s+(\d+|an?|one|two|three|half an?)\s*(minutes?|mins?|hours?|hrs?|days?)\b/i))) {
+ const words = { a: 1, an: 1, one: 1, two: 2, three: 3 };
+ let n = /half/i.test(m[1]) ? 0.5 : (Number(m[1]) || words[m[1].toLowerCase()] || 1);
+ const unit = m[2].toLowerCase();
+ const ms = /^d/.test(unit) ? n * 86400000 : /^h/.test(unit) ? n * 3600000 : n * 60000;
+ if (/half/i.test(m[1]) && /^m/.test(unit)) n = 30;
+ rule = { kind: 'once', at: new Date(now.getTime() + Math.round(ms / 60000) * 60000).toISOString() };
+ }
+
+ // Times of day: "at 3pm", "at 9 and 5", "noon", parts of the day ("tonight", "this evening", "in the morning").
+ const atRe = new RegExp(String.raw`\b(?:at|@)\s+${TIME}(?:\s*(?:and|&|,)\s*${TIME})*`, 'i');
+ if ((m = atRe.exec(s))) {
+ const all = [...m[0].matchAll(new RegExp(TIME, 'gi'))].map((x) => toClock(x, P, partWord)).filter(Boolean);
+ if (all.length) { times = all; cut(atRe); }
+ } else if ((m = cut(/\b(noon|midday|midnight)\b/i))) {
+ times = [toClock([m[1]], P)];
+ }
+ const partRe = /\b(?:this|in the|every|tomorrow|tonight)?\s*(morning|afternoon|evening|night|tonight)\b/i;
+ let dayShift = 0;
+ if ((m = cut(/\btomorrow\b/i))) dayShift = 1;
+ if ((m = cut(/\b(?:today)\b/i))) dayShift = 0;
+ if ((m = partRe.exec(s))) {
+ const w = m[1].toLowerCase() === 'tonight' ? 'night' : m[1].toLowerCase();
+ partWord = partWord || w;
+ cut(partRe);
+ }
+ if (!times.length && partWord) times = [P[partWord]];
+
+ // Build the rule.
+ if (rule && rule.kind === 'interval') {
+ if (from) { rule.from = from; rule.to = to; }
+ if (days) rule.days = days;
+ if (!from) rule.start = new Date(now).toISOString();
+ } else if (rule && rule.kind === 'monthly') {
+ rule.time = times[0] || P.morning;
+ } else if (rule && rule.kind === 'daily') {
+ rule.times = times.length ? times : [P.morning];
+ if (days) rule.days = days;
+ } else if (!rule && (days || (from && to))) {
+ if (from && to) return { error: 'Say how often in that window: "every hour from 9 to 5".' };
+ rule = { kind: 'daily', times: times.length ? times : [P.morning], days };
+ } else if (!rule) {
+ // Once: a day (today, tomorrow, a weekday) and a time (or a part of the day).
+ if (!times.length && !onceDay && !dayShift) return { error: 'Say when: "at 3pm", "tomorrow morning", "every day at 9" or "in 20 minutes".' };
+ const n = partsIn(now, zone);
+ let k = dayShift;
+ if (onceDay) { k = (onceDay - n.dow + 7) % 7; }
+ const t = hm(times[0] || P.morning);
+ let day = addDays(n.y, n.m, n.d, k);
+ let at = atLocal(day.y, day.m, day.d, t.h, t.min, zone);
+ if (at <= now) { day = addDays(n.y, n.m, n.d, k + (onceDay ? 7 : 1)); at = atLocal(day.y, day.m, day.d, t.h, t.min, zone); }
+ rule = { kind: 'once', at: at.toISOString() };
+ }
+
+ // What is left is the title.
+ let title = s.replace(/\s+/g, ' ').trim()
+ .replace(/^(?:please\s+)?(?:remind\s+me|reminder|remember|set\s+a\s+reminder)(?:\s+(?:to|that|about|of))?\s*/i, '')
+ .replace(/^(?:to|that)\s+/i, '')
+ .replace(/\s+(?:at|on|every|from|in|for|the)$/i, '')
+ .replace(/[,.;:\s]+$/, '')
+ .trim();
+ if (!title) return { error: 'Say what to remind you of, as well as when.' };
+ title = `${title[0].toUpperCase()}${title.slice(1)}`.slice(0, MAX_TITLE);
+ const nextAt = next(rule, { after: now, tz: zone, parts: P });
+ if (!nextAt) return { error: 'That time has already passed.' };
+ return { title, rule, tz: zone, next_at: nextAt.toISOString(), summary: describe(rule, { tz: zone, now }) };
+}
+
+module.exports = { parse, next, describe, partsIn, atLocal, validTz, h12, PARTS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * A person's own reminders (plan L48, R1): plain words become a rule (server/when.js) that fires in the person's own
+ * time zone, across a clock change; the API sets, lists, snoozes, stops and removes them, only for their owner; the
+ * job sends one notification per time and moves the reminder on (a one-off is then done), never twice; /me has the
+ * box and the list, and its forms work without the script.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const when = require('../server/when');
+
+(async () => {
+ await check('plain words become a rule, a title and the next time, in the person\'s zone', () => {
+ const now = new Date('2026-11-02T14:20:00Z'); // Monday 6:20 am in Los Angeles
+ const tz = 'America/Los_Angeles';
+ const p = (t) => when.parse(t, { now, tz });
+ const a = p('remind me to stretch every 2 hours from 9 to 5');
+ assert.deepStrictEqual([a.title, a.summary], ['Stretch', 'Every 2 hours, 9 am–5 pm']);
+ assert.strictEqual(a.next_at, '2026-11-02T17:00:00.000Z', '9 am in Los Angeles');
+ assert.strictEqual(p('take my meds every day at 9am and 9pm').summary, 'Every day at 9 am and 9 pm');
+ assert.strictEqual(p('standup weekdays at 9:30').summary, 'Weekdays at 9:30 am');
+ assert.strictEqual(p('stream every Monday and Thursday at 7pm').summary, 'Mondays and Thursdays at 7 pm');
+ assert.strictEqual(p('pay rent on the 1st of every month').next_at, '2026-12-01T17:00:00.000Z');
+ assert.strictEqual(p('check the oven in 20 minutes').next_at, '2026-11-02T14:40:00.000Z');
+ assert.strictEqual(p('call mom on Sunday at 6pm').summary, 'Sun, Nov 8 at 6 pm');
+ assert.strictEqual(p('walk the dog tomorrow morning').summary, 'Tomorrow at 9 am');
+ assert.strictEqual(p('drink water every 30 minutes').next_at, '2026-11-02T14:50:00.000Z', 'every 30 minutes from now');
+ assert.ok(p('water plants').error, 'no time: asked');
+ assert.ok(p('at 3pm').error, 'no what: asked');
+ assert.ok(p('ping every 2 minutes').error, 'too often: refused');
+ });
+
+ await check('"every day at 9" stays 9 o\'clock across the clock change', () => {
+ const tz = 'America/New_York'; // clocks go back on Sunday 1 November 2026
+ const rule = { kind: 'daily', times: ['09:00'] };
+ const sat = when.next(rule, { after: new Date('2026-10-31T13:30:00Z'), tz }); // after 9:30 am EDT
+ assert.strictEqual(sat.toISOString(), '2026-11-01T14:00:00.000Z', '9 am EST on Sunday is 14:00 UTC');
+ const sun = when.next(rule, { after: sat, tz });
+ assert.strictEqual(sun.toISOString(), '2026-11-02T14:00:00.000Z');
+ assert.strictEqual(when.next({ kind: 'monthly', day: 31, time: '09:00' }, { after: new Date('2026-11-05T00:00:00Z'), tz: 'UTC' }).toISOString(), '2026-11-30T09:00:00.000Z', 'a month without the 31st uses its last day');
+ });
+
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const db = t.ctx.s.db;
+ try {
+ await check('the API sets, lists, snoozes, stops and removes a person\'s own reminders', async () => {
+ const parsed = await t.get('/api/v1/reminders/parse', { as: alice, json: { text: 'stretch every 2 hours from 9 to 5', tz: 'Europe/Berlin' } });
+ assert.strictEqual(parsed.status, 200, parsed.text);
+ assert.strictEqual(parsed.json().summary, 'Every 2 hours, 9 am–5 pm');
+ const vague = await t.get('/api/v1/reminders/parse', { as: alice, json: { text: 'water plants', tz: 'Europe/Berlin' } });
+ assert.strictEqual(vague.status, 422);
+ const made = await t.get('/api/v1/reminders', { as: alice, json: { text: 'stretch every 2 hours from 9 to 5', tz: 'Europe/Berlin', url: 'https://openvibe.live/@alice', source: 'live' } });
+ assert.strictEqual(made.status, 201, made.text);
+ const r = made.json();
+ assert.ok(/^rem_/.test(r.id) && r.repeats && r.url === 'https://openvibe.live/@alice' && r.source === 'live');
+ const unsafe = await t.get('/api/v1/reminders', { as: alice, json: { text: 'x at 3pm', tz: 'UTC', url: 'javascript:alert(1)' } });
+ assert.strictEqual(unsafe.json().url, null, 'only an https link is kept');
+ const list = (await t.get('/api/v1/reminders', { as: alice })).json().reminders;
+ assert.ok(list.some((x) => x.id === r.id));
+ assert.strictEqual((await t.get(`/api/v1/reminders/${r.id}/done`, { as: bob, json: {} })).status, 404, 'another person cannot touch it');
+ const snoozed = (await t.get(`/api/v1/reminders/${r.id}/snooze`, { as: alice, json: { minutes: 60 } })).json();
+ assert.ok(Math.abs(new Date(snoozed.next_at).getTime() - (Date.now() + 3600000)) < 120000, 'an hour from now');
+ const stopped = (await t.get(`/api/v1/reminders/${r.id}/done`, { as: alice, json: {} })).json();
+ assert.strictEqual(stopped.status, 'done');
+ assert.strictEqual((await t.get(`/api/v1/reminders/${r.id}`, { as: alice, method: 'DELETE' })).status, 204);
+ assert.strictEqual(await db.value('SELECT count(*)::int FROM today_my_reminders WHERE id = $1', [r.id]), 0);
+ });
+
+ await check('the job sends one notification per time and moves the reminder on; a one-off is then done', async () => {
+ const rep = (await t.get('/api/v1/reminders', { as: alice, json: { text: 'drink water every 30 minutes', tz: 'UTC' } })).json();
+ const once = (await t.get('/api/v1/reminders', { as: alice, json: { text: 'check the oven in 20 minutes', tz: 'UTC' } })).json();
+ await db.exec("UPDATE today_my_reminders SET next_at = now() - interval '1 minute' WHERE id = ANY($1)", [[rep.id, once.id]]);
+ const before = t.network.notifications.length;
+ const [a, b] = await Promise.all([t.ctx.reminders.runOnce(), t.ctx.reminders.runOnce()]);
+ assert.strictEqual(a.sent + b.sent, 2, 'two reminders, two notifications, never four');
+ const sent = t.network.notifications.slice(before);
+ assert.deepStrictEqual(sent.map((n) => n.title).sort(), ['Check the oven', 'Drink water']);
+ assert.ok(sent.every((n) => n.type === 'TODAY_REMINDER' && n.service === 'today' && n.user_id === alice.id));
+ assert.match(sent.find((n) => n.title === 'Drink water').message, /Every 30 minutes/);
+ const after = await db.many('SELECT id, status, next_at, sent_count FROM today_my_reminders WHERE id = ANY($1)', [[rep.id, once.id]]);
+ const repRow = after.find((x) => x.id === rep.id), onceRow = after.find((x) => x.id === once.id);
+ assert.ok(repRow.status === 'active' && new Date(repRow.next_at) > new Date(), 'the repeat moved on');
+ assert.ok(onceRow.status === 'done' && onceRow.next_at === null, 'the one-off is done');
+ assert.strictEqual(Number(repRow.sent_count), 1);
+ });
+
+ await check('/me has the box and the list, and its forms work without the script', async () => {
+ const me = await t.get('/me', { as: bob });
+ assert.match(me.text, /<section class="td-card td-remind" id="reminders"/);
+ assert.match(me.text, /<form method="post" action="\/me\/reminders" class="td-remind-form" data-remind-preview="\/api\/v1\/reminders\/parse">/);
+ const add = await t.get('/me/reminders', { as: bob, form: { text: 'call mom on Sunday at 6pm', tz: 'America/Chicago' } });
+ assert.strictEqual(add.status, 303);
+ assert.strictEqual(add.headers.get('location'), '/me#reminders');
+ const page = await t.get('/me', { as: bob });
+ assert.match(page.text, /<b>Call mom<\/b>[\s\S]*?at 6 pm/);
+ const bad = await t.get('/me/reminders', { as: bob, form: { text: 'water plants', tz: 'UTC' } });
+ assert.match(decodeURIComponent(bad.headers.get('location')), /problem=Say when/);
+ const id = await db.value("SELECT id FROM today_my_reminders WHERE subject = $1 AND title = 'Call mom'", [bob.subject]);
+ const foreign = await t.get(`/me/reminders/${id}/remove`, { as: bob, form: { x: '1' }, headers: { origin: 'https://evil.example' } });
+ assert.strictEqual(foreign.status, 403, 'a post from another site is refused');
+ const rm = await t.get(`/me/reminders/${id}/remove`, { as: bob, form: { x: '1' } });
+ assert.strictEqual(rm.status, 303);
+ assert.strictEqual(await db.value('SELECT count(*)::int FROM today_my_reminders WHERE id = $1', [id]), 0);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });