openvibers/openvibe.fun Commits

Group plans: date pickers in your time zone, no ISO typing (#112)

OP OpenVibers committed 4 days ago · 94ced1cd8f · 5 files, +65 −18

The plan form asked for "Starts at (ISO time)" as YYYY-MM-DDTHH:MM:SSZ with the time zone defaulting to UTC,
and the option pickers' local times were read as UTC (a 7:30 pm plan in Portland saved for 11:30 am there). The
start and voting-close fields are date pickers now; the time zone is filled from the visitor's device; a time with no
zone is read in the plan's (or board's) time zone, daylight saving included; a value with its own zone keeps it. The
quest board's close time works the same way, and the "ada" placeholders read in plain words.

Changes

ddiff --git a/server/http/api.js b/server/http/api.js
iindex cfc4192..46a2b18 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -159,7 +159,7 @@ function createApi(ctx) {
}
const out = await store.createBoard({
streamerSubject: p.subject, title: b.title, liveUsername: channel.username, chatUsername: b.chat_username || channel.username,
-- city: b.city, lat: b.lat, lon: b.lon, accessibleOnly: bool(b.accessible_only), maxSuggestions: b.max_suggestions, closesAt: b.closes_at,
++ city: b.city, lat: b.lat, lon: b.lon, accessibleOnly: bool(b.accessible_only), maxSuggestions: b.max_suggestions, closesAt: b.closes_at, timezone: b.timezone,
});
if (!out.ok) return refuse(res, out);
res.set('Location', `/api/v1/quests/boards/${out.board.id}`);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 53710b4..aa2fd16 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -142,7 +142,7 @@ function createPageRoutes(ctx) {
}
const out = await store.createBoard({
streamerSubject: req.viewer.subject, title: b.title, liveUsername: channel.username, chatUsername: b.chat_username || channel.username,
-- city: b.city, accessibleOnly: b.accessible_only === '1', maxSuggestions: b.max_suggestions, closesAt: b.closes_at,
++ city: b.city, accessibleOnly: b.accessible_only === '1', maxSuggestions: b.max_suggestions, closesAt: b.closes_at, timezone: b.timezone,
});
if (!out.ok) return page(req, res, { title: 'Open a quest board', body: raw(views.questNew({ values: formValues(b, ['live_username', 'title', 'city', 'closes_at', 'max_suggestions', 'chat_username']), problem: out.message })) }, out.status || 400);
return res.redirect(303, `/quests/boards/${encodeURIComponent(out.board.id)}`);
ddiff --git a/server/store.js b/server/store.js
iindex 77ee9f7..c9543a5 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -93,9 +93,25 @@ function validTimezone(tz) {
try { new Intl.DateTimeFormat('en-US', { timeZone: String(tz) }); return true; } catch { return false; }
}
--function parseWhen(v) {
++// A date picker sends local time with no zone ("2026-11-14T19:30"). Read as UTC, a 7:30 pm plan in Portland was saved
++// for 11:30 am there (11-09). With a time zone, such a value is read in that zone; a value with a zone keeps its own.
++const LOCAL_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
++function zonedToUtc(m, tz) {
++ const wall = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0));
++ const fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
++ const offset = (ts) => {
++ const p = Object.fromEntries(fmt.formatToParts(new Date(ts)).map((x) => [x.type, x.value]));
++ return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - ts;
++ };
++ let ts = wall - offset(wall);
++ ts = wall - offset(ts); // a second pass settles a daylight-saving edge
++ return new Date(ts);
++}
++function parseWhen(v, tz = null) {
if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v;
if (typeof v !== 'string' || !v.trim()) return null;
++ const m = LOCAL_TIME.exec(v.trim());
++ if (m && tz && validTimezone(tz)) return zonedToUtc(m, tz);
const d = new Date(v.trim());
return Number.isNaN(d.getTime()) ? null : d;
}
@@@ -170,7 +186,7 @@ function createStore(s, { search = { sync: async () => null }, config = {}, shar
}
// ── Quest boards and suggestions ─────────────────────────
-- async function createBoard({ streamerSubject, title, liveUsername, chatUsername, city = null, lat = null, lon = null, accessibleOnly = false, maxSuggestions = null, closesAt = null }) {
++ async function createBoard({ streamerSubject, title, liveUsername, chatUsername, city = null, lat = null, lon = null, accessibleOnly = false, maxSuggestions = null, closesAt = null, timezone = null }) {
if (!SUBJECT_RE.test(String(streamerSubject || ''))) return refusal(403, 'fun.person_required', 'A board belongs to a signed-in person.');
if (!USERNAME_RE.test(String(liveUsername || ''))) return refusal(400, 'fun.quest.invalid', 'live_username is required', 'live_username');
if (!USERNAME_RE.test(String(chatUsername || ''))) return refusal(400, 'fun.quest.invalid', 'chat_username is required', 'chat_username');
@@@ -181,7 +197,7 @@ function createStore(s, { search = { sync: async () => null }, config = {}, shar
const la = num(lat); const lo = num(lon);
if (la != null && (la < -90 || la > 90)) return refusal(400, 'fun.quest.invalid', 'lat must be -90 to 90', 'lat');
if (lo != null && (lo < -180 || lo > 180)) return refusal(400, 'fun.quest.invalid', 'lon must be -180 to 180', 'lon');
-- const close = closesAt ? parseWhen(closesAt) : new Date(s.now() + 4 * 60 * 60_000);
++ const close = closesAt ? parseWhen(closesAt, timezone) : new Date(s.now() + 4 * 60 * 60_000);
if (!close || close.getTime() <= s.now() + 60_000) return refusal(400, 'fun.quest.invalid', 'closes_at must be in the future', 'closes_at');
const max = Math.min(100, Math.max(1, Number(maxSuggestions) || questConfig.maxSuggestions || 30));
const id = s.newId('qbd');
@@@ -480,11 +496,11 @@ function createStore(s, { search = { sync: async () => null }, config = {}, shar
if (lo != null && (lo < -180 || lo > 180)) return refusal(400, 'fun.plan.invalid', 'lon must be -180 to 180', 'lon');
const tz = String(timezone || 'UTC');
if (!validTimezone(tz)) return refusal(400, 'fun.plan.invalid', 'timezone must be a valid IANA zone', 'timezone');
-- const start = parseWhen(startsAt);
++ const start = parseWhen(startsAt, tz);
if (!start) return refusal(400, 'fun.plan.invalid', 'starts_at must be an ISO time', 'starts_at');
if (start.getTime() < s.now() + 5 * 60_000) return refusal(400, 'fun.plan.invalid', 'starts_at must be at least 5 minutes ahead', 'starts_at');
if (start.getTime() > s.now() + 366 * 86_400_000) return refusal(400, 'fun.plan.invalid', 'starts_at must be within a year', 'starts_at');
-- const close = closesAt ? parseWhen(closesAt) : new Date(start.getTime() - 60 * 60_000);
++ const close = closesAt ? parseWhen(closesAt, tz) : new Date(start.getTime() - 60 * 60_000);
if (!close) return refusal(400, 'fun.plan.invalid', 'closes_at must be an ISO time', 'closes_at');
if (close.getTime() <= s.now() + 60_000) return refusal(400, 'fun.plan.invalid', 'closes_at must be in the future', 'closes_at');
if (close.getTime() > start.getTime()) return refusal(400, 'fun.plan.invalid', 'closes_at must not be after starts_at', 'closes_at');
@@@ -521,7 +537,7 @@ function createStore(s, { search = { sync: async () => null }, config = {}, shar
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, $11, $12) RETURNING *`,
[s.newId('pop'), id, o.title, o.details ? clean(o.details, 1200) : null, o.place_name ? clean(o.place_name, 200) : null,
num(o.lat), num(o.lon), Boolean(o.accessible), o.price_note ? clean(o.price_note, 80) : null,
-- o.starts_at ? parseWhen(o.starts_at)?.toISOString() || null : null, ownerSubject, o.position],
++ o.starts_at ? parseWhen(o.starts_at, checked.fields.timezone)?.toISOString() || null : null, ownerSubject, o.position],
);
saved.push(wireOption(opt));
}
@@@ -548,7 +564,7 @@ function createStore(s, { search = { sync: async () => null }, config = {}, shar
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, $11, $12) RETURNING *`,
[s.newId('pop'), plan.id, t, details ? clean(details, 1200) : null, placeName ? clean(placeName, 200) : null,
num(lat), num(lon), Boolean(accessible), priceNote ? clean(priceNote, 80) : null,
-- startsAt ? parseWhen(startsAt)?.toISOString() || null : null, subject, n + 1],
++ startsAt ? parseWhen(startsAt, plan.timezone)?.toISOString() || null : null, subject, n + 1],
);
const updated = await tx.maybe('UPDATE fun_plans SET revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *', [plan.id]);
const options = (await tx.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [plan.id])).map((o) => wireOption(o));
@@@ -739,4 +755,4 @@ function slugify(title) {
return s || 'fun';
}
--module.exports = { createStore, slugify, wireBoard, wireQuest, wirePlan, wireOption, wireReport, SLUG_RE, USERNAME_RE, SUBJECT_RE, QUEST_STATUSES, PUBLIC_QUEST_STATUSES, VISIBILITIES, PLAN_STATUSES, REPORT_KINDS };
++module.exports = { createStore, parseWhen, slugify, wireBoard, wireQuest, wirePlan, wireOption, wireReport, SLUG_RE, USERNAME_RE, SUBJECT_RE, QUEST_STATUSES, PUBLIC_QUEST_STATUSES, VISIBILITIES, PLAN_STATUSES, REPORT_KINDS };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 8dcda88..a3588cc 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -215,14 +215,16 @@ function questNew({ values = {}, problem = null } = {}) {
<p class="fu-lede">One board for one session. Your signed-in Network username must match the Live channel; Fun checks the public Live channel before it writes anything.</p></header>
${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
<form id="fu-board-form" class="fu-form fu-card" method="post" action="/quests/boards">
--<label class="fu-field"><span>Live channel</span><input name="live_username" type="text" maxlength="64" value="${esc(v.live_username || '')}" required placeholder="ada"></label>
++<label class="fu-field"><span>Live channel</span><input name="live_username" type="text" maxlength="64" value="${esc(v.live_username || '')}" required placeholder="your channel name"></label>
<label class="fu-field"><span>Board title</span><input name="title" type="text" maxlength="120" value="${esc(v.title || '')}" required placeholder="Saturday IRL quests"></label>
<label class="fu-field"><span>City (optional)</span><input name="city" type="text" maxlength="120" value="${esc(v.city || '')}" placeholder="Portland, Oregon"></label>
--<label class="fu-field"><span>Closes at (ISO time, optional)</span><input name="closes_at" type="text" maxlength="40" value="${esc(v.closes_at || '')}" placeholder="2026-10-28T22:00:00Z"></label>
++<label class="fu-field"><span>Closes (optional, four hours from now if empty)</span><input name="closes_at" type="datetime-local" value="${esc(v.closes_at || '')}"></label>
++<input type="hidden" name="timezone" value="${esc(v.timezone || 'UTC')}" data-auto>
<label class="fu-field"><span>Most suggestions</span><input name="max_suggestions" type="number" min="1" max="100" value="${esc(v.max_suggestions || 30)}"></label>
<label class="fu-check"><input type="checkbox" name="accessible_only" value="1"${v.accessible_only ? ' checked' : ''}> Only accessible activities</label>
<label class="fu-field"><span>Chat channel for votes</span><input name="chat_username" type="text" maxlength="64" value="${esc(v.chat_username || '')}" placeholder="defaults to the Live channel"></label>
<div class="fu-actions"><button class="fu-btn" type="submit">Open the board</button></div>
++<script>(function(){try{var z=Intl.DateTimeFormat().resolvedOptions().timeZone;document.querySelectorAll('input[name=timezone][data-auto]').forEach(function(i){if(z&&(!i.value||i.value==='UTC'))i.value=z;});}catch(e){}})();</script>
<p class="fu-fine">The board itself is unlisted; only the quest you accept becomes public.</p>
</form></div>`;
}
@@@ -323,17 +325,17 @@ ${v.place ? `<p class="fu-note">Planning a visit to ${esc(v.place)}${v.place_url
<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="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>When</span><input name="starts_at" type="datetime-local" value="${esc(v.starts_at || '')}" required></label>
++<label class="fu-field"><span>Voting closes (optional)</span><input name="closes_at" type="datetime-local" value="${esc(v.closes_at || '')}"></label>
++<label class="fu-field"><span>Time zone</span><input name="timezone" type="text" maxlength="80" value="${esc(v.timezone || 'UTC')}" placeholder="America/Los_Angeles" data-auto></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>
++<label class="fu-field"><span>Also take votes in a Live chat (optional)</span><input name="chat_username" type="text" maxlength="64" value="${esc(v.chat_username || '')}" placeholder="a channel name, like yours"></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><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">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>`;
++<p class="fu-fine">The group can vote on dates as well as activities. The selected option's date becomes the plan date. Times are in the time zone above, filled in from your device; voting closes an hour before the start unless you pick a time.</p>
++</form><script>(function(){try{var z=Intl.DateTimeFormat().resolvedOptions().timeZone;document.querySelectorAll('input[name=timezone][data-auto]').forEach(function(i){if(z&&(!i.value||i.value==='UTC'))i.value=z;});}catch(e){}})();</script></div>`;
}
function plans({ plans: list = [], now = Date.now(), base = '' } = {}) {
ddiff --git a/test/plan-times.test.js b/test/plan-times.test.js
nnew file mode 100644
iindex 0000000..fd4f361
---- /dev/null
++++ b/test/plan-times.test.js
@@@ -0,0 +1,29 @@
++'use strict';
++// A plan's time comes from a date picker now, and a date picker sends local time with no zone. Read as UTC, a 7:30 pm
++// plan in Portland was saved for 11:30 am there (11-09). The time is read in the plan's time zone, which the form fills
++// from the visitor's device; a value that carries its own zone keeps it.
++// node test/plan-times.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { parseWhen } = require('../server/store');
++
++// Portland in November is UTC-8; in July, UTC-7 (daylight saving).
++assert.strictEqual(parseWhen('2026-11-14T19:30', 'America/Los_Angeles').toISOString(), '2026-11-15T03:30:00.000Z');
++assert.strictEqual(parseWhen('2026-07-14T19:30', 'America/Los_Angeles').toISOString(), '2026-07-15T02:30:00.000Z');
++assert.strictEqual(parseWhen('2026-11-14T19:30:00', 'Asia/Tokyo').toISOString(), '2026-11-14T10:30:00.000Z');
++// A value with its own zone keeps it; no zone and no time zone reads as before; a bad zone is not trusted.
++assert.strictEqual(parseWhen('2026-11-14T19:30:00Z', 'America/Los_Angeles').toISOString(), '2026-11-14T19:30:00.000Z');
++assert.strictEqual(parseWhen('2026-11-14T19:30:00-05:00', 'Asia/Tokyo').toISOString(), '2026-11-15T00:30:00.000Z');
++assert.ok(parseWhen('2026-11-14T19:30', 'Not/AZone') instanceof Date, 'an unknown zone falls back to the plain reading');
++assert.strictEqual(parseWhen('', 'UTC'), null);
++assert.strictEqual(parseWhen('soon', 'UTC'), null);
++
++// The forms: date pickers, plain labels, the time zone filled from the device; no ISO instructions left.
++const views = fs.readFileSync(path.join(__dirname, '..', 'server', 'views', 'index.js'), 'utf8');
++assert.match(views, /<input name="starts_at" type="datetime-local"/);
++assert.match(views, /<input name="closes_at" type="datetime-local"/);
++assert.doesNotMatch(views, /ISO time|YYYY-MM-DDTHH|placeholder="ada"/);
++assert.match(views, /input\[name=timezone\]\[data-auto\]/, 'the device time zone fills the field');
++assert.match(views, /<input type="hidden" name="timezone" value="\$\{esc\(v\.timezone \|\| 'UTC'\)\}" data-auto>/, 'the quest board sends a time zone too');
++console.log('plan times: date pickers read in the plan time zone; no ISO typing');