openvibers/openvibe.fun Commits

Shared places and date polls from Tours (#110)

OP OpenVibers committed 5 days ago · 71841adbf9 · 9 files, +202 −12

Changes

ddiff --git a/migrations/0003_our_places.sql b/migrations/0003_our_places.sql
nnew file mode 100644
iindex 0000000..1c6471e
---- /dev/null
++++ b/migrations/0003_our_places.sql
@@@ -0,0 +1,29 @@
++-- phase: expand
++-- An unlisted list is shared through its random link; signed-in members may add sourced places.
++CREATE TABLE fun_place_lists (
++ id text COLLATE "C" PRIMARY KEY,
++ owner_subject text COLLATE "C",
++ title text NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT fun_place_lists_owner CHECK (owner_subject IS NULL OR owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT fun_place_lists_title CHECK (length(title) BETWEEN 3 AND 120)
++);
++CREATE TABLE fun_place_list_members (
++ list_id text COLLATE "C" NOT NULL REFERENCES fun_place_lists(id) ON DELETE CASCADE,
++ subject text COLLATE "C" NOT NULL,
++ joined_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (list_id, subject),
++ CONSTRAINT fun_place_members_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')
++);
++CREATE INDEX fun_place_list_members_subject ON fun_place_list_members(subject);
++CREATE TABLE fun_place_list_items (
++ id text COLLATE "C" PRIMARY KEY,
++ list_id text COLLATE "C" NOT NULL REFERENCES fun_place_lists(id) ON DELETE CASCADE,
++ place_name text NOT NULL,
++ place_url text NOT NULL,
++ added_by text COLLATE "C",
++ created_at timestamptz NOT NULL DEFAULT now(),
++ UNIQUE (list_id, place_url),
++ CONSTRAINT fun_place_items_name CHECK (length(place_name) BETWEEN 1 AND 200),
++ CONSTRAINT fun_place_items_url CHECK (place_url ~ '^https://openvibe\.tours/place/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$')
++);
ddiff --git a/public/css/fun.css b/public/css/fun.css
iindex 5f253fe..0400f5a 100644
---- a/public/css/fun.css
++++ b/public/css/fun.css
@@@ -53,6 +53,8 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.fu-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
.fu-field input, .fu-field select, .fu-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }
.fu-field input:focus, .fu-field select:focus, .fu-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
++fieldset.fu-options { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; margin: 0; padding: 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; }
++fieldset.fu-options legend { padding: 0 6px; font-weight: 700; }
.fu-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
.fu-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
.fu-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
ddiff --git a/server/app.js b/server/app.js
iindex b0e2bef..d8636bd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -223,6 +223,7 @@ async function createApp(opts = {}) {
agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });
app.use(agent);
}
++ app.use(require('./places/lists').createPlaceListRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use((req, res) => send(res, 404, {
viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex b4796d7..53710b4 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -282,21 +282,29 @@ function createPageRoutes(ctx) {
});
r.get('/plans/new', (req, res) => {
-- if (!signedIn(req)) return page(req, res, { title: 'Start a plan', body: raw(views.signIn({ title: 'Start a group plan', text: 'A plan belongs to your OpenVibe account; the group can vote with the link.', next: '/plans/new' })) }, 401);
-- return page(req, res, { index: false, title: 'Start a group plan', body: raw(views.planNew({ values: { timezone: 'UTC' } })) });
++ if (!signedIn(req)) return page(req, res, { title: 'Start a plan', body: raw(views.signIn({ title: 'Start a group plan', text: 'A plan belongs to your OpenVibe account; the group can vote with the link.', next: req.originalUrl })) }, 401);
++ const place = typeof req.query.place === 'string' ? req.query.place.slice(0, 120) : '';
++ const city = typeof req.query.city === 'string' ? req.query.city.slice(0, 120) : '';
++ const placeUrl = typeof req.query.url === 'string' && /^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.query.url) ? req.query.url : '';
++ return page(req, res, { index: false, title: 'Start a group plan', body: raw(views.planNew({ values: {
++ timezone: 'UTC', place, city, place_url: placeUrl,
++ title: place ? `Go to ${place} together` : '', option1_title: place ? `Visit ${place}` : '',
++ lat: typeof req.query.lat === 'string' ? req.query.lat : '', lon: typeof req.query.lon === 'string' ? req.query.lon : '',
++ } })) });
});
r.post('/plans', guard, async (req, res) => {
const b = req.body || {};
-- const options = [1, 2, 3].map((i) => String(b[`option${i}_title`] || '').trim()).filter(Boolean).map((title) => ({ title }));
++ const options = [1, 2, 3].map((i) => ({ title: String(b[`option${i}_title`] || '').trim(), starts_at: b[`option${i}_starts_at`] || null,
++ place_name: b.place || null, lat: b.lat || null, lon: b.lon || null })).filter((o) => o.title);
const out = await store.createPlan({
ownerSubject: req.viewer.subject, ownerUsername: req.viewer.username,
-- title: b.title, summary: b.summary, city: b.city, timezone: b.timezone, startsAt: b.starts_at, closesAt: b.closes_at,
++ title: b.title, summary: b.summary, city: b.city, lat: b.lat, lon: b.lon, timezone: b.timezone, startsAt: b.starts_at, closesAt: b.closes_at,
visibility: b.visibility, chatUsername: b.chat_username, options,
});
if (!out.ok) {
-- const values = { ...formValues(b, ['title', 'summary', 'city', 'starts_at', 'closes_at', 'timezone', 'visibility', 'chat_username']) };
-- for (const i of [1, 2, 3]) values[`option${i}_title`] = b[`option${i}_title`] || '';
++ const values = { ...formValues(b, ['title', 'summary', 'city', 'starts_at', 'closes_at', 'timezone', 'visibility', 'chat_username', 'place', 'place_url', 'lat', 'lon']) };
++ for (const i of [1, 2, 3]) { values[`option${i}_title`] = b[`option${i}_title`] || ''; values[`option${i}_starts_at`] = b[`option${i}_starts_at`] || ''; }
return page(req, res, { title: 'Start a group plan', body: raw(views.planNew({ values, problem: out.message })) }, out.status || 400);
}
return res.redirect(303, `/plans/${encodeURIComponent(out.plan.slug)}`);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 8910ac7..7f0bec6 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -24,6 +24,9 @@ const TABLES = [
{ table: 'fun_plans', subject: 'owner_subject', file: 'plans.json', erase: { anonymize: { owner_username: null } } },
{ table: 'fun_plan_options', subject: 'created_by', file: 'plan-options.json', erase: { anonymize: {} } },
{ table: 'fun_plan_votes', subject: 'subject', file: 'plan-votes.json', erase: 'delete' },
++ { table: 'fun_place_lists', subject: 'owner_subject', file: 'place-lists.json', erase: 'delete' },
++ { table: 'fun_place_list_members', subject: 'subject', file: 'place-list-memberships.json', erase: 'delete' },
++ { table: 'fun_place_list_items', subject: 'added_by', file: 'place-list-items.json', erase: 'delete' },
{ table: 'fun_reports', subject: 'subject', file: 'reports.json', erase: 'delete' },
];
ddiff --git a/server/places/lists.js b/server/places/lists.js
nnew file mode 100644
iindex 0000000..7e0a79a
---- /dev/null
++++ b/server/places/lists.js
@@@ -0,0 +1,86 @@
++'use strict';
++
++const express = require('express');
++const { asyncRouter } = require('../http/router');
++const { sameOrigin } = require('../http/principal');
++const { esc, raw } = require('../render/html');
++const { send } = require('../render/layout');
++
++const validPlace = (url) => /^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(url || ''));
++
++function createPlaceListRoutes({ config, s }) {
++ const r = asyncRouter();
++ r.use(express.urlencoded({ extended: false, limit: '16kb' }));
++ const person = (req) => req.viewer?.kind === 'user' ? req.viewer.subject : null;
++ const page = (req, res, title, body, status = 200) => send(res, status, {
++ viewer: req.viewer, config, path: req.originalUrl, index: false, cache: null, title, body: raw(body),
++ });
++ const signIn = (req, res) => page(req, res, 'Our places', `<div class="fu-wrap fu-page-pad fu-narrow"><h1>Our places</h1><p>Sign in to make or join a shared list.</p><a class="fu-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in</a></div>`, 401);
++ const guarded = (req, res, next) => {
++ if (!person(req)) return signIn(req, res);
++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, 'Not from this site', '<div class="fu-wrap"><h1>Use the form on OpenVibe.Fun</h1></div>', 403);
++ next();
++ };
++
++ r.get('/places/new', (req, res) => {
++ if (!person(req)) return signIn(req, res);
++ const name = String(req.query.place || '').slice(0, 200);
++ const url = validPlace(req.query.url) ? String(req.query.url) : '';
++ return page(req, res, 'Our places', `<div class="fu-wrap fu-narrow" id="fu-places-new"><header class="fu-page-h"><p class="fu-kicker">Together</p><h1>Our places</h1><p class="fu-lede">Keep a list with the people you want to go with. Send its private link; each person joins with their OpenVibe account.</p></header>
++<form class="fu-form fu-card" method="post" action="/places"><label class="fu-field"><span>List name</span><input name="title" maxlength="120" required value="Our places"></label>
++${url ? `<input type="hidden" name="place_url" value="${esc(url)}"><input type="hidden" name="place_name" value="${esc(name)}"><p>Start with <a href="${esc(url)}" rel="noopener">${esc(name)}</a></p>` : ''}
++<button class="fu-btn" type="submit">Create our list</button></form></div>`);
++ });
++
++ r.post('/places', guarded, async (req, res) => {
++ const title = String(req.body.title || '').trim().slice(0, 120);
++ if (title.length < 3) return page(req, res, 'Our places', '<div class="fu-wrap"><h1>Give your list a name</h1></div>', 400);
++ const id = s.newId('lst');
++ const subject = person(req);
++ await s.tx(async (t) => {
++ await t.exec('INSERT INTO fun_place_lists(id, owner_subject, title) VALUES ($1,$2,$3)', [id, subject, title]);
++ await t.exec('INSERT INTO fun_place_list_members(list_id, subject) VALUES ($1,$2)', [id, subject]);
++ if (validPlace(req.body.place_url) && String(req.body.place_name || '').trim()) {
++ await t.exec('INSERT INTO fun_place_list_items(id, list_id, place_name, place_url, added_by) VALUES ($1,$2,$3,$4,$5)',
++ [s.newId('lpi'), id, String(req.body.place_name).trim().slice(0, 200), req.body.place_url, subject]);
++ }
++ });
++ return res.redirect(303, `/places/${id}`);
++ });
++
++ r.get('/places/:id', async (req, res) => {
++ const id = String(req.params.id || '');
++ const list = await s.db.maybe('SELECT id, title FROM fun_place_lists WHERE id=$1', [id]);
++ if (!list) return page(req, res, 'List not found', '<div class="fu-wrap"><h1>List not found</h1></div>', 404);
++ const items = await s.db.many('SELECT place_name, place_url FROM fun_place_list_items WHERE list_id=$1 ORDER BY created_at, id', [id]);
++ const members = await s.db.value('SELECT COUNT(*)::int FROM fun_place_list_members WHERE list_id=$1', [id]);
++ const joined = person(req) && await s.db.maybe('SELECT 1 FROM fun_place_list_members WHERE list_id=$1 AND subject=$2', [id, person(req)]);
++ return page(req, res, list.title, `<div class="fu-wrap fu-narrow" id="fu-places-list"><header class="fu-page-h"><p class="fu-kicker">Together · ${Number(members)} ${Number(members) === 1 ? 'person' : 'people'}</p><h1>${esc(list.title)}</h1><p class="fu-lede">Share this page with the people you want to go with. Signed-in members can add places.</p></header>
++${items.length ? `<ol class="fu-places">${items.map((item) => `<li><a href="${esc(item.place_url)}" rel="noopener">${esc(item.place_name)}</a></li>`).join('')}</ol>` : '<p>No place saved yet.</p>'}
++${joined ? `<form class="fu-form fu-card" method="post" action="/places/${esc(id)}/items"><label class="fu-field"><span>Place name</span><input name="place_name" maxlength="200" required></label><label class="fu-field"><span>OpenVibe.Tours place link</span><input name="place_url" type="url" required placeholder="https://openvibe.tours/place/..."></label><button class="fu-btn" type="submit">Add place</button></form>` : person(req) ? `<form method="post" action="/places/${esc(id)}/join"><button class="fu-btn" type="submit">Join this list</button></form>` : `<a class="fu-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in to join</a>`}
++<p><a href="https://openvibe.tours/cities">Find more places</a></p></div>`);
++ });
++
++ r.post('/places/:id/join', guarded, async (req, res) => {
++ const id = String(req.params.id || '');
++ const list = await s.db.maybe('SELECT id FROM fun_place_lists WHERE id=$1', [id]);
++ if (!list) return page(req, res, 'List not found', '<div class="fu-wrap"><h1>List not found</h1></div>', 404);
++ await s.db.exec('INSERT INTO fun_place_list_members(list_id, subject) VALUES ($1,$2) ON CONFLICT DO NOTHING', [id, person(req)]);
++ return res.redirect(303, `/places/${id}`);
++ });
++
++ r.post('/places/:id/items', guarded, async (req, res) => {
++ const id = String(req.params.id || '');
++ const member = await s.db.maybe('SELECT 1 FROM fun_place_list_members WHERE list_id=$1 AND subject=$2', [id, person(req)]);
++ if (!member) return page(req, res, 'Join first', '<div class="fu-wrap"><h1>Join this list first</h1></div>', 403);
++ const name = String(req.body.place_name || '').trim().slice(0, 200);
++ const url = String(req.body.place_url || '');
++ if (!name || !validPlace(url)) return page(req, res, 'Choose a Tours place', '<div class="fu-wrap"><h1>Use an OpenVibe.Tours place link</h1></div>', 400);
++ await s.db.exec(`INSERT INTO fun_place_list_items(id, list_id, place_name, place_url, added_by)
++ VALUES ($1,$2,$3,$4,$5) ON CONFLICT(list_id, place_url) DO NOTHING`, [s.newId('lpi'), id, name, url, person(req)]);
++ return res.redirect(303, `/places/${id}`);
++ });
++ return r;
++}
++
++module.exports = { createPlaceListRoutes, validPlace };
ddiff --git a/server/store.js b/server/store.js
iindex 4e058cc..77ee9f7 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -593,7 +593,9 @@ function createStore(s, { search = { sync: async () => null }, config = {}, shar
if (!rows.length) return refusal(409, 'fun.plan_no_votes', 'Nobody has voted yet.');
const votes = rows.map((r) => ({ option_id: r.option_id, votes: Number(r.votes) || 0 }));
if (votes.length > 1 && votes[0].votes === votes[1].votes) return refusal(409, 'fun.plan_tie', 'The vote is tied; add a tie-break or ask one more person to vote.');
-- const updated = await t.maybe("UPDATE fun_plans SET status = 'decided', winner_option_id = $2, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [plan.id, votes[0].option_id]);
++ const updated = await t.maybe(`UPDATE fun_plans SET status = 'decided', winner_option_id = $2,
++ starts_at = COALESCE((SELECT starts_at FROM fun_plan_options WHERE id = $2 AND plan_id = $1), starts_at),
++ revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`, [plan.id, votes[0].option_id]);
const options = (await t.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [plan.id])).map((o) => wireOption(o));
const out = wirePlan(updated, { options, votes });
if (updated.visibility === 'public') await search.sync(t, 'plan', out, options);
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 6623eac..8dcda88 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -319,19 +319,20 @@ function planNew({ values = {}, problem = null } = {}) {
<p class="fu-lede">Add two or three things to do, send the link to the group, and let the votes choose. The winner can go to your own Today in one press; Fun never writes to anyone else's calendar.</p></header>
${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
<form id="fu-plan-form" class="fu-form fu-card" method="post" action="/plans">
++${v.place ? `<p class="fu-note">Planning a visit to ${esc(v.place)}${v.place_url ? ` · <a href="${esc(v.place_url)}" rel="noopener">See the place</a>` : ''}</p><input type="hidden" name="place" value="${esc(v.place)}"><input type="hidden" name="place_url" value="${esc(v.place_url || '')}"><input type="hidden" name="lat" value="${esc(v.lat || '')}"><input type="hidden" name="lon" value="${esc(v.lon || '')}">` : ''}
<label class="fu-field"><span>Plan title</span><input name="title" type="text" maxlength="120" value="${esc(v.title || '')}" required placeholder="Friday with the group"></label>
<label class="fu-field"><span>Why / notes (optional)</span><textarea name="summary" rows="2" maxlength="1200">${esc(v.summary || '')}</textarea></label>
<label class="fu-field"><span>City</span><input name="city" type="text" maxlength="120" value="${esc(v.city || '')}" placeholder="Portland, Oregon"></label>
--<label class="fu-field"><span>Starts at (ISO time)</span><input name="starts_at" type="text" maxlength="40" value="${esc(v.starts_at || '')}" required placeholder="2026-10-30T19:00:00Z"></label>
--<label class="fu-field"><span>Voting closes at (ISO time, optional)</span><input name="closes_at" type="text" maxlength="40" value="${esc(v.closes_at || '')}" placeholder="2026-10-29T19:00:00Z"></label>
++<label class="fu-field"><span>Starts at (ISO time)</span><input name="starts_at" type="text" maxlength="40" value="${esc(v.starts_at || '')}" required placeholder="YYYY-MM-DDTHH:MM:SSZ"></label>
++<label class="fu-field"><span>Voting closes at (ISO time, optional)</span><input name="closes_at" type="text" maxlength="40" value="${esc(v.closes_at || '')}" placeholder="YYYY-MM-DDTHH:MM:SSZ"></label>
<label class="fu-field"><span>Timezone</span><input name="timezone" type="text" maxlength="80" value="${esc(v.timezone || 'UTC')}" placeholder="America/Los_Angeles"></label>
<label class="fu-field"><span>Who can see it?</span><select name="visibility"><option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Anyone with the link</option><option value="public"${v.visibility === 'public' ? ' selected' : ''}>Public</option><option value="hidden"${v.visibility === 'hidden' ? ' selected' : ''}>Only me</option></select></label>
<label class="fu-field"><span>Chat channel for votes (optional)</span><input name="chat_username" type="text" maxlength="64" value="${esc(v.chat_username || '')}" placeholder="ada"></label>
<fieldset class="fu-options"><legend>Options</legend>
--${opts.map((o, i) => `<label class="fu-field"><span>Option ${i + 1}${i === 0 ? '' : ' (optional)'}</span><input name="option${i + 1}_title" type="text" maxlength="120" value="${esc(o)}" placeholder="${i === 0 ? 'The mural walk' : ''}"></label>`).join('')}
++${opts.map((o, i) => `<label class="fu-field"><span>Option ${i + 1}${i === 0 ? '' : ' (optional)'}</span><input name="option${i + 1}_title" type="text" maxlength="120" value="${esc(o)}" placeholder="${i === 0 ? 'The mural walk' : ''}"></label><label class="fu-field"><span>Option ${i + 1} date and time (optional)</span><input name="option${i + 1}_starts_at" type="datetime-local" value="${esc(v[`option${i + 1}_starts_at`] || '')}"></label>`).join('')}
</fieldset>
<div class="fu-actions"><button class="fu-btn" type="submit">Start the plan</button></div>
--<p class="fu-fine">Times are UTC unless the timezone says otherwise. Voting closes one hour before the start when left blank.</p>
++<p class="fu-fine">The group can vote on dates as well as activities. The selected option's date becomes the plan date. Enter ISO time for the main start; voting closes one hour before it when left blank.</p>
</form></div>`;
}
@@@ -360,7 +361,7 @@ ${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
<section class="fu-section" id="fu-plan-options" aria-labelledby="fu-plan-options-h"><div class="fu-head-row"><h2 id="fu-plan-options-h">The options</h2><span class="fu-count">${options.length} ${options.length === 1 ? 'option' : 'options'}</span></div>
${options.length ? `<ol class="fu-options-list">${options.map((o) => `<li class="fu-option${o.id === (winner && winner.id) ? ' is-winner' : ''}${o.id === myOptionId ? ' is-mine' : ''}" data-option="${esc(o.id)}">
<div class="fu-option-body"><h3>${Number(o.position)}. ${esc(o.title)}${o.id === (winner && winner.id) ? ' <span class="fu-winner">chosen</span>' : ''}</h3>
--${o.details ? `<p>${esc(o.details)}</p>` : ''}
++${o.details ? `<p>${esc(o.details)}</p>` : ''}${o.starts_at ? `<p>${when(o.starts_at)}</p>` : ''}
<p class="fu-option-meta">${esc(String(o.votes || 0))} ${Number(o.votes) === 1 ? 'vote' : 'votes'} · ${pct(o.votes, totalVotes)}%${o.accessible ? ' · <span class="fu-accessible">Accessible</span>' : ''}${o.price_note ? ` · ${esc(o.price_note)}` : ''}${o.place_name ? ` · ${esc(o.place_name)}` : ''}</p>
<div class="fu-vote-bar" role="img" aria-label="${esc(String(o.votes || 0))} votes"><span style="width:${pct(o.votes, totalVotes)}%"></span></div></div>
${signedIn && p.status === 'voting' ? `<form method="post" action="/plans/${esc(p.slug)}/vote" class="fu-inline"><input type="hidden" name="option_id" value="${esc(o.id)}"><button class="fu-btn fu-btn-small" type="submit"${o.id === myOptionId ? ' disabled' : ''}>${o.id === myOptionId ? 'Your vote' : 'Vote'}</button></form>` : ''}
ddiff --git a/test/places-together.test.js b/test/places-together.test.js
nnew file mode 100644
iindex 0000000..0c0627c
---- /dev/null
++++ b/test/places-together.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ const kim = t.network.addUser('kim');
++ const ada = t.network.addUser('ada');
++ const place = 'https://openvibe.tours/place/kubota-garden-plc_0123456789ABCDEFGHJKMNPQRS';
++ await check('Tours place fills the plan form and signed-out redirect keeps context', async () => {
++ const url = `/plans/new?${new URLSearchParams({ place: 'Kubota Garden', city: 'Seattle', url: place })}`;
++ const signedOut = await t.get(url);
++ assert.strictEqual(signedOut.status, 401);
++ assert.match(signedOut.text, /Kubota/);
++ const form = await t.get(url, { as: kim });
++ assert.match(form.text, /Go to Kubota Garden together/);
++ assert.match(form.text, /Option 1 date and time/);
++ assert.match(form.text, /See the place/);
++ });
++ await check('a voted date becomes the plan date', async () => {
++ const first = new Date(Date.now() + 3 * 86_400_000).toISOString();
++ const second = new Date(Date.now() + 4 * 86_400_000).toISOString();
++ const created = await t.get('/api/v1/plans', { as: kim, json: {
++ title: 'Go to Kubota Garden together', city: 'Seattle', starts_at: first, timezone: 'UTC',
++ options: [{ title: 'Saturday', place_name: 'Kubota Garden', starts_at: first }, { title: 'Sunday', place_name: 'Kubota Garden', starts_at: second }],
++ } });
++ assert.strictEqual(created.status, 201, created.text);
++ const plan = created.json();
++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/vote`, { as: ada, json: { option_id: plan.options[1].id } })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/decide`, { as: kim, json: {} })).status, 200);
++ const decided = (await t.get(`/api/v1/plans/${plan.slug}`, { as: kim })).json();
++ assert.strictEqual(decided.plan.starts_at, second);
++ });
++ let listPath;
++ await check('two people can share a sourced list and add a place', async () => {
++ const created = await t.get('/places', { as: kim, form: { title: 'Our places', place_name: 'Kubota Garden', place_url: place } });
++ assert.strictEqual(created.status, 303, created.text);
++ listPath = created.headers.get('location');
++ assert.match(listPath, /^\/places\/lst_/);
++ assert.match((await t.get(listPath)).text, /Kubota Garden/);
++ const joined = await t.get(`${listPath}/join`, { as: ada, form: {} , method: 'POST' });
++ assert.strictEqual(joined.status, 303);
++ const after = await t.get(listPath, { as: ada });
++ assert.match(after.text, /2 people/);
++ const added = await t.get(`${listPath}/items`, { as: ada, form: { place_name: 'Another place', place_url: 'https://openvibe.tours/place/another-place-plc_0123456789ABCDEFGHJKMNPQRT' } });
++ assert.strictEqual(added.status, 303);
++ assert.match((await t.get(listPath)).text, /Another place/);
++ });
++ await check('a nonmember, another origin and an unrelated URL cannot add a place', async () => {
++ const bo = t.network.addUser('bo');
++ assert.strictEqual((await t.get(`${listPath}/items`, { as: bo, form: { place_name: 'No', place_url: place } })).status, 403);
++ assert.strictEqual((await t.get(`${listPath}/items`, { as: ada, origin: 'https://elsewhere.example', form: { place_name: 'No', place_url: place } })).status, 403);
++ assert.strictEqual((await t.get(`${listPath}/items`, { as: ada, form: { place_name: 'No', place_url: 'https://elsewhere.example/' } })).status, 400);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { process.stdout.write(String(err.stack || err)); process.exit(1); });