openvibers/openvibe.live Commits
Today's quest leads to OpenVibe.Quest: where you are and what waits there (#786)
Changes
@@@ -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. */@@@ -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>@@@ -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';@@@ -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 = globif (!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; } };@@@ -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);@@@ -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' },
@@ -26,27 +26,39 @@
@@ -26,27 +26,39 @@
color: var(--accent-light);
color: var(--accent-light);
}
}
.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.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-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-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="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-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-chev { color: var(--text-muted); transition: transform .2s; }
.home-daily-quest.open .home-daily-chev { transform: rotate(180deg); }
.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-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) {
@media (max-width: 599px) {
/* The pulse (goals, the latest activity, the week's leaders) is one row that scrolls sideways, not five stacked cards. */
/* The pulse (goals, the latest activity, the week's leaders) is one row that scrolls sideways, not five stacked cards. */
@@ -124,12 +124,16 @@
@@ -124,12 +124,16 @@
<button class="home-daily-head" type="button" id="home-daily-toggle" aria-expanded="false" aria-controls="home-daily-body">
<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-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-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>
<i class="fa-solid fa-chevron-down home-daily-chev" aria-hidden="true"></i>
</button>
</button>
<div class="home-daily-body" id="home-daily-body" hidden>
<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>
<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>
</div>
</div>
@@ -1021,38 +1021,80 @@ async function loadDailyQuest() {
@@ -1021,38 +1021,80 @@ async function loadDailyQuest() {
if (!quest || !Array.isArray(quest.steps) || quest.steps.length !== 3) { card.hidden = true; return; }
if (!quest || !Array.isArray(quest.steps) || quest.steps.length !== 3) { card.hidden = true; return; }
const mine = !!data.signed_in;
const mine = !!data.signed_in;
const progress = Math.max(0, Math.min(3, Number(quest.progress) || 0));
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
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');
const list = document.getElementById('home-daily-steps');
list.replaceChildren();
list.replaceChildren();
for (const step of quest.steps) {
for (const step of quest.steps) {
const row = document.createElement('li');
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');
const icon = document.createElement('span');
icon.className = 'ov-icon home-daily-site-icon';
icon.className = 'ov-icon home-daily-site-icon';
icon.dataset.icon = String(step.site || '').split('.')[1] || 'quest';
icon.dataset.icon = String(step.site || '').split('.')[1] || 'quest';
icon.dataset.size = '32';
icon.dataset.size = '32';
icon.setAttribute('aria-hidden', 'true');
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) {
if (mine && step.done) {
row.classList.add('is-done');
row.classList.add('is-done');
const done = document.createElement('span');
const done = document.createElement('span');
done.className = 'home-daily-check';
done.className = 'home-daily-check';
done.textContent = '✓';
done.textContent = '✓';
done.setAttribute('aria-label', 'Done');
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);
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');
const toggle = document.getElementById('home-daily-toggle');
if (!toggle.dataset.wired) {
if (!toggle.dataset.wired) {
toggle.dataset.wired = '1';
toggle.dataset.wired = '1';
@@ -1,8 +1,13 @@
@@ -1,8 +1,13 @@
'use strict';
'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. */
/** 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 base = String(baseUrl).replace(/\/+$/, '');
const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
const get = async (path, options = {}) => {
const get = async (path, options = {}) => {
@@ -11,18 +16,41 @@ async function readDailyQuest({ token = null, timeZone = 'UTC', fetchImpl = glob
@@ -11,18 +16,41 @@ async function readDailyQuest({ token = null, timeZone = 'UTC', fetchImpl = glob
if (!res.ok) return null;
if (!res.ok) return null;
return res.json();
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 {
try {
if (!token) {
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)) {
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' },
await get('/api/v1/me/daily/time-zone', { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time_zone: timeZone }) });
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;
if (!quest) return null;
- return { signed_in: true, quest };
+ return { signed_in: true, quest, ...extra };
} catch { return null; }
} catch { return null; }
}
}
-module.exports = { readDailyQuest };
+module.exports = { readDailyQuest, _resetForTests: () => { _catalog = null; } };
@@ -545,7 +545,7 @@ async function acrossOpenVibe(sinceMs) {
@@ -545,7 +545,7 @@ async function acrossOpenVibe(sinceMs) {
router.get('/daily-quest', optionalAuth, async (req, res) => {
router.get('/daily-quest', optionalAuth, async (req, res) => {
const token = req.user ? require('../net/subject-assertion').personToken(req.headers) : null;
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');
res.set('Cache-Control', 'no-store');
if (!data) return res.status(204).end();
if (!data) return res.status(204).end();
return res.json(data);
return res.json(data);
@@ -25,6 +25,28 @@ const { summarizeHistory } = require('../server/home/recent-facts');
@@ -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.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);
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 now = Date.parse('2026-11-08T22:00:00Z');
const rows = [
const rows = [
{ id: 1, started_at: '2026-11-08T18:00:00Z', ended_at: '2026-11-08T20:00:00Z', duration_seconds: 7200, title: 'Today' },
{ id: 1, started_at: '2026-11-08T18:00:00Z', ended_at: '2026-11-08T20:00:00Z', duration_seconds: 7200, title: 'Today' },