openvibers/openvibe.today Commits

Your own reminders on openvibe.today: plain words, your time zone, bell and push (L48 R1) (#36)

OP OpenVibers committed 13 days ago · 0390dbc413 · 13 files, +766 −10

* Your own reminders on openvibe.today (L48 R1): one box in plain words ('stretch every 2 hours from 9 to 5', 'take my meds every day at 9 am and 9 pm', 'pay rent on the 1st of every month'), a live preview of what it understood, times in your own zone across clock changes, delivered to your bell and push, with Snooze, Stop and Remove; an API for Vibe and every site

* Reminders: the claim compares next_at at millisecond precision (PostgreSQL keeps microseconds, a JS Date has milliseconds); passes on PGlite and on PostgreSQL through PgBouncer

Changes

ddiff --git a/migrations/0003_my_reminders.sql b/migrations/0003_my_reminders.sql
nnew file mode 100644
iindex 0000000..6018ee7
---- /dev/null
++++ b/migrations/0003_my_reminders.sql
@@@ -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);
ddiff --git a/public/css/today.css b/public/css/today.css
iindex e697fa7..3875124 100644
---- a/public/css/today.css
++++ b/public/css/today.css
@@@ -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; }
++}
ddiff --git a/public/js/today.js b/public/js/today.js
iindex cd4f4f8..7478a4e 100644
---- a/public/js/today.js
++++ b/public/js/today.js
@@@ -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);
})();
ddiff --git a/server/app.js b/server/app.js
iindex e752e80..6404dd7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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,
};
ddiff --git a/server/http/api.js b/server/http/api.js
iindex fa5e5b8..423ea13 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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');
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a8c6feb..a36f6f1 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 3091b13..eac4fc8 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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' },
ddiff --git a/server/my-reminders.js b/server/my-reminders.js
nnew file mode 100644
iindex 0000000..16708c4
---- /dev/null
++++ b/server/my-reminders.js
@@@ -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 };
ddiff --git a/server/notifications.js b/server/notifications.js
iindex 236ecc9..6176062 100644
---- a/server/notifications.js
++++ b/server/notifications.js
@@@ -87,9 +87,40 @@ function createNotifier({ config, s, fetchImpl = globalThis.fetch, log = console
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(); }
-- return { sendReminder, resolveUserIds, clear, titleFor, canSend };
++ return { sendReminder, sendMine, resolveUserIds, clear, titleFor, canSend };
}
module.exports = { createNotifier };
ddiff --git a/server/reminders.js b/server/reminders.js
iindex dec8218..c10c395 100644
---- a/server/reminders.js
++++ b/server/reminders.js
@@@ -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, intervalM
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();
-- return { due: due.length, sent, failed, skipped };
++ return { due: due.length + mineDue, sent, failed, skipped };
}
function start() {
ddiff --git a/server/views/index.js b/server/views/index.js
iindex ccfcbfc..d37f2a9 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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>
ddiff --git a/server/when.js b/server/when.js
nnew file mode 100644
iindex 0000000..5d66cd6
---- /dev/null
++++ b/server/when.js
@@@ -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 };
ddiff --git a/test/my-reminders.test.js b/test/my-reminders.test.js
nnew file mode 100644
iindex 0000000..f686c86
---- /dev/null
++++ b/test/my-reminders.test.js
@@@ -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); });