openvibers/openvibe.live Commits

Today's quest leads to OpenVibe.Quest: where you are and what waits there (#786)

OP OpenVibers committed 3 days ago · 46806083d9 · 6 files, +145 −37

The home page's quest card (ask 283):
- The head: "Today's quest · 1 of 3" (or "· Done"), the quest's title in full (no "Today:" prefix, no
  two-line clamp that cut it to "Make your way around..."), the three-part bar, and your streak as a chip.
- Open: the quest's story, then each step as a row that takes you where it is done, with the site's
  name under it; your next step is marked.
- The foot is the door to OpenVibe.Quest with what waits there: your badges, quests going and how many
  more to try; for everyone, the catalog ("39 quests on 15 sites, each with a badge").
- In tone, without the hard border.

/api/home/daily-quest adds `more`: the catalog's size (kept ten minutes) and, signed in, the person's
badge and quest counts from Quest's /me/quests. A Quest that cannot answer these still gives the card.

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 650a890b..c97cd372 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -26,27 +26,39 @@
color: var(--accent-light);
}
.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
--.home-daily-quest { max-width: 520px; margin: 0 auto 22px; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); border-radius: 16px; background: var(--bg-card); box-shadow: 0 14px 40px rgba(0,0,0,.18); text-align: left; overflow: hidden; }
--.home-daily-quest[hidden], .home-daily-body[hidden] { display: none; }
--.home-daily-head { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 56px; padding: 9px 14px; background: none; border: 0; color: var(--text-primary); font: inherit; text-align: left; cursor: pointer; }
++/* Today's quest (ask 283): a card in tone, no border. The head says what today's quest is and how far you are, the
++ title in full; open, each step is a row that takes you where it is done (the next one marked), and the foot is the
++ door to OpenVibe.Quest with what waits there (your streak, badges and quests going; for everyone, the catalog). */
++.home-daily-quest { max-width: 520px; margin: 0 auto 22px; border-radius: 16px; background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); box-shadow: 0 10px 30px rgba(0,0,0,.14); text-align: left; overflow: hidden; }
++.home-daily-quest[hidden], .home-daily-body[hidden], .home-daily-chip[hidden] { display: none; }
++.home-daily-head { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 64px; padding: 12px 14px; background: none; border: 0; color: var(--text-primary); font: inherit; text-align: left; cursor: pointer; }
.home-daily-titles { display: flex; flex: 1; flex-direction: column; min-width: 0; }
--.home-daily-kicker { color: var(--accent-light, var(--accent)); font-size: .65rem; font-weight: 800; letter-spacing: .09em; }
--/* The quest's name in full (two lines on a phone, never "Make your way around …"), and a three-part bar for the steps. */
--.home-daily-title { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; font-size: .95rem; font-weight: 800; line-height: 1.3; }
--.home-daily-bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; max-width: 160px; margin-top: 6px; }
++.home-daily-kicker { color: var(--accent-light, var(--accent)); font-size: .66rem; font-weight: 800; letter-spacing: .09em; }
++.home-daily-title { font-size: 1rem; font-weight: 800; line-height: 1.3; text-wrap: balance; }
++.home-daily-bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; max-width: 160px; margin-top: 7px; }
.home-daily-bar i { height: 4px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary) 12%, transparent); transition: background .4s ease; }
.home-daily-quest[data-progress="1"] .home-daily-bar i:nth-child(-n+1), .home-daily-quest[data-progress="2"] .home-daily-bar i:nth-child(-n+2), .home-daily-quest[data-progress="3"] .home-daily-bar i { background: var(--accent-light, var(--accent)); }
.home-daily-quest[data-progress="3"] .home-daily-bar i { background: var(--success, #22c55e); }
--.home-daily-chip { flex: none; padding: 5px 9px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: .72rem; font-weight: 700; }
++.home-daily-chip { display: inline-flex; flex: none; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 999px; background: color-mix(in srgb, #f97316 16%, transparent); color: color-mix(in srgb, #f97316 78%, var(--text-primary)); font-size: .78rem; font-weight: 800; }
.home-daily-chev { color: var(--text-muted); transition: transform .2s; }
.home-daily-quest.open .home-daily-chev { transform: rotate(180deg); }
--.home-daily-body { padding: 0 14px 14px; }
--.home-daily-steps { list-style: none; padding: 0; margin: 0 0 10px; }
--.home-daily-steps li { display: flex; align-items: center; gap: 10px; min-height: 52px; border-top: 1px solid var(--border); font-size: .85rem; }
--.home-daily-steps li > span:nth-child(2) { flex: 1; }
++.home-daily-body { padding: 0 10px 10px; }
++.home-daily-story { margin: -2px 4px 8px; color: var(--text-secondary); font-size: .82rem; line-height: 1.45; }
++.home-daily-steps { display: grid; gap: 2px; list-style: none; padding: 0; margin: 0 0 8px; }
++.home-daily-steps a { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px; border-radius: 12px; color: var(--text-primary); text-decoration: none; transition: background .15s; }
++.home-daily-steps a:hover, .home-daily-steps a:focus-visible { background: color-mix(in srgb, var(--text-primary) 6%, transparent); }
++.home-daily-steps li.is-next a { background: color-mix(in srgb, var(--accent) 13%, transparent); }
++.home-daily-step { display: flex; flex: 1; flex-direction: column; min-width: 0; font-size: .88rem; font-weight: 650; line-height: 1.3; }
++.home-daily-step small { color: var(--text-muted); font-size: .72rem; font-weight: 600; }
++.home-daily-steps li.is-next small { color: var(--accent-light, var(--accent)); }
++.home-daily-steps li.is-done .home-daily-step > span { color: var(--text-muted); }
++.home-daily-go { color: var(--text-muted); font-size: .72rem; }
.home-daily-check { display: grid; place-items: center; flex: none; width: 24px; height: 24px; border-radius: 50%; background: var(--success, #22c55e); color: #fff; font-size: .75rem; font-weight: 900; }
--.home-daily-steps li.is-done > span:nth-child(2) { color: var(--text-muted); }
--.home-daily-action { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 16px; border-radius: 10px; background: var(--accent); color: var(--bg-primary); font-weight: 800; text-decoration: none; }
++.home-daily-more { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 15%, var(--bg-card)); color: var(--text-primary); text-decoration: none; transition: background .15s; }
++.home-daily-more:hover, .home-daily-more:focus-visible { background: color-mix(in srgb, var(--accent) 22%, var(--bg-card)); }
++.home-daily-more-text { display: flex; flex: 1; flex-direction: column; min-width: 0; color: var(--text-secondary); font-size: .78rem; line-height: 1.35; }
++.home-daily-more-text b { color: var(--text-primary); font-size: .88rem; }
++.home-daily-more > i { color: var(--accent-light, var(--accent)); }
@media (max-width: 599px) {
/* The pulse (goals, the latest activity, the week's leaders) is one row that scrolls sideways, not five stacked cards. */
ddiff --git a/public/fragments/home.html b/public/fragments/home.html
iindex 5bb8c369..04c38e64 100644
---- a/public/fragments/home.html
++++ b/public/fragments/home.html
@@@ -124,12 +124,16 @@
<button class="home-daily-head" type="button" id="home-daily-toggle" aria-expanded="false" aria-controls="home-daily-body">
<span class="home-daily-icon ov-icon" data-icon="quest" data-size="32" aria-hidden="true"></span>
<span class="home-daily-titles"><span class="home-daily-kicker" id="home-daily-kicker"></span><span class="home-daily-title" id="home-daily-title"></span><span class="home-daily-bar" aria-hidden="true"><i></i><i></i><i></i></span></span>
-- <span class="home-daily-chip" id="home-daily-chip"></span>
++ <span class="home-daily-chip" id="home-daily-chip" hidden></span>
<i class="fa-solid fa-chevron-down home-daily-chev" aria-hidden="true"></i>
</button>
<div class="home-daily-body" id="home-daily-body" hidden>
++ <p class="home-daily-story" id="home-daily-story"></p>
<ol class="home-daily-steps" id="home-daily-steps"></ol>
-- <a class="home-daily-action" id="home-daily-action" href="https://openvibe.quest/"></a>
++ <a class="home-daily-more" id="home-daily-more" href="https://openvibe.quest/">
++ <span class="home-daily-more-text"><b>More on OpenVibe.Quest</b><span id="home-daily-more-line"></span></span>
++ <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
++ </a>
</div>
</div>
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 949222ea..95dd7f95 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1021,38 +1021,80 @@ async function loadDailyQuest() {
if (!quest || !Array.isArray(quest.steps) || quest.steps.length !== 3) { card.hidden = true; return; }
const mine = !!data.signed_in;
const progress = Math.max(0, Math.min(3, Number(quest.progress) || 0));
++ const streak = Math.max(0, Number(quest.streak) || 0);
card.dataset.progress = mine ? String(progress) : '0'; // the three-part bar under the title
-- document.getElementById('home-daily-kicker').textContent = mine ? "TODAY'S QUEST" : 'DAILY QUESTS';
-- document.getElementById('home-daily-title').textContent = mine ? quest.title : 'Today: ' + quest.title;
-- document.getElementById('home-daily-chip').textContent = mine
-- ? quest.completed ? `Done for today · ${quest.streak}-day streak` : `${progress} of 3`
-- : 'New every day';
++ // The head: what today's quest is and how far you are. The title is the quest's own, in full.
++ document.getElementById('home-daily-kicker').textContent = !mine ? "TODAY'S QUEST"
++ : quest.completed ? "TODAY'S QUEST · DONE" : `TODAY'S QUEST · ${progress} OF 3`;
++ document.getElementById('home-daily-title').textContent = String(quest.title || '');
++ const chip = document.getElementById('home-daily-chip');
++ chip.replaceChildren();
++ chip.hidden = !(mine && streak > 0);
++ if (!chip.hidden) {
++ const fire = document.createElement('i');
++ fire.className = 'fa-solid fa-fire';
++ fire.setAttribute('aria-hidden', 'true');
++ chip.append(fire, ` ${streak}`);
++ chip.title = `${streak}-day streak`;
++ chip.setAttribute('aria-label', chip.title);
++ }
++ document.getElementById('home-daily-story').textContent = String(quest.story || '');
++ // Each step is a row that takes you where it is done; the next one for you is marked.
++ const siteName = (host) => {
++ const part = String(host || '').split('.')[1] || '';
++ return part ? 'OpenVibe.' + part.charAt(0).toUpperCase() + part.slice(1) : '';
++ };
++ const next = mine && !quest.completed ? quest.steps.find(step => !step.done) : null;
const list = document.getElementById('home-daily-steps');
list.replaceChildren();
for (const step of quest.steps) {
const row = document.createElement('li');
++ const link = document.createElement('a');
++ link.href = /^https:\/\//.test(step.href || '') ? step.href : 'https://openvibe.quest/';
const icon = document.createElement('span');
icon.className = 'ov-icon home-daily-site-icon';
icon.dataset.icon = String(step.site || '').split('.')[1] || 'quest';
icon.dataset.size = '32';
icon.setAttribute('aria-hidden', 'true');
-- const label = document.createElement('span');
-- label.textContent = String(step.sentence || '');
-- row.append(icon, label);
++ const text = document.createElement('span');
++ text.className = 'home-daily-step';
++ const sentence = document.createElement('span');
++ sentence.textContent = String(step.sentence || '');
++ const where = document.createElement('small');
++ where.textContent = step === next ? 'Next · ' + siteName(step.site) : siteName(step.site);
++ text.append(sentence, where);
++ link.append(icon, text);
if (mine && step.done) {
row.classList.add('is-done');
const done = document.createElement('span');
done.className = 'home-daily-check';
done.textContent = '✓';
done.setAttribute('aria-label', 'Done');
-- row.append(done);
++ link.append(done);
++ } else {
++ const go = document.createElement('i');
++ go.className = 'fa-solid fa-chevron-right home-daily-go';
++ go.setAttribute('aria-hidden', 'true');
++ link.append(go);
}
++ if (step === next) row.classList.add('is-next');
++ row.append(link);
list.append(row);
}
-- const action = document.getElementById('home-daily-action');
-- const next = mine && quest.steps.find(step => !step.done);
-- action.textContent = mine ? next ? 'Do the next step' : 'See your streak' : "See today's quest";
-- action.href = next && /^https:\/\//.test(next.href || '') ? next.href : 'https://openvibe.quest/';
++ // The door to Quest: what else is there, in one line.
++ const more = data.more || {};
++ const own = more.mine || null;
++ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
++ const bits = [];
++ if (own) { // the streak is the chip in the head already
++ if (own.badges) bits.push(plural(own.badges, 'badge', 'badges'));
++ if (own.in_progress) bits.push(plural(own.in_progress, 'quest', 'quests') + ' going');
++ const left = (Number(more.quests) || 0) - (Number(own.completed) || 0) - (Number(own.in_progress) || 0);
++ if (left > 0) bits.push(`${left} more to try`);
++ }
++ if (!bits.length && more.quests) bits.push(`${more.quests} quests${more.sites ? ` on ${more.sites} sites` : ''}, each with a badge`);
++ if (!bits.length) bits.push('Every quest, your badges and your streak');
++ document.getElementById('home-daily-more-line').textContent = bits.join(' · ');
const toggle = document.getElementById('home-daily-toggle');
if (!toggle.dataset.wired) {
toggle.dataset.wired = '1';
ddiff --git a/server/home/daily-quest.js b/server/home/daily-quest.js
iindex 011884c0..78a073b1 100644
---- a/server/home/daily-quest.js
++++ b/server/home/daily-quest.js
@@@ -1,8 +1,13 @@
'use strict';
++// What OpenVibe.Quest holds beyond today's quest, for the card's door to it: the catalog's size (public, the same for
++// everyone, kept ten minutes) and, for a signed-in person, their badges and the quests they have started.
++const CATALOG_TTL_MS = 10 * 60 * 1000;
++let _catalog = null; // { at, base, value: { quests, sites } }
++
/** Quest calls carry only a Network bearer token. The browser never sends Quest a Live cookie. */
--async function readDailyQuest({ token = null, timeZone = 'UTC', fetchImpl = globalThis.fetch,
-- baseUrl = process.env.OV_QUEST_INTERNAL_URL || 'https://openvibe.quest' } = {}) {
++async function readDailyQuest({ token = null, timeZone = 'UTC', more = false, fetchImpl = globalThis.fetch,
++ baseUrl = process.env.OV_QUEST_INTERNAL_URL || 'https://openvibe.quest', now = Date.now } = {}) {
const base = String(baseUrl).replace(/\/+$/, '');
const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
const get = async (path, options = {}) => {
@@@ -11,18 +16,41 @@ async function readDailyQuest({ token = null, timeZone = 'UTC', fetchImpl = glob
if (!res.ok) return null;
return res.json();
};
++ const soft = (p) => p.catch(() => null); // the door's figures never cost the card itself
++ const catalog = async () => {
++ if (_catalog && _catalog.base === base && now() - _catalog.at < CATALOG_TTL_MS) return _catalog.value;
++ const all = await soft(get('/api/v1/quests'));
++ if (!all || !Array.isArray(all.quests)) return null;
++ const value = { quests: all.quests.length, sites: Array.isArray(all.sites) ? all.sites.length : 0 };
++ _catalog = { at: now(), base, value };
++ return value;
++ };
++ const mine = async () => {
++ const me = token ? await soft(get('/api/v1/me/quests')) : null;
++ if (!me || !Array.isArray(me.quests)) return null;
++ return {
++ badges: Array.isArray(me.badges) ? me.badges.length : 0,
++ in_progress: me.quests.filter((q) => q && q.state === 'in_progress').length,
++ completed: me.quests.filter((q) => q && q.state === 'completed').length,
++ };
++ };
++ const withMore = async (result) => {
++ if (!more) return result;
++ const [cat, me] = await Promise.all([catalog(), mine()]);
++ return { ...result, more: { ...(cat || {}), ...(me ? { mine: me } : {}) } };
++ };
try {
if (!token) {
-- const quest = await get('/api/v1/daily');
-- return quest && { signed_in: false, quest };
++ const [quest, extra] = await Promise.all([get('/api/v1/daily'), withMore({})]);
++ return quest && { signed_in: false, quest, ...extra };
}
if (timeZone && /^[A-Za-z0-9_+\/-]{1,64}$/.test(timeZone)) {
await get('/api/v1/me/daily/time-zone', { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time_zone: timeZone }) });
}
-- const quest = await get('/api/v1/me/daily');
++ const [quest, extra] = await Promise.all([get('/api/v1/me/daily'), withMore({})]);
if (!quest) return null;
-- return { signed_in: true, quest };
++ return { signed_in: true, quest, ...extra };
} catch { return null; }
}
--module.exports = { readDailyQuest };
++module.exports = { readDailyQuest, _resetForTests: () => { _catalog = null; } };
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 0a5fe037..aa429262 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -545,7 +545,7 @@ async function acrossOpenVibe(sinceMs) {
router.get('/daily-quest', optionalAuth, async (req, res) => {
const token = req.user ? require('../net/subject-assertion').personToken(req.headers) : null;
-- const data = await require('./daily-quest').readDailyQuest({ token, timeZone: String(req.query.time_zone || 'UTC') });
++ const data = await require('./daily-quest').readDailyQuest({ token, timeZone: String(req.query.time_zone || 'UTC'), more: true });
res.set('Cache-Control', 'no-store');
if (!data) return res.status(204).end();
return res.json(data);
ddiff --git a/test/home-daily.test.js b/test/home-daily.test.js
iindex daee0e50..8e58852f 100644
---- a/test/home-daily.test.js
++++ b/test/home-daily.test.js
@@@ -25,6 +25,28 @@ const { summarizeHistory } = require('../server/home/recent-facts');
assert.ok(calls.slice(1).every(c => c.options.headers.Authorization === 'Bearer person-token' && !('Cookie' in c.options.headers)));
assert.equal(await readDailyQuest({ fetchImpl: async () => { throw Error('down'); } }), null);
++ // The card's door to Quest: the catalog's size for everyone (kept ten minutes), a person's badges and started quests.
++ let catalogReads = 0;
++ const moreFetch = async (url) => {
++ if (url.endsWith('/api/v1/quests')) { catalogReads++; return { ok: true, json: async () => ({ sites: [{}, {}], quests: [{}, {}, {}] }) }; }
++ if (url.endsWith('/api/v1/me/quests')) return { ok: true, json: async () => ({
++ quests: [{ state: 'in_progress' }, { state: 'completed' }, { state: 'not_started' }, { state: 'in_progress' }], badges: [{}, {}],
++ }) };
++ if (url.endsWith('/api/v1/me/daily')) return { ok: true, json: async () => ({ title: 'Across OpenVibe', steps: [{}, {}, {}], streak: 4 }) };
++ if (url.endsWith('/api/v1/daily')) return { ok: true, json: async () => ({ title: 'Across OpenVibe', steps: [{}, {}, {}] }) };
++ return { ok: true, json: async () => ({}) };
++ };
++ const out = await readDailyQuest({ more: true, fetchImpl: moreFetch, baseUrl: 'https://quest.more' });
++ assert.deepEqual(out.more, { quests: 3, sites: 2 });
++ const me = await readDailyQuest({ token: 't', more: true, fetchImpl: moreFetch, baseUrl: 'https://quest.more' });
++ assert.deepEqual(me.more, { quests: 3, sites: 2, mine: { badges: 2, in_progress: 2, completed: 1 } });
++ assert.equal(catalogReads, 1, 'the catalog is read once and kept');
++ // A Quest that cannot answer the door's figures still gives the card.
++ const bare = await readDailyQuest({ more: true, baseUrl: 'https://quest.bare', fetchImpl: async (url) => (url.endsWith('/api/v1/daily')
++ ? { ok: true, json: async () => ({ title: 'Across OpenVibe', steps: [{}, {}, {}] }) } : { ok: false }) });
++ assert.equal(bare.quest.title, 'Across OpenVibe');
++ assert.deepEqual(bare.more, {});
++
const now = Date.parse('2026-11-08T22:00:00Z');
const rows = [
{ id: 1, started_at: '2026-11-08T18:00:00Z', ended_at: '2026-11-08T20:00:00Z', duration_seconds: 7200, title: 'Today' },