openvibers/openvibe.quest Commits

The OpenVibe.Quest home shows the network's quest activity: figures under the hero, the badges people earn most, how many hold each quest's badge, the badges nobody has yet, the sites Quest follows and a FAQ (counts only, never people; home budget raised to 60 KB for it) (#71)

OP OpenVibers committed 22 days ago · 8e5443d542 · 7 files, +190 −30

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex fa1fd7e..530464d 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -87,3 +87,35 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
.badges li { border: 1px solid var(--border-light, #2c3d5c); border-radius: 999px; padding: .2rem .8rem; }
.badges small { color: var(--text-muted, #7386a3); }
.quest-block { margin: .8rem 0; }
++/* The home's live parts (server/http/pages.js): figures, the most-earned badges, open badges, sites followed, questions. */
++.q-stats { list-style: none; margin: 4px 0 8px; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr)); }
++.q-stats li { display: flex; flex-direction: column; gap: 2px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.q-stats b { font-size: 1.6rem; font-weight: 800; line-height: 1.1; }
++.q-stats span { font-size: .86rem; color: var(--text-secondary, #96a7c2); }
++.quest-count { margin: 6px 0 0; font-size: .84rem; font-weight: 600; color: var(--success, #34d399); }
++.q-top { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); }
++.q-top li { display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.q-top-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; overflow-wrap: anywhere; }
++.q-top-main span { font-size: .88rem; color: var(--text-secondary, #96a7c2); }
++.q-top-n { font-weight: 800; white-space: nowrap; }
++@media (max-width: 520px) {
++ .q-top li { grid-template-columns: 44px minmax(0, 1fr); }
++ .q-top .medal { width: 44px; height: 44px; font-size: .85rem; }
++ .q-top-n { grid-column: 2; font-size: .9rem; }
++ .q-stats b { font-size: 1.3rem; }
++}
++.q-open { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); }
++.q-open li { display: grid; grid-template-columns: 44px minmax(0, 1fr); align-items: center; gap: 12px; padding: 10px 12px; border-radius: 14px; border: 1px dashed var(--border, #1f2d47); }
++.q-open .medal { width: 44px; height: 44px; font-size: .85rem; }
++.q-open span { display: flex; flex-direction: column; gap: 2px; min-width: 0; overflow-wrap: anywhere; }
++.q-open small { color: var(--text-muted, #7386a3); }
++.q-sites { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); }
++.q-sites li { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); min-width: 0; }
++.q-sites a { font-weight: 700; }
++.q-sites small { color: var(--text-muted, #7386a3); }
++.q-sites span { display: flex; flex-wrap: wrap; gap: 4px; }
++.q-sites code { font-size: .78rem; overflow-wrap: anywhere; }
++.q-faq { display: flex; flex-direction: column; gap: 10px; max-width: 820px; }
++.q-faq details { border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); padding: 0 16px; }
++.q-faq summary { cursor: pointer; padding: 14px 0; font-weight: 700; }
++.q-faq details p { margin: 0 0 14px; color: var(--text-secondary, #96a7c2); line-height: 1.6; }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex fa95314..d65eab2 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -13,6 +13,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
++const seo = require('openvibe-shared/seo');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { html, raw, table } = require('../render/html');
@@@ -60,49 +61,135 @@ function createPageRoutes(ctx) {
{ empty: 'No events.' });
// ── Home ─────────────────────────────────────────────────
++ // The home's figures (store.totals, store.completionsByQuest): counts only, read at most once a minute.
++ let pulseMemo = null;
++ const pulse = async () => {
++ const now = s.now();
++ if (pulseMemo && now - pulseMemo.at < 60_000) return pulseMemo.value;
++ const since = new Date(now - 7 * store.DAY_MS).toISOString();
++ const [totals, byQuest] = await Promise.all([store.totals(s.db, since), store.completionsByQuest(s.db)]);
++ const value = { ...totals, byQuest: new Map(byQuest.map((r) => [r.quest_id, Number(r.n)])) };
++ pulseMemo = { at: now, value };
++ return value;
++ };
++ const siteCount = Object.keys(catalog.SITES).length;
++ const countText = (n) => n.toLocaleString('en-US');
++
++ /** The figures under the hero: each one only when it has something to say. */
++ const statsStrip = (p) => {
++ const items = [
++ [catalog.all().length, `quests on ${siteCount} sites`],
++ p.completions > 0 && [p.completions, p.completions === 1 ? 'quest completed' : 'quests completed'],
++ p.people > 0 && [p.people, p.people === 1 ? 'person has earned badges' : 'people have earned badges'],
++ p.recent > 0 && [p.recent, 'completed in the last 7 days'],
++ coinsUi.coinsEnabled && p.coins > 0 && [p.coins, 'OpenCoins credited'],
++ ].filter(Boolean);
++ return html`<ul class="q-stats" aria-label="${SITE_NAME} in numbers">${items.map(([n, label]) => html`<li><b>${countText(n)}</b><span>${label}</span></li>`)}</ul>`;
++ };
++
++ /** The badges people hold most (all time), with the quest that pays each. */
++ const topBadges = (p) => {
++ const top = catalog.all().map((q) => ({ q, n: p.byQuest.get(q.id) || 0 })).filter((x) => x.n > 0)
++ .sort((a, b) => b.n - a.n || a.q.title.localeCompare(b.q.title)).slice(0, 6);
++ if (!top.length) return '';
++ return html`<section class="sc-sec" id="most-earned" aria-labelledby="most-earned-h"><h2 id="most-earned-h">Badges people earn most</h2>
++<p class="sc-lede">How many people hold each badge. Nobody is named: Quest shows counts, never who.</p>
++<ol class="q-top">${top.map(({ q, n }) => html`<li>${quests.medal(q, { earned: true })}<span class="q-top-main"><strong>${q.reward.badge.name}</strong><span>for <a href="/quests/${q.id}">${q.title}</a> on ${siteName(q.site)}</span></span><span class="q-top-n">${countText(n)} ${n === 1 ? 'person' : 'people'}</span></li>`)}</ol></section>`;
++ };
++
++ /** Quests nobody has finished yet (outside a series): the badge is there for the first person. */
++ const firstToFinish = (p) => {
++ const open = catalog.all().filter((q) => !q.series && !(p.byQuest.get(q.id) > 0)).slice(0, 6);
++ if (!open.length) return '';
++ return html`<section class="sc-sec" id="be-first" aria-labelledby="be-first-h"><h2 id="be-first-h">Nobody has these badges yet</h2>
++<p class="sc-lede">Quests no one has finished so far. Be the first to hold the badge.</p>
++<ul class="q-open">${open.map((q) => html`<li>${quests.medal(q)}<span><a href="/quests/${q.id}">${q.title}</a><small>${siteName(q.site)} · Badge: ${q.reward.badge.name}</small></span></li>`)}</ul></section>`;
++ };
++
++ /** Which sites Quest follows and the events each reports: the whole of what moves a quest. */
++ const sitesFollowed = () => {
++ const bySite = new Map();
++ for (const e of catalog.EVENTS) {
++ if (e.counted === false) continue;
++ if (!bySite.has(e.site)) bySite.set(e.site, []);
++ bySite.get(e.site).push(e.event_type);
++ }
++ return html`<ul class="q-sites">${[...bySite].map(([site, events]) => html`<li><a href="https://${site}">${siteName(site)}</a><small>${(catalog.SITES[site] || {}).what || ''}</small><span>${events.map((ev, i) => html`${i ? ' ' : ''}<code>${ev}</code>`)}</span></li>`)}</ul>`;
++ };
++
++ const FAQ = [
++ { q: 'What is OpenVibe.Quest?', a: `One quest log for the OpenVibe sites. It counts what you really do on ${siteCount} of them, like going live, posting in the forum or publishing a world, and pays a badge for each quest you finish.` },
++ { q: 'How do I start?', a: 'Use the OpenVibe sites while signed in to your OpenVibe account: what you do there counts, with nothing to join first. Sign in here to see your quest log and your badges.' },
++ { q: 'What are OpenCoins?', a: 'Loyalty points credited to your OpenVibe.Network wallet when a quest pays them, within a daily cap. They are never money and never Vibes.' },
++ { q: 'What does Quest track about me?', a: 'Only the events listed under How it works, one per real action. No page views, no clicks, no watch time and nothing you wrote. Private chat rooms and direct messages never reach Quest.' },
++ { q: 'Can other people see my progress?', a: 'No. Your log is yours alone. The only public part is your badge list: each badge\'s name and the date you earned it.' },
++ { q: 'Can developers add quests?', a: 'Yes. A developer app defines quests on its own events with an app token through the OpenVibe SDK. They pay a badge named for the app, never OpenCoins.' },
++ ];
++
r.get('/', async (req, res) => {
-- const appList = ctx.appQuests ? await ctx.appQuests.listed(24) : [];
++ const [appList, p] = await Promise.all([ctx.appQuests ? ctx.appQuests.listed(24) : [], pulse()]);
++ const site = String(config.baseUrl).replace(/\/+$/, '');
const hero = showcase.hero({
-- eyebrow: `${SITE_NAME} · ${TAGLINE}`,
++ eyebrow: SITE_NAME,
title: 'Quests across OpenVibe.',
accent: 'Badges for what you really do.',
-- lede: `${SITE_NAME} is the network's shared quest log: one place that knows what you have really done on the OpenVibe sites it follows, and rewards it with badges${coinsUi.coinsEnabled ? ' and OpenCoins' : ' (and OpenCoins, once they are switched on)'}. Quest runs no activities of its own — it listens to what the sites already report, and nothing else.`,
++ lede: `Go live, start a thread, publish a world, share a picture: ${SITE_NAME} counts what you really do on ${siteCount} OpenVibe sites and rewards it with badges${coinsUi.coinsEnabled ? ' and OpenCoins' : ' (and OpenCoins, once they are switched on)'}. One quest log for the whole network, with nothing new to install or learn.`,
actions: signedIn(req)
-- ? [{ label: 'Your quest log', href: '/me', primary: true }, { label: 'How it works', href: '/how-it-works' }]
-- : [{ label: 'Sign in to start', href: '/auth/login?next=%2Fme', primary: true }, { label: 'How it works', href: '/how-it-works' }],
-- note: 'Open source (AGPL-3.0). Rewards are badges and OpenCoins — loyalty points, never money, and never Vibes.',
++ ? [{ label: 'Your quest log', href: '/me', primary: true }, { label: 'See the quests', href: '#the-quests' }]
++ : [{ label: 'Sign in to start', href: '/auth/login?next=%2Fme', primary: true }, { label: 'See the quests', href: '#the-quests' }],
++ note: 'Open source (AGPL-3.0). Rewards are badges, items and OpenCoins: loyalty points, never money and never Vibes.',
});
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
-- description: 'One quest log across the OpenVibe sites: badges for what you really do on Live, Chat, Community, Games and more, built on real activity.',
-- jsonLd: homeJsonLd(config),
++ title: 'Earn badges for what you do on OpenVibe',
++ description: `One quest log across ${siteCount} OpenVibe sites: finish quests by streaming, posting, chatting and playing, and earn badges, items and OpenCoins.`,
++ jsonLd: [
++ ...homeJsonLd(config),
++ seo.jsonLd.itemList('OpenVibe quests', [
++ ...catalog.all().filter((q) => !q.series).map((q) => ({ name: q.title, url: `${site}/quests/${q.id}` })),
++ ...Object.values(catalog.SERIES).map((x) => ({ name: x.title, url: `${site}/series/${x.id}` })),
++ ], { url: `${site}/` }),
++ seo.jsonLd.faq(FAQ),
++ ],
styles: [showcase.STYLESHEET],
body: html`${raw(hero)}
++${statsStrip(p)}
++${topBadges(p)}
${raw(showcase.features({
title: 'What Quest is',
-- lede: 'One quest log across eight OpenVibe sites (Live, the Network, Community, Chat, the Wiki, Games, Tools and Space), built on what the sites already tell the network.',
++ lede: `One quest log across ${siteCount} OpenVibe sites, built on what the sites already tell the network.`,
items: [
{ icon: 'ov:page', title: 'One log, many sites', text: 'A stream on OpenVibe.Live, a thread on OpenVibe.Community, a message in OpenVibe.Chat: the same log, grouped by the site the event came from.' },
{ icon: 'ov:account', title: 'Rewards are badges', text: coinsUi.coinsEnabled ? 'A quest pays a badge that shows on your profile across the network, and OpenCoins into your OpenVibe.Network wallet.' : 'A quest pays a badge that shows on your profile across the network and, once they are switched on, OpenCoins.' },
{ icon: 'ov:tools', title: 'OpenCoins, not Vibes', text: 'OpenCoins are loyalty points credited through OpenVibe.Network\'s wallet. They are never money, they are not Vibes, and Vibes are never touched here.' },
-- { icon: 'ov:games', title: 'Made by apps', text: 'A developer app defines quests on its own events with an app token (the SDK\'s quests): they pay a badge named for the app, never OpenCoins. Quests a streamer writes come later.' },
++ { icon: 'ov:games', title: 'Made by apps', text: 'A developer app defines quests on its own events with an app token (the SDK\'s quests): they pay a badge named for the app, never OpenCoins.' },
],
}))}
<section class="sc-sec" id="the-quests" aria-labelledby="the-quests-h"><h2 id="the-quests-h">The quests</h2>
--<p class="sc-lede">Every quest below counts something a person really did on an OpenVibe site. Grouped by the site whose events it counts.</p>
--${quests.list(catalog.grouped({ fold: true }), coinsUi)}
++<p class="sc-lede">Every quest below counts something a person really did on an OpenVibe site, grouped by the site whose events it counts.</p>
++${quests.list(catalog.grouped({ fold: true }), { ...coinsUi, counts: p.byQuest })}
${quests.coinsNote(coinsUi)}</section>
++${firstToFinish(p)}
${appList.length ? html`<section class="sc-sec" id="app-quests" aria-labelledby="app-quests-h"><h2 id="app-quests-h">Made by apps</h2>
<p class="sc-lede">Quests that developer apps on OpenVibe define on their own events. Each pays a badge named for the app, never OpenCoins.</p>
<ul class="quest-list">${appList.map((q) => html`<li class="quest-card"><h3><a href="/apps/quests/${q.id}">${q.title}</a></h3>${q.why ? html`<p>${q.why}</p>` : ''}<p class="muted">Badge: ${q.reward.badge.name}${q.url ? html` · <a href="${q.url}" rel="nofollow noopener">Where to do it</a>` : ''}</p></li>`)}</ul></section>` : ''}
--<section class="sc-sec" id="how-progress-works" aria-labelledby="how-progress-works-h"><h2 id="how-progress-works-h">How progress works</h2>
--<p class="sc-lede">Quest does not watch you and does not ask the sites who you are. The network's event backbone,
--OpenVibe.Events, delivers the domain events a site already emits, and Quest counts the ones below — one per real
--action, with the person taken from the field the event's own schema defines. Nothing else is tracked: no page
--views, no clicks, no watch time, and no content you wrote.</p>
--${eventsTable()}
--<p class="sc-note">What each one means is on <a href="/how-it-works">How it works</a>. That list is the whole of it. A quest completes when every one of its steps reaches its count;
--the completion, its badge and its OpenCoins are recorded once. <a href="/how-it-works">The rules, in full</a>.</p></section>
++${raw(showcase.steps({
++ id: 'how-progress-works',
++ title: 'How quests work across sites',
++ lede: 'Quest does not watch you and runs no activities of its own. It counts what the sites already report.',
++ items: [
++ { title: 'You do something on a site', text: 'Go live on OpenVibe.Live, reply in a forum thread, publish a creator world on OpenVibe.Games.' },
++ { title: 'The site reports it', text: 'The site sends the event it already emits to OpenVibe.Events, with the person named by the event\'s own schema.' },
++ { title: 'Quest counts it', text: 'One count per real action. When every step of a quest reaches its count, the quest is complete, once.' },
++ { title: 'You get the reward', text: 'The badge goes on your profile, an item goes to your inventory where the quest gives one, and OpenCoins go to your wallet.' },
++ ],
++ }))}
++<section class="sc-sec" aria-labelledby="sites-followed-h"><h2 id="sites-followed-h">The sites Quest follows</h2>
++<p class="sc-lede">These events are the whole of what moves a quest. Nothing else is tracked: no page views, no clicks, no watch time and no content you wrote.</p>
++${sitesFollowed()}
++<p class="sc-note">What each event means is on <a href="/how-it-works">How it works</a>, with what is stored and what is public.</p></section>
++<section class="sc-sec" id="questions" aria-labelledby="questions-h"><h2 id="questions-h">Questions</h2>
++<div class="q-faq">${FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</div></section>
${raw(showcase.cta({
title: signedIn(req) ? 'Your quest log is waiting' : 'Start your quest log',
text: signedIn(req) ? 'Your progress, your badges, and what each quest still needs.' : 'Sign in with OpenVibe and the sites you already use start counting.',
ddiff --git a/server/quests/store.js b/server/quests/store.js
iindex 980a187..7cd06fc 100644
---- a/server/quests/store.js
++++ b/server/quests/store.js
@@@ -117,13 +117,35 @@ const owedItems = (db, questIds, limit = 50) => (questIds.length ? db.many(
ORDER BY completed_at, id LIMIT $2`,
[questIds, limit]) : Promise.resolve([]));
++/**
++ * The home page's figures, counts only: completions, the people with at least one, the OpenCoins that landed, and
++ * completions since `since` (an ISO time). Nothing here can name or single out a person.
++ */
++async function totals(db, since) {
++ const row = await db.maybe(
++ `SELECT count(*)::int AS completions, count(DISTINCT subject)::int AS people,
++ COALESCE(sum(coins) FILTER (WHERE coins_state = 'credited'), 0)::int AS coins,
++ count(*) FILTER (WHERE completed_at >= $1::text)::int AS recent
++ FROM quest_completions`,
++ [since]);
++ return {
++ completions: Number(row && row.completions) || 0,
++ people: Number(row && row.people) || 0,
++ coins: Number(row && row.coins) || 0,
++ recent: Number(row && row.recent) || 0,
++ };
++}
++
++/** Completions per quest (each is that quest's badge, awarded once): [{ quest_id, n }], counts and never people. */
++const completionsByQuest = (db) => db.many('SELECT quest_id, count(*)::int AS n FROM quest_completions GROUP BY quest_id');
++
/** How many people have a badge — the count a quest page can show without naming anyone. */
const badgeHolders = async (db, badgeId) => Number(await db.value('SELECT count(*) FROM quest_badges WHERE badge_id = $1', [badgeId]));
module.exports = {
DAY_MS, dayOf,
bumpProgress, progressOf, progressFor,
-- completion, completionsFor, completionCount, complete,
++ completion, completionsFor, completionCount, complete, totals, completionsByQuest,
awardBadge, badgesFor, badgesOf, badgeHolders,
setCoins, owedCoins, creditedTo, creditedAll,
setItem, owedItems,
ddiff --git a/server/render/quests.js b/server/render/quests.js
iindex ed73cc7..b0f9928 100644
---- a/server/render/quests.js
++++ b/server/render/quests.js
@@@ -37,8 +37,13 @@ function medal(quest, { earned = false } = {}) {
return html`<span class="medal${earned ? ' earned' : ''}" style="--h:${hueOf(quest.id)}" aria-hidden="true">${initials}</span>`;
}
++/** "Earned by 3 people": how many have a quest's badge (a count, never who), or nothing at 0. */
++function earnedBy(n) {
++ return n > 0 ? html`<p class="quest-count">Earned by ${n} ${n === 1 ? 'person' : 'people'}</p>` : '';
++}
++
/** One quest in a list: the badge it pays, what it is, why, the steps as a checklist, and the reward. */
--function card(quest, { coinsEnabled = false, earned = false } = {}) {
++function card(quest, { coinsEnabled = false, earned = false, counts = null } = {}) {
return html`<li class="quest">
${medal(quest, { earned })}
<div class="quest-main">
@@@ -46,32 +51,35 @@ function card(quest, { coinsEnabled = false, earned = false } = {}) {
<p class="quest-why">${quest.why}</p>
<ul class="quest-steps">${quest.steps.map((s) => html`<li>${s.label}${s.count > 1 ? html` <span class="step-count">×${s.count}</span>` : ''}</li>`)}</ul>
<p class="quest-reward">${reward(quest, { coinsEnabled })}</p>
++ ${counts ? earnedBy(counts.get(quest.id) || 0) : ''}
</div>
</li>`;
}
/** A series in a list (the home page): one card for all its quests, opening the series' page. */
--function seriesCard(series, { coinsEnabled = false } = {}) {
++function seriesCard(series, { coinsEnabled = false, counts = null } = {}) {
const coins = series.quests.reduce((n, q) => n + q.reward.coins, 0);
const n = series.quests.length;
++ const done = counts ? series.quests.reduce((sum, q) => sum + (counts.get(q.id) || 0), 0) : 0;
return html`<li class="quest quest-series">
<span class="medal" style="--h:${hueOf(series.id)}" aria-hidden="true">${n}</span>
<div class="quest-main">
<a class="quest-title" href="/series/${series.id}">${series.title}</a>
<p class="quest-why">${series.why}</p>
<p class="quest-reward"><span class="reward"><span class="reward-badge"><strong>${n}</strong> quests, a badge each</span>${coins > 0 ? (coinsEnabled ? html`<span class="reward-coins"><strong>${coins}</strong> OpenCoins in all</span>` : html`<span class="reward-coins soon">OpenCoins rewards start soon</span>`) : ''}</span></p>
++ ${done > 0 ? html`<p class="quest-count">${done} ${done === 1 ? 'badge' : 'badges'} earned in this series</p>` : ''}
</div>
</li>`;
}
/** The catalog grouped by the site whose events each quest counts (a folded catalog shows each series as one card). */
--function list(groups = catalog.grouped(), { coinsEnabled = false } = {}) {
++function list(groups = catalog.grouped(), { coinsEnabled = false, counts = null } = {}) {
// The heading id is the domain with its dots replaced: an id with a dot is legal but awkward to target.
const anchor = (site) => `site-${String(site).replace(/[^a-z0-9-]/gi, '-')}`;
const count = (g) => g.quests.length + (g.series || []).reduce((n, x) => n + x.quests.length, 0);
return html`${groups.map((g) => html`<section class="site-group" aria-labelledby="${anchor(g.site)}">
<h3 id="${anchor(g.site)}"><a href="https://${g.site}">${String(g.name).replace(/^OpenVibe\./, '')}</a><span class="site-count">${count(g)} quest${count(g) === 1 ? '' : 's'}</span>${g.what ? html` <small>${g.what}</small>` : ''}</h3>
-- <ul class="quest-list">${g.quests.map((q) => card(q, { coinsEnabled }))}${(g.series || []).map((x) => seriesCard(x, { coinsEnabled }))}</ul>
++ <ul class="quest-list">${g.quests.map((q) => card(q, { coinsEnabled, counts }))}${(g.series || []).map((x) => seriesCard(x, { coinsEnabled, counts }))}</ul>
</section>`)}`;
}
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex 00c9887..da272a5 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -40,11 +40,11 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200);
const head = r.text.slice(0, r.text.indexOf('</head>'));
assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
-- assert.ok(head.includes('<title>OpenVibe.Quest: Quests across the whole network.</title>'), 'home title');
++ assert.ok(head.includes('<title>Earn badges for what you do on OpenVibe · OpenVibe.Quest</title>'), 'home title: what the site does');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.quest/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'ItemList', 'FAQPage'], 'the home JSON-LD: the site, the quests and the questions it answers');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="quest"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
}
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 10288bc..792fd8a 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -18,13 +18,16 @@ const BUDGETS = {
// site, each with its reward) and the list of the events Quest counts. Measured 34.0 KB raw / 7.7 KB brotli.
// Raised 2026-10-12 with openvibe-shared 4.1.3: the head reserves the navbar's 52px (120 bytes). Measured 37.6 KB raw.
// Raised 2026-10-15: five more quests (Blog, Deals, Run, Inventory) and their events on the home page. Measured 43 KB raw.
-- htmlRawKB: 47, // measured 43 (the home page: hero, features, the quests, the events, cta, JSON-LD)
-- htmlBrotliKB: 9.8, // 9
++ // Raised 2026-10-22 with the rich home: figures under the hero, the most-earned badges, each card's "earned by",
++ // the badges nobody holds yet, the sites Quest follows, a FAQ and the quests as ItemList + FAQPage JSON-LD.
++ // Measured 54.3 KB raw / 10.6 KB brotli on a fresh database.
++ htmlRawKB: 60, // measured 54.3 (the home page: hero, figures, badges, features, the quests, sites, FAQ, cta, JSON-LD)
++ htmlBrotliKB: 11.7, // 10.6
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 21.0, // 19.1 with the quest medals and cards
++ cssRawKB: 25.0, // 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)
cssBrotliKB: 5.1, // 4.6
externalFiles: 0, // 0
};
ddiff --git a/test/quests-api.test.js b/test/quests-api.test.js
iindex 53c9f47..45d89ba 100644
---- a/test/quests-api.test.js
++++ b/test/quests-api.test.js
@@@ -130,6 +130,14 @@ const { render } = require('../server/render/html');
assert.ok(home.text.includes('OpenVibe.Live'), 'the quests are grouped by site');
assert.ok(home.text.includes('Say hello in chat'), 'a chat quest is listed');
assert.ok(home.text.includes('chat.room.message.created'), 'the events are listed');
++ // The live parts are counts, never people: kim's completion shows as a number and nothing about her.
++ assert.ok(home.text.includes('Badges people earn most'), 'the most-earned badges');
++ assert.ok(home.text.includes('Earned by 1 person'), 'a quest card says how many hold its badge');
++ assert.ok(home.text.includes('<b>1</b><span>quest completed</span>'), 'the figures under the hero');
++ assert.ok(home.text.includes('Nobody has these badges yet'), 'the quests nobody has finished');
++ assert.ok(home.text.includes('"@type":"FAQPage"') && home.text.includes('Can other people see my progress?'), 'the FAQ and its JSON-LD');
++ assert.ok(home.text.includes('"@type":"ItemList"'), 'the quests as an ItemList');
++ assert.ok(!home.text.includes(kim.subject) && !home.text.includes(kim.username), 'nobody is named');
const how = await t.get('/how-it-works');
assert.ok(how.text.includes('never money'), 'the rules say what rewards are');
assert.ok(how.text.includes('Vibes'), 'and that they are never Vibes');