openvibers/openvibe.fun Commits
Fun: a "Remind me" link for a plan's start (plan L48 R3) (#34)
Changes
@@@ -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; }@@@ -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 }));@@@ -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>`;}nnew file mode 100644@@@ -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, '&').replace(/"/g, '"');++ 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 };nnew file mode 100644@@@ -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(/&/g, '&'));++}++const query = (href) => Object.fromEntries(new URL(href.replace(/&/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();++})();
@@ -86,6 +86,14 @@ html.ov-motion .fu-flip-card { animation: fu-flip .8s cubic-bezier(.2,.7,.2,1) .
@@ -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: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-link, .fu-plan-link { transition: border-color .15s, transform .15s; }
.fu-quest-card, .fu-plan-card { display: grid; }
.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-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-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; }
.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; }
@@ -63,7 +63,7 @@ function createPageRoutes(ctx) {
@@ -63,7 +63,7 @@ function createPageRoutes(ctx) {
title: null,
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.',
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),
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) {
@@ -256,7 +256,7 @@ function createPageRoutes(ctx) {
title: 'Group plans',
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.',
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() })],
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) {
@@ -289,7 +289,7 @@ function createPageRoutes(ctx) {
title: out.plan.title,
title: out.plan.title,
description: (out.plan.summary || 'A group plan on OpenVibe.Fun').slice(0, 160),
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() })] : [],
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);
}, 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 }));
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 }));
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
*/
*/
const { esc } = require('../render/html');
const { esc } = require('../render/html');
const sc = require('openvibe-shared/showcase');
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 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' };
const CATEGORY_LABEL = { near_me: 'Near me', outside: 'Outside', brainy: 'Brainy', silly: 'Silly', chill: 'Chill' };
@@ -45,12 +46,16 @@ function questCard(q) {
@@ -45,12 +46,16 @@ function questCard(q) {
</a></li>`;
</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)}">
return `<li class="fu-plan-card" data-plan="${esc(p.slug)}">
<a class="fu-plan-link" href="/plans/${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-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. */
/** The home's picture: a small city block with idea pins and a quest card flipping up. */
@@ -62,7 +67,7 @@ function cityMotif() {
@@ -62,7 +67,7 @@ function cityMotif() {
</div>`;
</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
// 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.
// 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">
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 = [] } = {}) {
@@ -76,7 +81,7 @@ function home({ counts = {}, featured = [], plans = [] } = {}) {
</section>` : '';
</section>` : '';
const plansSec = plans.length ? `<section class="fu-section" id="fu-plans" aria-labelledby="fu-plans-h">
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>
<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>` : '';
</section>` : '';
const first = [
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>` : '',
!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 ? '
@@ -317,17 +322,20 @@ ${opts.map((o, i) => `<label class="fu-field"><span>Option ${i + 1}${i === 0 ? '
</form></div>`;
</form></div>`;
}
}
-function plans({ plans: list = [] } = {}) {
+function plans({ plans: list = [], now = Date.now(), base = '' } = {}) {
return `<div class="fu-wrap" id="fu-plans">
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>
<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>
<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>
<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>`;
</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;
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)}">
return `<div class="fu-wrap" id="fu-plan" data-plan="${esc(p.slug)}">
<article class="fu-plan-detail">
<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>
<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
@@ -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>`}
<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>` : ''}
${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>
</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>`;
</article></div>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -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, '&').replace(/"/g, '"');
+ 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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(/&/g, '&'));
+}
+const query = (href) => Object.fromEntries(new URL(href.replace(/&/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();
+})();