openvibers/openvibe.today Commits

"Remind me" from anywhere: openvibe.today/remind (plan L48 R3) (#44)

OP OpenVibers committed 13 days ago · e4add9cf58 · 6 files, +225 −5

Any OpenVibe site with a time on it (a stream, a job's closing date, a coupon's end) can link a
Remind me to openvibe.today/remind. Signed out, sign in and come back; signed in, the time is
shown in the reader's own zone (today.js asks again once in it) with how long before: at the
time, 10 minutes, an hour or the day before. It becomes a one-off reminder (bell and push on every
site) with the way back to where it came from. The same thing set twice is set once; a past time
is refused; a lead longer than the time left is set as soon as it can be; a link that is not
https is dropped; only openvibe.today itself may post it.

Changes

ddiff --git a/public/css/today.css b/public/css/today.css
iindex 99804e1..bf730b3 100644
---- a/public/css/today.css
++++ b/public/css/today.css
@@@ -269,3 +269,26 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
/* The week's lines read as text (the room's link colour belongs to links in prose), and their times line up. */
.td-wrap .td-wi a, .td-wrap .td-wi-t { color: var(--text-primary, #e8eef8); }
.td-wi time { min-width: 2.9em; }
++
++/* ── "Remind me" from anywhere (plan L48 R3): the confirm card and what was set ── */
++#td-remind .td-kicker, #td-remind-done .td-kicker { display: inline-flex; align-items: center; gap: 6px; }
++#td-remind .td-kicker .ovk-i { width: 1.05em; height: 1.05em; }
++.td-remind-when { margin: 10px 0 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
++.td-remind-when time { font-size: clamp(1.15rem, 2.6vw, 1.45rem); font-weight: 800; letter-spacing: -.01em; color: var(--text-primary, #e8eef8); font-variant-numeric: tabular-nums; }
++.td-remind-when span { color: var(--text-muted, #6f819d); font-size: .9rem; }
++.td-remind-form { margin-top: 18px; display: block; }
++.td-remind-leads { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 136px), 1fr)); }
++.td-remind-leads legend { grid-column: 1 / -1; }
++.td-remind-leads .td-opt { align-items: center; min-height: 48px; padding: 10px 12px; }
++.td-remind-leads .td-opt input { margin: 0; }
++.td-remind-leads .td-opt:has(input:checked) { border-color: var(--room-accent); box-shadow: inset 0 0 0 1px var(--room-accent); background: color-mix(in srgb, var(--room-accent) 10%, var(--bg-card, #0f1729)); }
++.td-remind-form .td-acts .ovk-btn { gap: 8px; }
++.td-remind-form .td-fine { margin: 14px 0 0; color: var(--text-muted, #6f819d); font-size: .86rem; line-height: 1.5; }
++.td-remind-form .td-fine a { color: var(--room-strong, var(--room-accent)); }
++.td-remind-set { margin-top: clamp(16px, 6vh, 56px); padding: clamp(20px, 4vw, 32px); text-align: center; display: grid; justify-items: center; }
++.td-remind-set h1 { margin: 2px 0 0; font-size: clamp(1.5rem, 3.6vw, 2.2rem); letter-spacing: -.025em; text-wrap: balance; }
++.td-remind-set .td-remind-when { justify-content: center; }
++.td-remind-set .td-acts { justify-content: center; margin-top: 18px; }
++.td-remind-ok { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 10px; border-radius: 16px; background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
++.td-remind-ok .ovk-i { width: 26px; height: 26px; }
++.td-notice--bad { padding: 12px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, #ef4444 50%, var(--border, #1f2d47)); background: color-mix(in srgb, #ef4444 10%, transparent); }
ddiff --git a/public/js/today.js b/public/js/today.js
iindex 8c43a92..94dd8b4 100644
---- a/public/js/today.js
++++ b/public/js/today.js
@@@ -13,9 +13,11 @@
try {
if (!/(?:^|; )td_tz=/.test(document.cookie) || decodeURIComponent((document.cookie.match(/(?:^|; )td_tz=([^;]*)/) || [])[1] || '') !== zone) {
document.cookie = 'td_tz=' + encodeURIComponent(zone) + '; path=/; max-age=31536000; samesite=lax' + (location.protocol === 'https:' ? '; secure' : '');
-- var wk = document.getElementById('td-week');
-- if (wk && wk.getAttribute('data-tz') !== zone && !/[?&]tz=/.test(location.search) && !sessionStorage.getItem('td-week-tz')) {
-- sessionStorage.setItem('td-week-tz', '1');
++ // The week, and a "Remind me" that came from another site: both say a time in the reader's zone.
++ var wk = document.getElementById('td-week') || document.getElementById('td-remind');
++ var once = 'td-tz-' + (wk ? wk.id : '');
++ if (wk && wk.getAttribute('data-tz') !== zone && !/[?&]tz=/.test(location.search) && !sessionStorage.getItem(once)) {
++ sessionStorage.setItem(once, '1');
location.replace(location.pathname + location.search + (location.search ? '&' : '?') + 'tz=' + encodeURIComponent(zone));
}
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex d52c3dc..cc7ffb6 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -234,6 +234,40 @@ function createPageRoutes(ctx) {
});
});
++ // ── "Remind me" from anywhere (plan L48 R3): /remind?title=&at=&url=&from= from any OpenVibe site ──
++ const remindArgs = (q) => ({
++ title: String(q.title || '').replace(/\s+/g, ' ').trim().slice(0, 200),
++ at: String(q.at || '').slice(0, 40),
++ url: /^https:\/\//.test(String(q.url || '')) ? String(q.url).slice(0, 2000) : '',
++ from: /^[a-z][a-z0-9.-]{1,40}$/.test(String(q.from || '')) ? String(q.from) : '',
++ });
++ const zoneOf = (req, asked) => {
++ const z = String(asked || (req.cookies && req.cookies.td_tz) || '');
++ return require('../when').validTz(z) ? z : 'UTC';
++ };
++ r.get('/remind', async (req, res) => {
++ const a = remindArgs(req.query || {});
++ if (!signedIn(req)) {
++ return page(req, res, { title: 'Remind me', body: raw(views.signIn({ title: a.title ? `Remind me: ${a.title}` : 'Remind me', text: 'Sign in with your OpenVibe account, and the reminder reaches you in the bell and as a push on every OpenVibe site.', next: req.originalUrl })) });
++ }
++ const tz = zoneOf(req, req.query.tz);
++ res.set('Cache-Control', 'private, no-store');
++ return page(req, res, { title: a.title ? `Remind me: ${a.title}` : 'Remind me', body: raw(views.remind({ ...a, tz, lead: 10 })) });
++ });
++ r.post('/remind', express.urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
++ const b = req.body || {};
++ const a = remindArgs(b);
++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/remind?${new URLSearchParams(a)}`)}`);
++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: raw(views.notFound()) }, 403);
++ const tz = zoneOf(req, b.tz);
++ if (!mine) return page(req, res, { title: 'Remind me', body: raw(views.remind({ ...a, tz, problem: 'Reminders are not available right now.' })) }, 503);
++ const out = await mine.createAt(req.viewer.subject, { title: a.title, at: a.at, lead: b.lead, url: a.url, source: (a.from || hostOf(a.url) || 'today').replace(/^openvibe\./, '').replace(/[^a-z0-9-]/g, '-').slice(0, 30) || 'today', tz });
++ if (out.error) return page(req, res, { title: 'Remind me', body: raw(views.remind({ ...a, tz, lead: Number(b.lead) || 10, problem: out.error })) }, 422);
++ res.set('Cache-Control', 'private, no-store');
++ return page(req, res, { title: 'Reminder set', body: raw(views.remindDone({ reminder: out.reminder, back: a.url, already: out.already, tz })) }, out.already ? 200 : 201);
++ });
++ const hostOf = (u) => { try { return new URL(u).host; } catch { return ''; } };
++
// ── 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');
ddiff --git a/server/my-reminders.js b/server/my-reminders.js
iindex 16708c4..5502276 100644
---- a/server/my-reminders.js
++++ b/server/my-reminders.js
@@@ -6,6 +6,10 @@
* 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)
++ * createAt(subject, { title, at, lead, url, source, tz })
++ * → a one-off before something with a time on another site ("Remind me" on a
++ * stream, a job's closing date, a coupon's end): { reminder, already } or
++ * { error }. lead is minutes before (0, 10, 60 or 1440).
* 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
@@@ -48,6 +52,39 @@ function createMyReminders({ s }) {
return wire(row, { now: s.now() });
}
++ const LEADS = [0, 10, 60, 1440];
++ const leadWords = (m) => ({ 0: 'at the time', 10: '10 minutes before', 60: 'an hour before', 1440: 'the day before' })[m] || '';
++
++ /** A thing with a time on another site → a one-off reminder before it. The same thing set twice is set once. */
++ async function createAt(subject, { title, at, lead = 10, url = null, source = 'today', tz = 'UTC', now = s.now() }) {
++ const name = String(title == null ? '' : title).replace(/\s+/g, ' ').trim().slice(0, 200);
++ if (!name) return { error: 'What should the reminder say?' };
++ const when0 = Date.parse(String(at || ''));
++ if (!Number.isFinite(when0)) return { error: 'That time could not be read.' };
++ if (when0 <= now + 60_000) return { error: 'That time has already passed.' };
++ if (when0 > now + 2 * 365 * 86_400_000) return { error: 'That is more than two years away.' };
++ const m = LEADS.includes(Number(lead)) ? Number(lead) : 10;
++ // Too close for the lead asked: as soon as it can, never in the past.
++ const remindAt = new Date(Math.max(now + 60_000, when0 - m * 60_000));
++ const zone = when.validTz(tz) ? tz : 'UTC';
++ const link = safeUrl(url);
++ const src = SOURCES.test(String(source)) ? String(source) : 'today';
++ const same = await db.maybe(
++ "SELECT * FROM today_my_reminders WHERE subject = $1 AND status = 'active' AND title = $2 AND url IS NOT DISTINCT FROM $3 AND date_trunc('minute', next_at) = date_trunc('minute', $4::timestamptz) LIMIT 1",
++ [subject, name, link, remindAt.toISOString()],
++ );
++ if (same) return { reminder: wire(same, { now }), already: true };
++ 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 rule = { kind: 'once', at: remindAt.toISOString() };
++ 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 *`,
++ [s.newId('rem'), subject, name, `${name} (${leadWords(m)}, from ${src})`.slice(0, 500), JSON.stringify(rule), zone, link, src, remindAt.toISOString()],
++ );
++ return { reminder: wire(row, { now }), already: false };
++ }
++
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})
@@@ -104,7 +141,7 @@ function createMyReminders({ s }) {
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 };
++ return { preview, create, createAt, LEADS, list, done, snooze, remove, due, claim, release, wire, MAX_ACTIVE };
}
module.exports = { createMyReminders };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 68ec817..0a67e55 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -345,6 +345,50 @@ ${problem ? `<p class="td-note is-bad" role="alert">${esc(problem)}</p>` : ''}
</form></div>`;
}
++/**
++ * "Remind me" from anywhere (plan L48 R3): a thing with a time on another OpenVibe site (a stream, a job's closing
++ * date, a coupon's end) comes here as /remind?title=&at=&url=&from=, in the person's own zone, with how long before.
++ * remind({ title, at, url, from, tz, lead, problem }) the confirm card
++ * remindDone({ reminder, back, already, tz }) what was set, and the way back
++ */
++const LEAD_CHOICES = [[0, 'At the time', null], [10, '10 minutes before', null], [60, 'An hour before', null], [1440, 'The day before', null]];
++const hostOf = (u) => { try { return new URL(u).host.replace(/^www\./, ''); } catch { return ''; } };
++const whenIn = (iso, tz) => {
++ try {
++ const d = new Date(iso);
++ const day = new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short', month: 'short', day: 'numeric' }).format(d);
++ const time = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit' }).format(d);
++ return `${day} at ${time}`;
++ } catch { return new Date(iso).toISOString().slice(0, 16).replace('T', ' '); }
++};
++
++function remind({ title = '', at = '', url = '', from = '', tz = 'UTC', lead = 10, problem = '' } = {}) {
++ const host = hostOf(url) || (from ? from : '');
++ const valid = title && Number.isFinite(Date.parse(at));
++ const hidden = (n, v) => `<input type="hidden" name="${n}" value="${esc(v)}">`;
++ const radio = ([m, label]) => `<label class="td-opt"><input type="radio" name="lead" value="${m}"${Number(lead) === m ? ' checked' : ''}><span><b>${esc(label)}</b></span></label>`;
++ return `<div class="td-wrap td-page-pad td-narrow" id="td-remind" data-tz="${esc(tz)}">
++<header class="td-page-h"><p class="td-kicker">${kit.icon('bell')} Remind me${host ? ` · from ${esc(host)}` : ''}</p>
++<h1>${valid ? esc(title) : 'Nothing to remind you of'}</h1>
++${valid ? `<p class="td-remind-when"><time datetime="${esc(new Date(at).toISOString())}">${esc(whenIn(at, tz))}</time><span>your time, ${esc(tz.replace(/_/g, ' '))}</span></p>` : '<p class="td-lede">This link has no title or no time. Open it again from the page it came from.</p>'}</header>
++${problem ? `<p class="td-notice td-notice--bad" role="alert">${esc(problem)}</p>` : ''}
++${valid ? `<form class="td-card td-form td-remind-form" method="post" action="/remind">${hidden('title', title)}${hidden('at', at)}${hidden('url', url)}${hidden('from', from)}<input type="hidden" name="tz" value="${esc(tz)}" data-tz>
++<fieldset class="td-opts td-remind-leads"><legend>When should it reach you?</legend>${LEAD_CHOICES.map(radio).join('')}</fieldset>
++<div class="td-acts"><button class="ovk-btn ovk-btn--primary" type="submit">${kit.icon('bell')} Remind me</button>${url ? `<a class="ovk-btn ovk-btn--quiet" href="${esc(url)}">Back to ${esc(host || 'the page')}</a>` : ''}</div>
++<p class="td-fine">It reaches you in the bell and as a push on every OpenVibe site. Snooze or stop it on <a href="/me#reminders">your agenda</a>.</p></form>` : ''}
++</div>`;
++}
++
++function remindDone({ reminder, back = '', already = false, tz = 'UTC' } = {}) {
++ const host = hostOf(back);
++ return `<div class="td-wrap td-page-pad td-narrow" id="td-remind-done">
++<div class="td-card td-remind-set"><span class="td-remind-ok" aria-hidden="true">${kit.icon('check')}</span>
++<p class="td-kicker">${already ? 'Already set' : 'Reminder set'}</p><h1>${esc(reminder.title)}</h1>
++<p class="td-remind-when"><time datetime="${esc(reminder.next_at || '')}">${esc(reminder.next_at ? whenIn(reminder.next_at, tz) : '')}</time><span>it reaches you then, in the bell and as a push</span></p>
++<div class="td-acts">${back ? `<a class="ovk-btn ovk-btn--primary" href="${esc(back)}">Back to ${esc(host || 'where you were')}</a>` : ''}<a class="ovk-btn ovk-btn--quiet" href="/me#reminders">Your reminders</a><a class="ovk-btn ovk-btn--quiet" href="/me/week">Your week</a></div></div>
++</div>`;
++}
++
function signIn({ title, text, next }) {
return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty td-signin">${kit.icon('calendar')}<h1>${esc(title)}</h1><p>${esc(text)}</p>${kit.button({ href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe', kind: 'primary' })}</div></div>`;
}
@@@ -379,4 +423,4 @@ function notFound() {
return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty">${kit.icon('calendar')}<h1>Not on the calendar</h1><p>There is nothing at this address. It may have been cancelled, or it is for followers only.</p>${kit.button({ href: '/', label: 'See what is coming up', kind: 'quiet' })}</div></div>`;
}
--module.exports = { week, home, channel, event, me, brief, schedule, signIn, developers, notFound, ZONES, LENGTHS, parts, dayKey };
++module.exports = { remind, remindDone, week, home, channel, event, me, brief, schedule, signIn, developers, notFound, ZONES, LENGTHS, parts, dayKey };
ddiff --git a/test/remind-anywhere.test.js b/test/remind-anywhere.test.js
nnew file mode 100644
iindex 0000000..d48d1d4
---- /dev/null
++++ b/test/remind-anywhere.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * "Remind me" from anywhere (plan L48 R3): /remind?title=&at=&url=&from= from any OpenVibe site. Signed out, sign in
++ * and come back; signed in, the time in the person's own zone and how long before; a one-off reminder before it, the
++ * same thing set twice set once, a past time refused, a lead too long for the time left made as soon as it can be, a
++ * link that is not https dropped, and only openvibe.today itself may post it.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const ana = t.network.addUser('ana');
++ const hour = 3_600_000;
++ const at = new Date(Math.ceil((Date.now() + 3 * 24 * hour) / hour) * hour).toISOString();
++ const q = new URLSearchParams({ title: 'Surf race, heat 3', at, url: 'https://openvibe.live/@surfwax', from: 'openvibe.live' });
++ const paris = (iso) => {
++ const d = new Date(iso);
++ return `${new Intl.DateTimeFormat('en-US', { timeZone: 'Europe/Paris', weekday: 'short', month: 'short', day: 'numeric' }).format(d)} at ${new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }).format(d)}`;
++ };
++ const rows = () => t.ctx.s.db.many("SELECT * FROM today_my_reminders WHERE source <> 'today' ORDER BY created_at");
++ try {
++ await check('signed out: sign in, and come back to the same reminder', async () => {
++ const r = await t.get(`/remind?${q}`);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Remind me: Surf race, heat 3/);
++ assert.ok(r.text.includes(encodeURIComponent(`/remind?${q}`)), 'the sign-in comes back here');
++ const post = await t.get('/remind', { form: Object.fromEntries(q), origin: t.config.baseUrl });
++ assert.strictEqual(post.status, 303);
++ assert.match(post.headers.get('location'), /^\/auth\/login\?next=%2Fremind%3F/);
++ });
++
++ await check('signed in: the time in the person\'s zone, and how long before', async () => {
++ const r = await t.get(`/remind?${q}`, { as: ana, headers: { cookie: 'td_tz=Europe%2FParis' } });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<div class="td-wrap td-page-pad td-narrow" id="td-remind" data-tz="Europe\/Paris">/);
++ assert.ok(r.text.includes(paris(at)), `the time in Paris: ${paris(at)}`);
++ assert.match(r.text, /from openvibe\.live/);
++ assert.match(r.text, /<input type="radio" name="lead" value="10" checked>/, '10 minutes before, unless they choose');
++ assert.match(r.text, /href="https:\/\/openvibe\.live\/@surfwax">Back to openvibe\.live/);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ });
++
++ await check('set: a one-off before it, the way back, and set once', async () => {
++ const form = { ...Object.fromEntries(q), lead: '60', tz: 'Europe/Paris' };
++ const r = await t.get('/remind', { as: ana, form });
++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
++ assert.match(r.text, /Reminder set/);
++ assert.match(r.text, /Back to openvibe\.live/);
++ const [row] = await rows();
++ assert.strictEqual(new Date(row.next_at).toISOString(), new Date(Date.parse(at) - hour).toISOString(), 'an hour before');
++ assert.deepStrictEqual([row.title, row.url, row.source, row.tz, row.status], ['Surf race, heat 3', 'https://openvibe.live/@surfwax', 'live', 'Europe/Paris', 'active']);
++ assert.strictEqual((typeof row.rule === 'string' ? JSON.parse(row.rule) : row.rule).kind, 'once');
++ const again = await t.get('/remind', { as: ana, form });
++ assert.strictEqual(again.status, 200);
++ assert.match(again.text, /Already set/);
++ assert.strictEqual((await rows()).length, 1, 'set once');
++ assert.ok((await t.get('/me', { as: ana })).text.includes('Surf race, heat 3'), 'on the agenda');
++ });
++
++ await check('a past time, a lead longer than the time left, a link that is not https, someone else\'s site', async () => {
++ const past = await t.get('/remind', { as: ana, form: { title: 'Gone', at: new Date(Date.now() - hour).toISOString(), lead: '10' } });
++ assert.strictEqual(past.status, 422);
++ assert.match(past.text, /That time has already passed/);
++ const soon = await t.get('/remind', { as: ana, form: { title: 'Soon', at: new Date(Date.now() + 2 * hour).toISOString(), lead: '1440' } });
++ assert.strictEqual(soon.status, 201);
++ const s = await t.ctx.s.db.maybe("SELECT * FROM today_my_reminders WHERE title = 'Soon'");
++ assert.ok(Date.parse(s.next_at) - Date.now() < 5 * 60_000, 'the day before has passed: as soon as it can, never in the past');
++ const odd = await t.get(`/remind?${new URLSearchParams({ title: '<b>Odd</b>', at, url: 'javascript:alert(1)' })}`, { as: ana });
++ assert.ok(odd.text.includes('&lt;b&gt;Odd&lt;/b&gt;') && !odd.text.includes('<b>Odd</b>'), 'the title is text');
++ assert.ok(!odd.text.includes('javascript:'), 'a link that is not https is dropped');
++ const cross = await t.get('/remind', { as: ana, form: { ...Object.fromEntries(q), lead: '10' }, origin: 'https://evil.example' });
++ assert.strictEqual(cross.status, 403, 'only openvibe.today itself may set it');
++ const blank = await t.get('/remind?title=&at=', { as: ana });
++ assert.match(blank.text, /Nothing to remind you of/);
++ });
++ } finally {
++ await done(t);
++ }
++})();