openvibers/openvibe.fun Commits

Fun: a "Remind me" link for a plan's start (plan L48 R3) (#34)

OP OpenVibers committed 12 days ago · a287ae83a3 · 5 files, +128 −12

A group plan that starts later now says so: a quiet 'Remind me' link beside the page's own actions on
the plan's page, and one in the card's own line on the front page and on the plans list. It opens
openvibe.today/remind with the plan's name, the start itself in UTC, the plan's canonical page and
this site's host, so the person signs in there, picks how long before, and the reminder reaches
them in the bell and as a push.

A plan that has started gets no link. The card's box moves from the link to the card, so the line
under the title can hold the link.

Changes

ddiff --git a/public/css/fun.css b/public/css/fun.css
iindex 1214c70..33cd427 100644
---- a/public/css/fun.css
++++ b/public/css/fun.css
@@@ -86,6 +86,14 @@ html.ov-motion .fu-flip-card { animation: fu-flip .8s cubic-bezier(.2,.7,.2,1) .
.fu-quest-link:hover, .fu-plan-link:hover { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
.fu-quest-link, .fu-plan-link { transition: border-color .15s, transform .15s; }
.fu-quest-card, .fu-plan-card { display: grid; }
++/* The plan card carries its own box now: the title is the link, and the line under it holds the time and the
++ "Remind me" link (plan L48 R3). */
++.fu-plan-card { gap: 6px; align-content: start; height: 100%; padding: 15px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }
++.fu-plan-card:hover { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
++.fu-plan-card .fu-plan-link { display: block; height: auto; padding: 0; border: 0; background: none; }
++/* "Remind me" (plan L48 R3): the shared kit's bell beside the link. */
++.fu-remind { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; }
++.fu-remind .ovk-i { width: 1.05em; height: 1.05em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.fu-quest-title, .fu-plan-title, .fu-place-body b, .fu-event-body b, .fu-option-body b { font-size: 1.04rem; font-weight: 800; line-height: 1.3; overflow-wrap: anywhere; }
.fu-quest-meta, .fu-quest-foot, .fu-plan-meta, .fu-plan-foot, .fu-place-addr, .fu-place-type, .fu-event-meta, .fu-event-type, .fu-option-meta, .fu-hours { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: .82rem; color: var(--text-muted, #7386a3); }
.fu-quest-detail, .fu-plan-detail, .fu-quest-body, .fu-place-body, .fu-event-body, .fu-option-body { display: grid; gap: 4px; min-width: 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 72f8639..edbf31b 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -63,7 +63,7 @@ function createPageRoutes(ctx) {
title: null,
description: 'Something to do, alone, together, or live: IRL quests on OpenVibe.Live, a local guide from OpenStreetMap, and group plans that the group votes on.',
jsonLd: homeJsonLd(config),
-- body: raw(views.home({ counts, featured, plans: planOut.plans })),
++ body: raw(views.home({ counts, featured, plans: planOut.plans, now: s.now(), base: base() })),
});
});
@@@ -256,7 +256,7 @@ function createPageRoutes(ctx) {
title: 'Group plans',
description: 'Two or three options, one link, one vote each. The group picks the plan and the winner can go to Today.',
jsonLd: [seo.jsonLd.webPage({ name: 'Group plans on OpenVibe.Fun', url: `${base()}/plans`, description: 'Decide a plan together and send the winner to Today.', siteUrl: base() })],
-- body: raw(views.plans({ plans: out.plans })),
++ body: raw(views.plans({ plans: out.plans, now: s.now(), base: base() })),
});
});
@@@ -289,7 +289,7 @@ function createPageRoutes(ctx) {
title: out.plan.title,
description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160),
jsonLd: out.plan.visibility === 'public' ? [seo.jsonLd.webPage({ name: out.plan.title, url: `${base()}/plans/${out.plan.slug}`, description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160), siteUrl: base() })] : [],
-- body: raw(views.plan({ p: out.plan, options: out.options, winner: out.winner, signedIn: Boolean(signedIn(req)), mine: out.isOwner, staff: staffViewer(req), flash, problem, myOptionId: out.plan.my_option_id, totalVotes: out.plan.total_votes })),
++ body: raw(views.plan({ p: out.plan, options: out.options, winner: out.winner, signedIn: Boolean(signedIn(req)), mine: out.isOwner, staff: staffViewer(req), flash, problem, myOptionId: out.plan.my_option_id, totalVotes: out.plan.total_votes, now: s.now(), base: base() })),
}, status);
}
r.get('/plans/:slug', (req, res) => renderPlan(req, res, { flash: req.query.reported === '1' ? 'Thank you. Staff will look at the report.' : req.query.decided === '1' ? 'The winner is chosen.' : null }));
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 2021a6f..3172ed4 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -20,6 +20,7 @@
*/
const { esc } = require('../render/html');
const sc = require('openvibe-shared/showcase');
++const { remindLink } = require('./remind');
const STATUS_LABEL = { suggested: 'Suggested', accepted: 'Accepted', live: 'Live now', completed: 'Completed', verified: 'Verified', rejected: 'Not chosen', hidden: 'Hidden', voting: 'Voting', decided: 'Decided', cancelled: 'Cancelled' };
const CATEGORY_LABEL = { near_me: 'Near me', outside: 'Outside', brainy: 'Brainy', silly: 'Silly', chill: 'Chill' };
@@@ -45,12 +46,16 @@ function questCard(q) {
</a></li>`;
}
--function planCard(p) {
++function planCard(p, { now = Date.now(), base = '' } = {}) {
++ // "Remind me" for the moment the plan starts (plan L48 R3): a quiet link in the card's own line, only when the
++ // start is still ahead.
++ const remind = remindLink(p, { now, url: base ? `${base}/plans/${encodeURIComponent(p.slug)}` : '' });
return `<li class="fu-plan-card" data-plan="${esc(p.slug)}">
<a class="fu-plan-link" href="/plans/${esc(p.slug)}">
<span class="fu-plan-title">${esc(p.title)}</span>
--<span class="fu-plan-meta">${statusBadge(p.status)} · ${when(p.starts_at)}${p.city ? ` · ${esc(p.city)}` : ''}</span>
--</a></li>`;
++</a>
++<span class="fu-plan-meta">${statusBadge(p.status)} · ${when(p.starts_at)}${p.city ? ` · ${esc(p.city)}` : ''}${remind ? ` · ${remind}` : ''}</span>
++</li>`;
}
/** The home's picture: a small city block with idea pins and a quest card flipping up. */
@@@ -62,7 +67,7 @@ function cityMotif() {
</div>`;
}
--function home({ counts = {}, featured = [], plans = [] } = {}) {
++function home({ counts = {}, featured = [], plans = [], now = Date.now(), base = '' } = {}) {
// Real things first (owner, 10-31: empty sections made the rooms look unfinished). The local guide always has
// something; quests and plans lead once there are some, and until then share one short "be the first" row below.
const guide = `<section class="fu-section" id="fu-guide-teaser" aria-labelledby="fu-guide-teaser-h">
@@@ -76,7 +81,7 @@ function home({ counts = {}, featured = [], plans = [] } = {}) {
</section>` : '';
const plansSec = plans.length ? `<section class="fu-section" id="fu-plans" aria-labelledby="fu-plans-h">
<div class="fu-head-row"><h2 id="fu-plans-h">Plans, decided together</h2><a class="fu-more" href="/plans">All plans</a></div>
--<ol class="fu-plans">${plans.slice(0, 4).map(planCard).join('')}</ol>
++<ol class="fu-plans">${plans.slice(0, 4).map((p) => planCard(p, { now, base })).join('')}</ol>
</section>` : '';
const first = [
!featured.length ? `<li><b>IRL quests on Live</b><span>A streamer opens a board and chat suggests the first one.</span><a href="/live">Open a board</a></li>` : '',
@@@ -317,17 +322,20 @@ ${opts.map((o, i) => `<label class="fu-field"><span>Option ${i + 1}${i === 0 ? '
</form></div>`;
}
--function plans({ plans: list = [] } = {}) {
++function plans({ plans: list = [], now = Date.now(), base = '' } = {}) {
return `<div class="fu-wrap" id="fu-plans">
<header class="fu-page-h"><p class="fu-kicker">Group plans</p><h1>Decide it together</h1>
<p class="fu-lede">Two or three options, one link, one vote each. The winner is chosen by the group and can go to Today. A plan with votes from public chat shows the matching command.</p>
<div class="fu-actions"><a class="fu-btn" href="/plans/new">Start a plan</a><a class="fu-btn fu-btn-quiet" href="/guide">Find a place</a></div></header>
--${list.length ? `<ol class="fu-plans" id="fu-public-plans">${list.map(planCard).join('')}</ol>` : empty('No public plan yet. Start one and share the link with the group.', '/plans/new', 'Start a plan')}
++${list.length ? `<ol class="fu-plans" id="fu-public-plans">${list.map((p) => planCard(p, { now, base })).join('')}</ol>` : empty('No public plan yet. Start one and share the link with the group.', '/plans/new', 'Start a plan')}
</div>`;
}
--function plan({ p, options = [], winner = null, signedIn = false, mine = false, staff = false, flash = null, problem = null, myOptionId = null, totalVotes = 0 } = {}) {
++function plan({ p, options = [], winner = null, signedIn = false, mine = false, staff = false, flash = null, problem = null, myOptionId = null, totalVotes = 0, now = Date.now(), base = '' } = {}) {
const owner = mine || staff;
++ // "Remind me" for the moment the plan starts (plan L48 R3), beside the page's own actions; only when the start
++ // is still ahead.
++ const remind = remindLink(p, { now, url: base ? `${base}/plans/${encodeURIComponent(p.slug)}` : '', action: true });
return `<div class="fu-wrap" id="fu-plan" data-plan="${esc(p.slug)}">
<article class="fu-plan-detail">
<header class="fu-page-h"><p class="fu-kicker">${statusBadge(p.status)} · ${p.visibility === 'public' ? 'public' : p.visibility === 'unlisted' ? 'share link' : 'private'}</p><h1>${esc(p.title)}</h1>
@@@ -360,7 +368,7 @@ ${signedIn ? `<details class="fu-report"><summary>Report this plan</summary><for
<button class="fu-btn fu-btn-quiet" type="submit">Send the report</button></form></details>` : `<p class="fu-muted"><a href="/auth/login?next=${encodeURIComponent(`/plans/${p.slug}`)}">Sign in</a> to report.</p>`}
${staff ? `<form method="post" action="/plans/${esc(p.slug)}/hide" class="fu-inline"><button class="fu-btn fu-btn-quiet" type="submit">Hide (staff)</button></form>` : ''}
</section>
--<footer class="fu-plan-foot"><a class="fu-btn fu-btn-quiet" href="/plans">All plans</a><span class="fu-fine">Share link: ${esc(p.slug)}</span></footer>
++<footer class="fu-plan-foot"><a class="fu-btn fu-btn-quiet" href="/plans">All plans</a>${remind}<span class="fu-fine">Share link: ${esc(p.slug)}</span></footer>
</article></div>`;
}
ddiff --git a/server/views/remind.js b/server/views/remind.js
nnew file mode 100644
iindex 0000000..b3559c5
---- /dev/null
++++ b/server/views/remind.js
@@@ -0,0 +1,40 @@
++'use strict';
++
++/**
++ * "Remind me" on a plan's start (plan L48 R3): a link to openvibe.today/remind that sets a one-off reminder for the
++ * moment the plan starts. The person signs in there, picks how long before, and it reaches them in the bell and as a
++ * push on every OpenVibe site.
++ *
++ * The link is only there when the start is still ahead: a plan that has started, gets none.
++ */
++const kit = require('openvibe-shared/kit');
++
++const HOST = 'openvibe.fun';
++const REMIND = 'https://openvibe.today/remind';
++
++/** The moment a plan starts, in UTC, or null when it has none. */
++function startsAt(plan) {
++ const raw = plan && plan.starts_at;
++ if (!raw) return null;
++ const d = new Date(raw);
++ return Number.isFinite(d.getTime()) ? d.toISOString() : null;
++}
++
++/** The reminder's title: the plan's own name, the city it is in, and that it starts. */
++function remindTitle(plan) {
++ const name = String(plan && plan.title ? plan.title : '').trim();
++ const city = String(plan && plan.city ? plan.city : '').trim();
++ return city ? `${name} in ${city} starts` : `${name} starts`;
++}
++
++/** Today's "Remind me" link for this plan, or '' when there is nothing to remind about. */
++function remindLink(plan, { now = Date.now(), url = '', action = false } = {}) {
++ const at = startsAt(plan);
++ if (!at || Date.parse(at) <= now) return '';
++ const query = new URLSearchParams({ title: remindTitle(plan), at, url, from: HOST });
++ const href = `${REMIND}?${query}`.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
++ const cls = action ? 'fu-remind fu-btn fu-btn-quiet' : 'fu-remind';
++ return `<a class="${cls}" href="${href}">${kit.icon('bell', { size: 16 })}<span>Remind me</span></a>`;
++}
++
++module.exports = { startsAt, remindTitle, remindLink, HOST, REMIND };
ddiff --git a/test/remind-links.test.js b/test/remind-links.test.js
nnew file mode 100644
iindex 0000000..0eaaea3
---- /dev/null
++++ b/test/remind-links.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * "Remind me" on a plan's start (plan L48 R3): the link to openvibe.today/remind on the plan's own page and on the
++ * cards that show the start, with the right title, time and page — and no link at all once the start has passed.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++/** Every "Remind me" link on a page, as the URL it points at. */
++function remindLinks(html) {
++ return [...String(html).matchAll(/href="(https:\/\/openvibe\.today\/remind\?[^"]+)"/g)]
++ .map((m) => m[1].replace(/&amp;/g, '&'));
++}
++const query = (href) => Object.fromEntries(new URL(href.replace(/&amp;/g, '&')).searchParams);
++
++(async () => {
++ const t = await boot();
++ try {
++ const kim = t.network.addUser('kim');
++ const future = new Date(Date.now() + 172_800_000).toISOString();
++
++ await check('a plan that starts later links to Today\'s "Remind me" on its page and on the cards', async () => {
++ const r = await t.get('/api/v1/plans', { as: kim, json: { title: 'Friday with the group', summary: 'Two choices.', city: 'Portland', starts_at: future, timezone: 'UTC', visibility: 'public', options: [{ title: 'Mural walk' }, { title: 'Bowling' }] } });
++ assert.strictEqual(r.status, 201, r.text);
++ const slug = r.json().slug;
++
++ const page = await t.get(`/plans/${slug}`);
++ const links = remindLinks(page.text);
++ assert.ok(links.length, 'the plan\'s own page has a "Remind me" link');
++ const q = query(links[0]);
++ assert.strictEqual(q.title, 'Friday with the group in Portland starts');
++ assert.strictEqual(q.at, new Date(future).toISOString(), 'the start itself, in UTC');
++ assert.strictEqual(q.url, `https://openvibe.fun/plans/${slug}`, 'the plan\'s canonical page');
++ assert.strictEqual(q.from, 'openvibe.fun');
++
++ const list = await t.get('/plans');
++ const cardLink = remindLinks(list.text).find((h) => query(h).url === `https://openvibe.fun/plans/${slug}`);
++ assert.ok(cardLink, 'the plan card has the link too');
++
++ const home = await t.get('/');
++ const homeLink = remindLinks(home.text).find((h) => query(h).url === `https://openvibe.fun/plans/${slug}`);
++ assert.ok(homeLink, 'the front page\'s card has the link too');
++ assert.strictEqual(query(homeLink).at, new Date(future).toISOString());
++ });
++
++ await check('a plan that has started has no "Remind me" link', async () => {
++ const r = await t.get('/api/v1/plans', { as: kim, json: { title: 'Yesterday with the group', starts_at: future, timezone: 'UTC', visibility: 'public', options: [{ title: 'Option' }] } });
++ assert.strictEqual(r.status, 201, r.text);
++ const slug = r.json().slug;
++ // The start moves into the past: nobody may be reminded for a plan that has begun.
++ const past = new Date(Date.now() - 3600_000).toISOString();
++ await t.ctx.s.db.maybe('UPDATE fun_plans SET starts_at = $1, closes_at = $2 WHERE slug = $3', [past, past, slug]);
++ const page = await t.get(`/plans/${slug}`);
++ assert.strictEqual(remindLinks(page.text).length, 0, 'no link once the start has passed');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();