openvibers/openvibe.quest Commits

Quest cards: state, progress with units, reward amounts, terms (#169)

OP OpenVibers committed 11 days ago · 9e43b67764 · 7 files, +352 −52

* Quest cards: state, progress with units, reward amounts, terms

A quest card now says Active, Completed or Locked (a retired app quest) in text
as well as colour, shows the viewer's fraction with its unit and an accessible
progress bar, keeps the reward's amount even while OpenCoins are off, and repeats
the date and badge on a completed card. A quest page states its terms, and the
completed notice names the badge earned.

* Budget: 2026-11-01: market outcomes

* Tests: follow the new progress markup

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex 9ef9354..a6d9776 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -67,7 +67,7 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
.quest-title:hover { color: var(--accent-light, #60a5fa); text-decoration: underline; text-underline-offset: 3px; }
.quest-why { margin: 4px 0 8px; color: var(--text-secondary, #96a7c2); font-size: .92rem; line-height: 1.45; }
.quest-steps { list-style: none; margin: 0 0 10px; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; font-size: .9rem; }
--.quest-steps li { position: relative; padding-left: 22px; color: var(--text-primary, #e6edf7); }
++.quest-steps li { position: relative; padding-left: 22px; color: var(--text-primary, #e6edf7); display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; min-width: 0; }
.quest-steps li::before { content: ''; position: absolute; left: 0; top: .28em; width: 13px; height: 13px; border-radius: 4px; border: 1.5px solid var(--border-light, #2c3d5c); }
.step-count { font-size: .78rem; font-weight: 700; color: var(--text-muted, #7386a3); }
.quest-reward { margin: 0; }
@@@ -78,6 +78,21 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
.reward-item { font-size: .78rem; padding: 3px 10px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
.reward-item small { color: var(--text-secondary, #96a7c2); } /* muted failed AA on the tinted chip */
.reward-coins.soon { color: var(--text-muted, #7386a3); border-style: dashed; border-color: var(--border-light, #2c3d5c); }
++.reward-badge small { color: var(--text-muted, #7386a3); }
++/* The state in text as well as colour (server/render/quests.js stateChip): Active, or Completed. */
++.quest-state { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); vertical-align: 1px; }
++.quest-state--active { border-color: var(--info, #38bdf8); color: var(--info-text, var(--info, #38bdf8)); }
++.quest-state--completed { border-color: var(--success, #22c55e); color: var(--success-text, var(--success, #22c55e)); }
++.quest-state--locked { border-color: var(--border-light, #2c3d5c); color: var(--text-muted, #7386a3); }
++.quest.is-completed { opacity: .78; }
++.quest-earned { margin: 8px 0 0; font-size: .84rem; color: var(--text-secondary, #96a7c2); }
++.quest-earned time { font-variant-numeric: tabular-nums; white-space: nowrap; }
++.step-progress { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; }
++.step-progress progress { width: 64px; max-width: 32vw; }
++/* The terms on a quest page: plain list, the code's own words. */
++.terms { margin: .4rem 0; padding-left: 1.2rem; display: grid; gap: .4rem; max-width: 820px; }
++.terms li { color: var(--text-secondary, #96a7c2); }
++.terms strong { color: var(--text-primary, #e6edf7); }
.quest-series { border-style: dashed; }
.quest-series .medal { font-size: 1.15rem; }
@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex eec03b2..11035dc 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -227,7 +227,20 @@ ${raw(showcase.cta({
const keys = ['all', ...groups.map((g) => siteKey(g.site))];
const active = kit.tabOf(req.query, keys, 'site');
const shown = active === 'all' ? groups : groups.filter((g) => siteKey(g.site) === active);
-- const [appList, p] = await Promise.all([active === 'all' && ctx.appQuests ? ctx.appQuests.listed(100) : [], pulse()]);
++ const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
++ const [appList, p, rows, completions] = await Promise.all([
++ active === 'all' && ctx.appQuests ? ctx.appQuests.listed(100) : [],
++ pulse(),
++ mine ? store.progressFor(s.db, req.viewer.subject) : [],
++ mine ? store.completionsFor(s.db, req.viewer.subject) : [],
++ ]);
++ const completed = new Map(completions.map((c) => [c.quest_id, c.completed_at]));
++ const seen = new Map();
++ for (const row of rows) {
++ const list = seen.get(row.quest_id) || new Map();
++ list.set(Number(row.step), Number(row.count));
++ seen.set(row.quest_id, list);
++ }
const count = (g) => g.quests.length + (g.series || []).reduce((n, x) => n + x.quests.length, 0);
const total = catalog.all().length;
const one = active === 'all' ? null : shown[0];
@@@ -242,10 +255,10 @@ ${raw(showcase.cta({
body: html`<h1>${one ? html`Quests on ${one.name}` : 'All quests'}</h1>
<p class="lede">${one ? html`${count(one)} quest${count(one) === 1 ? '' : 's'} on <a href="https://${one.site}">${one.name}</a>${one.what ? html`, ${one.what}` : ''}.` : html`${total} quests on ${siteCount} OpenVibe sites, grouped by the site whose events each one counts.`} Each pays a badge; how many people hold it is a count, never who.</p>
${raw(kit.tabs({ base: '/quests', param: 'site', label: 'Quests by site', active, items: [{ key: 'all', label: 'All', count: total, href: '/quests' }, ...groups.map((g) => ({ key: siteKey(g.site), label: quests.shortSite(g.site), count: count(g) }))] }))}
--${quests.list(shown, { ...coinsUi, counts: p.byQuest })}
++${quests.list(shown, { ...coinsUi, counts: p.byQuest, seen: mine ? seen : null, completed: mine ? completed : null })}
${appList.length ? html`<section class="site-group" id="app-quests" aria-labelledby="app-quests-h"><h2 id="app-quests-h">Made by apps</h2>
<p class="muted">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) => quests.appCard(q))}</ul></section>` : ''}
++<ul class="quest-list">${appList.map((q) => quests.appCard(q, { seen: mine ? (seen.get(q.id) || new Map()) : null, completedAt: mine ? (completed.get(q.id) || null) : null }))}</ul></section>` : ''}
${quests.coinsNote(coinsUi)}
<p><a href="/">Quests home</a> · <a href="/how-it-works">How it works</a></p>`,
});
@@@ -274,6 +287,7 @@ ${quests.coinsNote(coinsUi)}
store.badgeHolders(s.db, quest.reward.badge.id),
]);
const seen = new Map(rows.map((x) => [Number(x.step), Number(x.count)]));
++ const shown = completion ? new Map(quest.steps.map((s, i) => [i, s.count])) : seen;
return page(req, res, {
index: quest.status === 'active', cache: mine ? null : PUBLIC_CACHE,
title: quest.title, description: quest.why || `${quest.title}: a quest an OpenVibe app made.`,
@@@ -281,12 +295,15 @@ ${quests.coinsNote(coinsUi)}
body: html`<h1>${quest.title}</h1>
${quest.why ? html`<p class="lede">${quest.why}</p>` : ''}
<p class="muted">Made by an OpenVibe developer app (project <code>${quest.project_id}</code>)${quest.url ? html` · <a href="${quest.url}" rel="nofollow noopener">Where to do it</a>` : ''}.${quest.status === 'retired' ? ' Retired: it no longer counts.' : ''}</p>
++<p>${quests.stateChip(Boolean(completion), quest.status === 'retired')}</p>
<h2>What it counts</h2>
--<ul class="steps">${quest.steps.map((step, i) => html`<li><strong>${step.label}</strong> — ${step.count}× <code>${step.event_type}</code>${(seen.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul>
++<ul class="steps">${quest.steps.map((step, i) => html`<li><strong>${step.label}</strong> — ${step.count}× <code>${step.event_type}</code>${(shown.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul>
++${mine ? html`<h2>Your progress</h2>
++${quest.steps.map((step, i) => quests.progress(step.label, shown.get(i) || 0, step.count, quests.unitOf(step)))}` : ''}
<h2>The reward</h2>
<p>The badge <strong>${quest.reward.badge.name}</strong>. App quests never pay OpenCoins.</p>
<p class="muted">${holders ? html`${holders} ${holders === 1 ? 'person has' : 'people have'} this badge.` : 'A badge is awarded once and kept.'}</p>
--${completion ? html`<div class="notice" role="status">You completed this quest on <strong>${String(completion.completed_at).slice(0, 10)}</strong>.</div>` : ''}
++${completion ? html`<div class="notice ok" role="status">Completed on <strong><time datetime="${completion.completed_at}">${String(completion.completed_at).slice(0, 10)}</time></strong>. Badge earned: <strong>${quest.reward.badge.name}</strong>.</div>` : ''}
<p><a href="/quests">All quests</a></p>`,
});
});
@@@ -310,23 +327,31 @@ ${completion ? html`<div class="notice" role="status">You completed this quest o
]);
return { seen: new Map(rows.map((x) => [Number(x.step), Number(x.count)])), completion, holders };
};
-- return load().then((state) => page(req, res, {
-- index: true, cache: mine ? null : PUBLIC_CACHE,
-- title: quest.title,
-- description: questDescription(quest),
-- crumbs: [{ label: 'Quests', href: '/' }, { label: quest.title }],
-- body: html`<h1>${quest.title}</h1>
++ return load().then((state) => {
++ // A completion means every step reached its target, even if no progress row was written for this step.
++ const seen = state ? (state.completion ? new Map(quest.steps.map((s, i) => [i, s.count])) : state.seen) : null;
++ return page(req, res, {
++ index: true, cache: mine ? null : PUBLIC_CACHE,
++ title: quest.title,
++ description: questDescription(quest),
++ crumbs: [{ label: 'Quests', href: '/' }, { label: quest.title }],
++ body: html`<h1>${quest.title}</h1>
<p class="lede">${quest.why}</p>
<p class="muted">On <a href="https://${quest.site}">${siteName(quest.site)}</a>${quest.series && catalog.SERIES[quest.series] ? html`, in <a href="/series/${quest.series}">${catalog.SERIES[quest.series].title}</a>` : ''}.</p>
++<p>${quests.stateChip(Boolean(state && state.completion))}</p>
<h2>What it counts</h2>
--<ul class="steps">${quest.steps.map((step, i) => html`<li><strong>${step.label}</strong> — ${step.count}× <code>${step.event_type}</code>${Object.entries(step.match || {}).map(([k, v]) => html` with <code>${k}</code> <code>${String(v)}</code>`)}${state && (state.seen.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul>
++<ul class="steps">${quest.steps.map((step, i) => html`<li><strong>${step.label}</strong> — ${step.count}× <code>${step.event_type}</code>${Object.entries(step.match || {}).map(([k, v]) => html` with <code>${k}</code> <code>${String(v)}</code>`)}${seen && (seen.get(i) || 0) >= step.count ? html` <span class="done">done</span>` : ''}</li>`)}</ul>
++${state ? html`<h2>Your progress</h2>
++${quest.steps.map((step, i) => quests.progress(step.label, seen.get(i) || 0, step.count, quests.unitOf(step)))}` : ''}
<h2>The reward</h2>
<p>${quests.reward(quest, coinsUi)}</p>
<p class="muted">${state && state.holders ? html`${state.holders} ${state.holders === 1 ? 'person has' : 'people have'} this badge.` : html`A badge is awarded once and kept.`}</p>
--${state && state.completion ? html`<div class="notice" role="status">You completed this quest on <strong>${String(state.completion.completed_at).slice(0, 10)}</strong>${coinsUi.coinsEnabled && state.completion.coins_state === 'credited' ? html`, and its OpenCoins are in your wallet` : ''}.</div>` : html`<p><a href="/me">Your quest log</a> keeps your progress.</p>`}
++${state && state.completion ? html`<div class="notice ok" role="status">Completed on <strong><time datetime="${state.completion.completed_at}">${String(state.completion.completed_at).slice(0, 10)}</time></strong>. Badge earned: <strong>${quest.reward.badge.name}</strong>${coinsUi.coinsEnabled && state.completion.coins_state === 'credited' ? html`, and its OpenCoins are in your wallet` : ''}.</div>` : html`<p><a href="/me">Your quest log</a> keeps your progress.</p>`}
++${quests.terms()}
${quests.coinsNote(coinsUi)}
<p><a href="/quests">All quests</a></p>`,
-- }));
++ });
++ });
});
// ── A series: its quests, and which of them the person has done ──
@@@ -337,8 +362,17 @@ ${quests.coinsNote(coinsUi)}
body: html`<h1>No such series</h1><p>There is no quest series at this address. <a href="/quests">The quest list</a> has every quest.</p>`,
}, 404);
const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
-- const done = new Set(mine ? (await store.completionsFor(s.db, req.viewer.subject)).map((c) => c.quest_id) : []);
-- const finished = series.quests.filter((q) => done.has(q.id)).length;
++ const [rows, completions] = mine
++ ? await Promise.all([store.progressFor(s.db, req.viewer.subject), store.completionsFor(s.db, req.viewer.subject)])
++ : [[], []];
++ const completed = new Map(completions.map((c) => [c.quest_id, c.completed_at]));
++ const seen = new Map();
++ for (const row of rows) {
++ const list = seen.get(row.quest_id) || new Map();
++ list.set(Number(row.step), Number(row.count));
++ seen.set(row.quest_id, list);
++ }
++ const finished = series.quests.filter((q) => completed.has(q.id)).length;
return page(req, res, {
index: true, cache: mine ? null : PUBLIC_CACHE,
title: series.title,
@@@ -347,8 +381,8 @@ ${quests.coinsNote(coinsUi)}
body: html`<h1>${series.title}</h1>
<p class="lede">${series.why}</p>
<p class="muted">On <a href="https://${series.site}">${siteName(series.site)}</a>. ${series.how} <a href="${series.href}">${series.hrefLabel}</a>.</p>
--${mine ? quests.progress('Your progress', finished, series.quests.length) : ''}
--<ul class="quest-list">${series.quests.map((q) => quests.card(q, { ...coinsUi, earned: done.has(q.id) }))}</ul>
++${mine ? quests.progress('Your progress', finished, series.quests.length, series.quests.length > 1 ? 'quests' : '') : ''}
++<ul class="quest-list">${series.quests.map((q) => quests.card(q, { ...coinsUi, earned: completed.has(q.id), seen: mine ? (seen.get(q.id) || new Map()) : null, completedAt: mine ? (completed.get(q.id) || null) : null }))}</ul>
${quests.coinsNote(coinsUi)}
<p><a href="/quests">All quests</a>${mine ? html` · <a href="/me">Your quest log</a>` : ''}</p>`,
});
@@@ -403,14 +437,21 @@ each quest still needs, and the badges you have earned.</p>
<p class="muted">Signed in as <strong>${req.viewer.displayName || req.viewer.username || 'you'}</strong>. One log across every OpenVibe site.</p>
${yourStuff(req)}
<h2>In progress</h2>
--${started.length ? started.map((q) => html`<section class="quest-block"><h3><a href="/quests/${q.id}">${q.title}</a> <small>on ${siteName(q.site)}</small></h3>
--${q.steps.map((step, i) => quests.progress(step.label, (seen.get(q.id) || new Map()).get(i) || 0, step.count))}
++${started.length ? started.map((q) => html`<section class="quest-block"><h3><a href="/quests/${q.id}">${q.title}</a> <small>on ${siteName(q.site)}</small> ${quests.stateChip(false)}</h3>
++${q.steps.map((step, i) => quests.progress(step.label, (seen.get(q.id) || new Map()).get(i) || 0, step.count, quests.unitOf(step)))}
<p class="quest-reward">${quests.reward(q, coinsUi)}</p></section>`) : html`<p class="muted">Nothing started yet. <a href="/quests">Pick a quest</a> — any of the sites you already use counts.</p>`}
<h2>Completed</h2>
${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
const c = done.get(q.id);
-- return html`<li class="quest"><a class="quest-title" href="/quests/${q.id}">${q.title}</a>
--<p class="quest-why">Completed ${String(c.completed_at).slice(0, 10)}. Badge: <strong>${q.reward.badge.name}</strong>. ${q.reward.item ? (c.item_state === 'granted' ? html`${q.reward.item.emoji} <strong>${q.reward.item.name}</strong> is in <a href="https://inventory.openvibe.network/me">your inventory</a>. ` : c.item_state === 'failed' ? html`${q.reward.item.name} could not be given yet — this is recorded and retried. ` : html`${q.reward.item.name} is on its way to your inventory. `) : ''}${c.coins > 0 ? (c.coins_state === 'credited' ? html`<strong>${c.coins}</strong> OpenCoins credited.` : c.coins_state === 'skipped' ? html`${c.coins} OpenCoins ${coinsUi.coinsEnabled ? html`were not credited: ${c.coins_error || 'the daily cap was reached'}` : html`start soon`}.` : html`${c.coins} OpenCoins could not be credited yet — this is recorded and retried.`) : ''}</p></li>`;
++ const item = q.reward.item ? (c.item_state === 'granted' ? html`${q.reward.item.emoji} <strong>${q.reward.item.name}</strong> is in <a href="https://inventory.openvibe.network/me">your inventory</a>. ` : c.item_state === 'failed' ? html`${q.reward.item.name} could not be given yet — this is recorded and retried. ` : html`${q.reward.item.name} is on its way to your inventory. `) : '';
++ const coins = c.coins > 0 ? (c.coins_state === 'credited' ? html`<strong>${c.coins}</strong> OpenCoins credited.` : c.coins_state === 'skipped' ? html`${c.coins} OpenCoins ${coinsUi.coinsEnabled ? html`were not credited: ${c.coins_error || 'the daily cap was reached'}` : html`start soon`}.` : html`${c.coins} OpenCoins could not be credited yet — this is recorded and retried.`) : '';
++ return html`<li class="quest is-completed">
++ ${quests.medal(q, { earned: true })}
++ <div class="quest-main">
++ <a class="quest-title" href="/quests/${q.id}">${q.title}</a> ${quests.stateChip(true)}
++ <p class="quest-earned">Completed <time datetime="${c.completed_at}">${String(c.completed_at).slice(0, 10)}</time> · Badge: <strong>${q.reward.badge.name}</strong></p>
++ ${item || coins ? html`<p class="quest-why">${item}${coins}</p>` : ''}
++ </div></li>`;
})}</ul>` : html`<p class="muted">No quests completed yet.</p>`}
<h2>Your badges</h2>
${badges.length ? html`<ul class="badges">${badges.map((b) => html`<li><strong>${badge(b.badge_id).name}</strong> <small>${String(b.awarded_at).slice(0, 10)}</small></li>`)}</ul>
ddiff --git a/server/render/quests.js b/server/render/quests.js
iindex 790d4de..454c5fb 100644
---- a/server/render/quests.js
++++ b/server/render/quests.js
@@@ -11,20 +11,60 @@ const art = require('./badge-art');
const { html } = require('./html');
/**
-- * What a quest pays: its badge (a text label), for some an item (OpenVibe.Inventory) and its OpenCoins. When OpenCoins are off the reward says so
-- * instead of promising points that do not move — the sentence the product uses everywhere.
++ * What a quest pays: its badge (a text label), for some an item (OpenVibe.Inventory) and its OpenCoins. Every chip
++ * says its type and what it is for, and the coin chip keeps its amount even while OpenCoins are off, so the catalog's
++ * promise is visible rather than replaced by "soon". Never an "up to": the catalog's amount is the whole amount.
*/
function reward(quest, { coinsEnabled = false } = {}) {
const badge = quest.reward.badge.name;
-- const badgeChip = html`<span class="reward-badge">Badge: <strong>${badge}</strong></span>`;
++ const badgeChip = html`<span class="reward-badge">Badge: <strong>${badge}</strong> <small>on your profile</small></span>`;
// An item for the person's OpenVibe.Inventory (server/quests/items.js), worn in chat and on their profile.
const it = quest.reward.item;
-- const itemChip = it ? html`<span class="reward-item" title="${it.kind} for your OpenVibe inventory"><span aria-hidden="true">${it.emoji}</span> <strong>${it.name}</strong> <small>${it.kind.toLowerCase()}</small></span>` : '';
++ const itemChip = it ? html`<span class="reward-item" title="${it.kind} for your OpenVibe inventory"><span aria-hidden="true">${it.emoji}</span> <strong>${it.name}</strong> <small>${it.kind} for your inventory</small></span>` : '';
if (quest.reward.coins <= 0) return html`<span class="reward">${badgeChip}${itemChip}</span>`;
-- if (!coinsEnabled) return html`<span class="reward">${badgeChip}${itemChip}<span class="reward-coins soon">OpenCoins rewards start soon</span></span>`;
++ if (!coinsEnabled) return html`<span class="reward">${badgeChip}${itemChip}<span class="reward-coins soon"><strong>${quest.reward.coins}</strong> OpenCoins, <small>start soon</small></span></span>`;
return html`<span class="reward">${badgeChip}${itemChip}<span class="reward-coins"><strong>${quest.reward.coins}</strong> OpenCoins</span></span>`;
}
++/**
++ * What a counted step counts, in words, for the fraction beside its bar. Only a step that needs more than one event
++ * shows it ("2/3 streams"); a one-event step reads as "1/1", the way the reference card renders "7/10". The nouns
++ * describe the catalog's own labels; no count or limit is added.
++ */
++const STEP_UNITS = Object.freeze({
++ 'live.stream.started': 'streams',
++ 'network.follow.created': 'follows',
++ 'community.thread.created': 'threads',
++ 'community.post.created': 'replies',
++ 'community.comment.created': 'comments',
++ 'space.thread.created': 'threads',
++ 'chat.room.message.created': 'messages',
++ 'wiki.revision.created': 'revisions',
++ 'games.player.joined': 'worlds',
++ 'tools.job.created': 'jobs',
++ 'live.setup.task_completed': 'tasks',
++ 'blog.post.published': 'posts',
++ 'deals.offer.created': 'deals',
++ 'deals.vote.changed': 'votes',
++ 'run.job.succeeded': 'jobs',
++ 'inventory.item.equipped': 'items',
++ 'bot.robot.online': 'robots',
++ 'games.world.published': 'worlds',
++ 'mediahub.video.published': 'videos',
++ 'mediahub.picture.uploaded': 'pictures',
++});
++const unitOf = (step) => (step.count > 1 ? STEP_UNITS[step.event_type] || '' : '');
++
++/** A progress bar with the ARIA a screen reader reads (role=progressbar, now/max, the fraction as the value text). */
++function bar(value, target, text, label) {
++ return html`<progress max="${target}" value="${value}" role="progressbar" aria-label="${label}" aria-valuemin="0" aria-valuemax="${target}" aria-valuenow="${value}" aria-valuetext="${text}">${text}</progress>`;
++}
++
++/** The fraction as the page writes it: "3/12 hours" when the step has a unit, "7/10" when it has none. */
++function fraction(value, target, unit = '') {
++ return `${value}/${target}${unit ? ` ${unit}` : ''}`;
++}
++
/** A badge's colour, fixed by its quest id, so a badge looks the same wherever it is shown. */
function hueOf(id) {
let h = 0;
@@@ -82,15 +122,48 @@ 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, counts = null } = {}) {
-- return html`<li class="quest">
-- ${medal(quest, { earned })}
++/**
++ * The state as text as well as colour: Active, Completed once the badge is awarded, or Locked for an app quest its
++ * app retired (the only state in the code that stops a quest counting).
++ */
++const stateChip = (done, locked = false) => (locked && !done
++ ? html`<span class="quest-state quest-state--locked">Locked</span>`
++ : done
++ ? html`<span class="quest-state quest-state--completed">Completed</span>`
++ : html`<span class="quest-state quest-state--active">Active</span>`);
++
++/**
++ * One counted step in a card: its label, and — when the viewer's progress is known — the fraction with its unit and
++ * a bar. Without a viewer it keeps the catalog's target ("×3"), so "what it counts" is never hidden.
++ */
++function stepLine(step, i, seen) {
++ const unit = unitOf(step);
++ if (!seen) {
++ return html`<li>${step.label}${step.count > 1 ? html` <span class="step-count">×${step.count}</span>` : ''}</li>`;
++ }
++ const value = Math.min(seen.get(i) || 0, step.count);
++ const text = fraction(value, step.count, unit);
++ return html`<li>${step.label}<span class="step-progress">${bar(value, step.count, text, step.label)}<span class="progress-count">${text}</span></span></li>`;
++}
++
++/**
++ * One quest in a list: its title, one-line description, what it counts, the reward with its amount, and its state as
++ * text. `seen` is the viewer's step counts (Map step → count); `completedAt` is when they earned it. A completed card
++ * is dimmed and repeats the date and the badge, so the state is not colour alone.
++ */
++function card(quest, { coinsEnabled = false, earned = false, counts = null, seen = null, completedAt = null } = {}) {
++ const done = Boolean(earned || completedAt);
++ const date = completedAt ? String(completedAt).slice(0, 10) : '';
++ // A completion means every step reached its target; show that even if the progress rows were not the reason.
++ const stepSeen = done && seen ? new Map(quest.steps.map((s, i) => [i, s.count])) : seen;
++ return html`<li class="quest${done ? ' is-completed' : ''}">
++ ${medal(quest, { earned: done })}
<div class="quest-main">
-- <a class="quest-title" href="/quests/${quest.id}">${quest.title}</a>${earned ? html` <span class="done">done</span>` : ''}
++ <a class="quest-title" href="/quests/${quest.id}">${quest.title}</a> ${stateChip(done)}
<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>
++ <ul class="quest-steps">${quest.steps.map((s, i) => stepLine(s, i, stepSeen))}</ul>
<p class="quest-reward">${reward(quest, { coinsEnabled })}</p>
++ ${done && completedAt ? html`<p class="quest-earned">Completed <time datetime="${completedAt}">${date}</time> · Badge: <strong>${quest.reward.badge.name}</strong></p>` : ''}
${counts ? earnedBy(counts.get(quest.id) || 0) : ''}
</div>
</li>`;
@@@ -106,42 +179,70 @@ function seriesCard(series, { coinsEnabled = false, counts = null } = {}) {
<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>
++ <p class="quest-reward"><span class="reward"><span class="reward-badge"><strong>${n}</strong> quests, a badge each</span>${coins > 0 ? html`<span class="reward-coins${coinsEnabled ? '' : ' soon'}"><strong>${coins}</strong> OpenCoins in all${coinsEnabled ? '' : html`, <small>start soon</small>`}</span>` : ''}</span></p>
${done > 0 ? html`<p class="quest-count">${done} ${done === 1 ? 'badge' : 'badges'} earned in this series</p>` : ''}
</div>
</li>`;
}
/** A quest a developer app made, in a list (the full index, /quests): its badge, what it is, and where to do it. */
--function appCard(q) {
-- return html`<li class="quest">
-- ${medal(q)}
++function appCard(q, { seen = null, completedAt = null } = {}) {
++ const done = Boolean(completedAt);
++ const stepSeen = done && seen ? new Map(q.steps.map((s, i) => [i, s.count])) : seen;
++ return html`<li class="quest${done ? ' is-completed' : ''}">
++ ${medal(q, { earned: done })}
<div class="quest-main">
-- <a class="quest-title" href="/apps/quests/${q.id}">${q.title}</a>
++ <a class="quest-title" href="/apps/quests/${q.id}">${q.title}</a> ${stateChip(done)}
${q.why ? html`<p class="quest-why">${q.why}</p>` : ''}
-- <p class="quest-reward"><span class="reward"><span class="reward-badge">Badge: <strong>${q.reward.badge.name}</strong></span></span></p>
++ <ul class="quest-steps">${q.steps.map((s, i) => stepLine(s, i, stepSeen))}</ul>
++ <p class="quest-reward"><span class="reward"><span class="reward-badge">Badge: <strong>${q.reward.badge.name}</strong> <small>on your profile</small></span></span></p>
++ ${done && completedAt ? html`<p class="quest-earned">Completed <time datetime="${completedAt}">${String(completedAt).slice(0, 10)}</time> · Badge: <strong>${q.reward.badge.name}</strong></p>` : ''}
${q.url ? html`<p class="quest-count"><a href="${q.url}" rel="nofollow noopener">Where to do it</a></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, counts = null } = {}) {
++function list(groups = catalog.grouped(), { coinsEnabled = false, counts = null, seen = null, completed = 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);
++ // `seen` is the viewer's Map quest id → Map(step → count); null for a signed-out reader, who gets the targets only.
++ const cardFor = (q) => card(q, {
++ coinsEnabled, counts,
++ seen: seen ? (seen.get(q.id) || new Map()) : null,
++ completedAt: completed ? (completed.get(q.id) || null) : null,
++ });
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, counts }))}${(g.series || []).map((x) => seriesCard(x, { coinsEnabled, counts }))}</ul>
++ <ul class="quest-list">${g.quests.map(cardFor)}${(g.series || []).map((x) => seriesCard(x, { coinsEnabled, counts }))}</ul>
</section>`)}`;
}
--/** A progress bar as a plain <progress> element — readable without JavaScript and by a screen reader. */
--function progress(label, count, target) {
-- const value = Math.min(count, target);
++/**
++ * A progress bar as a plain <progress> element with the ARIA role and values a screen reader reads, and the fraction
++ * written beside it — "3/12 hours" when the unit is passed, "7/10" otherwise.
++ */
++function progress(label, count, target, unit = '') {
++ const value = Math.min(Math.max(Number(count) || 0, 0), target);
++ const text = fraction(value, target, unit);
return html`<div class="progress"><span class="progress-label">${label}</span>
-- <progress max="${target}" value="${value}">${value} of ${target}</progress>
-- <span class="progress-count">${value} / ${target}</span></div>`;
++ ${bar(value, target, text, label)}
++ <span class="progress-count">${text}</span></div>`;
++}
++
++/**
++ * A quest's terms, the same on every quest page: only what the code does. It needs a signed-in account and nothing
++ * else — no wallet to connect, no card, no subscription or auto-renew; the event inbox is the only thing that moves
++ * a quest, and a badge is awarded once and kept.
++ */
++function terms() {
++ return html`<h2>Terms</h2>
++<ul class="terms">
++<li><strong>Nothing to connect or pay.</strong> Earning a quest needs a signed-in OpenVibe account: no wallet to connect, no card, and no subscription or auto-renew.</li>
++<li><strong>Counted by Quest's event inbox.</strong> Quest counts the events OpenVibe sites send to its own event inbox (<code>quest_event_inbox</code>), and nothing else: no page views, no clicks, no watch time.</li>
++<li><strong>Once per person.</strong> A quest completes once, and its badge is awarded once and kept.</li>
++</ul>`;
}
/** The one sentence about OpenCoins: what they are, and whether they move yet. */
@@@ -155,4 +256,4 @@ ${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone toge
never money — and never Vibes.</p>`;
}
--module.exports = { medal, hueOf, reward, card, seriesCard, appCard, list, progress, coinsNote, kitCard, seriesKitCard, appKitCard, badgeSvg, seriesSvg, shortSite };
++module.exports = { medal, hueOf, reward, card, seriesCard, appCard, list, progress, terms, stateChip, coinsNote, kitCard, seriesKitCard, appKitCard, badgeSvg, seriesSvg, shortSite, unitOf };
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex 78b8406..1e42f5a 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -165,7 +165,7 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
// The series page shows a signed-in streamer what they have done; the home page folds the series into one card.
const page = await t.get('/series/live-setup', { as: u });
assert.strictEqual(page.status, 200);
-- assert.match(page.text, /<progress max="18" value="18">/);
++ assert.match(page.text, /<progress max="18" value="18" role="progressbar"[^>]*>18\/18 quests<\/progress>/);
assert.ok(page.text.includes('href="/quests/live-setup-emote"'));
const home = await t.get('/');
assert.ok(home.text.includes('href="/series/live-setup"'), 'the home page links the series');
@@@ -258,7 +258,7 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
assert.strictEqual(settleIn.state, 'in_progress');
assert.deepStrictEqual(settleIn.progress.map((p) => [p.event_type, p.count]), [['community.thread.created', 0], ['chat.room.message.created', 1]]);
const page = await t.get('/me', { as: u });
-- assert.match(page.text, /<progress max="3" value="1">1 of 3<\/progress>/, 'a real <progress> element');
++ assert.match(page.text, /<progress max="3" value="1" role="progressbar"[^>]*>1\/3 streams<\/progress>/, 'a real <progress> element with its ARIA values');
assert.ok(page.text.includes('First Broadcast'), 'the badge it earned');
});
} finally { await t.close(); }
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex cc071fd..9dce528 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -27,7 +27,7 @@ const BUDGETS = {
jsRawKB: 331, // 328.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 324.8 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 323.2 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 80.8, // 80.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 79.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
cssFiles: 4, // 4 (2026-10-29: + /shared/kit.css and /shared/room.css, the kit's numbers, filters, folds, cards and rails); 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 107, // 106.1 with 2026-11-01: openvibe-shared 4.86.0 (2026-11-01); 105.3 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 103.3 with openvibe-shared 4.83.0: the -text status tokens add about 0.4 KB raw (102.9 KB measured on 4.82.0) (2026-11-01); 102.9 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 100.8 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 96.4 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 92.9 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 87.6 the phone home (2026-10-30: the fold rules for how it works and the questions); 83.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)
++ cssRawKB: 108, // 107.5 with 2026-11-01: market outcomes (2026-11-01); 106.1 with 2026-11-01: openvibe-shared 4.86.0 (2026-11-01); 105.3 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 103.3 with openvibe-shared 4.83.0: the -text status tokens add about 0.4 KB raw (102.9 KB measured on 4.82.0) (2026-11-01); 102.9 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 100.8 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 96.4 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 92.9 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 87.6 the phone home (2026-10-30: the fold rules for how it works and the questions); 83.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 74.6 the home on the shared kit (2026-10-29, openvibe-shared 4.37.0): kit.css 25.3 KB (numbers strip, site filters, folds) and room.css 15.7 KB (the quests as rails of cards), app.css down 1.5 KB; the page shed 13 of its 17 phone screens; 34.4 openvibe-shared 4.26.1 (hero visual); 28.9 with Shared 4.13.0 (2026-10-26: account links); 22.6 with the quest medals and cards, and the home's figures, badges, sites and FAQ (2026-10-22)
cssBrotliKB: 23.5, // 23.1 with 2026-11-01: phone table cards (+1.3 KB raw) (2026-11-01); 22.8 with openvibe-shared 4.82.0: room over the rounded measure (2026-11-01); 22.5 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 22.0 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 20.1 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 19.3 the phone home (2026-10-30: the fold rules for how it works and the questions); 18.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 16.5 kit.css and room.css on the home (2026-10-29, see cssRawKB); 6.2 with Shared 4.13.0 (2026-10-26: account links); 4.6
externalFiles: 0, // 0
};
ddiff --git a/test/quest-cards.test.js b/test/quest-cards.test.js
nnew file mode 100644
iindex 0000000..665f480
---- /dev/null
++++ b/test/quest-cards.test.js
@@@ -0,0 +1,141 @@
++'use strict';
++/**
++ * The quest card and quest page the market outcomes ask for:
++ *
++ * - a card shows its title, one-line description, what it counts and a reward with its amount;
++ * - progress is a numerator/denominator with a unit and a bar carrying role=progressbar and its ARIA values;
++ * - the state is written as text (Active / Completed), and a completed card is dimmed;
++ * - a reward says its type and amount, never "up to";
++ * - a quest page states its terms (no wallet, no card, no auto-renew, the event inbox);
++ * - a completed quest shows the date it was earned and the badge.
++ *
++ * The cases where a line must be left out: a signed-out card has no personal progress or completion date, and no
++ * expiry/urgent line exists because the database has no expiry column.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const catalog = require('../server/quests/catalog');
++const renderQuests = require('../server/render/quests');
++const { render } = require('../server/render/html');
++
++const NOW = '2026-10-01T10:00:00.000Z';
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++
++ try {
++ await check('progress writes the fraction with a unit and a bar with role=progressbar and ARIA now/max', () => {
++ const hours = render(renderQuests.progress('Watch time', 3, 12, 'hours'));
++ assert.ok(hours.includes('3/12 hours'), 'the fraction with its unit');
++ assert.ok(hours.includes('role="progressbar"'), 'a progressbar role');
++ assert.ok(hours.includes('aria-valuenow="3"') && hours.includes('aria-valuemax="12"'), 'its values');
++ const bare = render(renderQuests.progress('Votes', 7, 10));
++ assert.ok(bare.includes('7/10') && !bare.includes('hours'), 'a unit is optional, as in 7/10');
++ });
++
++ await check('a quest card shows the title, why, what it counts, the reward amount and the state', () => {
++ const q = catalog.byId('stream-three-times');
++ const card = render(renderQuests.card(q, { coinsEnabled: false, seen: new Map([[0, 1]]) }));
++ assert.ok(card.includes('Stream three times'), 'the title');
++ assert.ok(card.includes('Three broadcasts is a habit'), 'the one-line description');
++ assert.ok(card.includes('Start a stream') && card.includes('1/3 streams'), 'what it counts, with progress and its unit');
++ assert.ok(card.includes('role="progressbar"') && card.includes('aria-valuenow="1"') && card.includes('aria-valuemax="3"'), 'the bar and its values');
++ assert.ok(card.includes('Badge: <strong>Regular Broadcaster</strong>'), 'the badge reward, named');
++ assert.ok(card.includes('25</strong> OpenCoins'), 'the coin amount, even while OpenCoins are off');
++ assert.ok(card.includes('start soon'), 'and that they do not move yet');
++ assert.ok(!card.includes('up to'), 'never an up-to figure');
++ assert.ok(card.includes('Active') && !card.includes('is-completed'), 'the state as text, active');
++ });
++
++ await check('a completed card is dimmed and repeats the date earned and the badge', () => {
++ const q = catalog.byId('go-live');
++ const card = render(renderQuests.card(q, { coinsEnabled: false, seen: new Map(), completedAt: NOW }));
++ assert.ok(card.includes('class="quest is-completed"'), 'the card is dimmed by its class');
++ assert.ok(card.includes('Completed'), 'the state as text');
++ assert.ok(card.includes(`<time datetime="${NOW}">2026-10-01</time>`), 'the date it was earned');
++ assert.ok(card.includes('Badge: <strong>First Broadcast</strong>'), 'the badge');
++ assert.ok(card.includes('1/1'), 'a completed step reads 1/1, not the missing progress row');
++ const open = render(renderQuests.card(q, { coinsEnabled: false, seen: new Map() }));
++ assert.ok(!open.includes('Completed <time'), 'not completed: the completion line is left out');
++ assert.ok(open.includes('Active'), 'and it says Active');
++ });
++
++ await check('a retired app quest says Locked, a completed one still says Completed', () => {
++ assert.ok(render(renderQuests.stateChip(false, true)).includes('Locked'), 'the one locked state in the code');
++ assert.ok(render(renderQuests.stateChip(true, true)).includes('Completed'), 'a badge already earned stays earned');
++ assert.ok(render(renderQuests.stateChip(false)).includes('Active'), 'everything else is Active');
++ });
++
++ await check('a quest page states its terms, only what the code does', () => {
++ const terms = render(renderQuests.terms());
++ for (const line of ['no wallet to connect', 'no card', 'no subscription or auto-renew', 'quest_event_inbox', 'no page views, no clicks, no watch time']) {
++ assert.ok(terms.includes(line), line);
++ }
++ assert.ok(!/expir/i.test(terms), 'no expiry claim: there is no expiry column');
++ assert.ok(!terms.includes('up to'), 'never an up-to figure');
++ });
++
++ // A person with one started quest and one completed quest, read back from the real pages.
++ await t.ctx.s.db.query('INSERT INTO quest_progress (subject, quest_id, step, count, updated_at) VALUES ($1, $2, 0, 1, $3)', [kim.subject, 'stream-three-times', NOW]);
++ await t.ctx.s.db.query('INSERT INTO quest_completions (id, subject, quest_id, completed_at, coins, coins_state) VALUES ($1, $2, $3, $4, 10, $5)', ['qcp_kim', kim.subject, 'go-live', NOW, 'skipped']);
++ await t.ctx.s.db.query('INSERT INTO quest_badges (subject, badge_id, quest_id, awarded_at) VALUES ($1, $2, $3, $4)', [kim.subject, 'first-broadcast', 'go-live', NOW]);
++
++ await check('signed out, the index shows the catalog card and leaves personal progress and dates out', async () => {
++ const r = await t.get('/quests');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('10</strong> OpenCoins'), 'the reward amount is on the public card');
++ assert.ok(r.text.includes('class="quest-steps"'), 'what it counts');
++ assert.ok(r.text.includes('quest-state--active') && r.text.includes('>Active</span>'), 'state as text');
++ assert.ok(!r.text.includes('is-completed'), 'nobody is signed in, so no completed card');
++ assert.ok(!r.text.includes('1/3 streams'), 'no personal progress is invented');
++ assert.ok(!r.text.includes('Completed <time'), 'no completion date');
++ assert.ok(!/expir/i.test(r.text) && !r.text.includes('up to'), 'no expiry or up-to line');
++ });
++
++ await check('signed in, the index shows the viewer\'s fraction, unit and completed card', async () => {
++ const r = await t.get('/quests', { as: kim });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('1/3 streams'), 'the started quest\'s fraction with its unit');
++ assert.ok(r.text.includes('aria-valuenow="1"') && r.text.includes('aria-valuemax="3"'), 'the bar\'s ARIA values');
++ assert.ok(r.text.includes('class="quest is-completed"'), 'the completed quest is dimmed');
++ assert.match(r.text, /Completed <time datetime="2026-10-01T10:00:00\.000Z">2026-10-01<\/time> · Badge: <strong>First Broadcast<\/strong>/, 'the date and the badge');
++ assert.ok(r.text.includes('quest-state--completed'), 'state as text as well as colour');
++ });
++
++ await check('a quest page carries the terms, and a completed one the date and the badge', async () => {
++ const open = await t.get('/quests/go-live');
++ assert.ok(open.text.includes('<h2>Terms</h2>'), 'the terms heading');
++ assert.ok(open.text.includes('no wallet to connect') && open.text.includes('quest_event_inbox'), 'the terms themselves');
++ assert.ok(open.text.includes('quest-state--active'), 'an open quest is Active');
++ assert.ok(!open.text.includes('Your progress'), 'signed out: no personal progress section');
++ assert.ok(!open.text.includes('Badge earned'), 'signed out: no earned line');
++
++ const donePage = await t.get('/quests/go-live', { as: kim });
++ assert.ok(donePage.text.includes('quest-state--completed'), 'the completed quest is Completed');
++ assert.ok(donePage.text.includes('<h2>Your progress</h2>') && donePage.text.includes('1/1'), 'its progress is complete');
++ assert.match(donePage.text, /Completed on <strong><time datetime="2026-10-01T10:00:00\.000Z">2026-10-01<\/time><\/strong>\. Badge earned: <strong>First Broadcast<\/strong>/, 'the date and the badge');
++ assert.ok(!/expir/i.test(donePage.text), 'no expiry line: no column exists');
++ });
++
++ await check('a series shows its own progress with the quest unit', async () => {
++ const series = await t.get('/series/live-setup', { as: kim });
++ assert.strictEqual(series.status, 200);
++ const total = catalog.seriesById('live-setup').quests.length;
++ assert.ok(series.text.includes(`0/${total} quests`), 'the series fraction with its unit');
++ assert.ok(series.text.includes('role="progressbar"') && series.text.includes('aria-valuemax="1"'), 'its bar, and the cards\' bars');
++ });
++
++ await check('the quest log shows the active state, the unit and the completed date and badge', async () => {
++ const me = await t.get('/me', { as: kim });
++ assert.strictEqual(me.status, 200);
++ assert.ok(me.text.includes('1/3 streams'), 'the in-progress fraction with its unit');
++ assert.ok(me.text.includes('quest-state--active'), 'Active');
++ assert.ok(me.text.includes('class="quest is-completed"') && me.text.includes('quest-state--completed'), 'the completed card, dimmed');
++ assert.match(me.text, /Completed <time datetime="2026-10-01T10:00:00\.000Z">2026-10-01<\/time> · Badge: <strong>First Broadcast<\/strong>/, 'the date and the badge');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/quests-api.test.js b/test/quests-api.test.js
iindex be4fac7..077ca1d 100644
---- a/test/quests-api.test.js
++++ b/test/quests-api.test.js
@@@ -182,7 +182,9 @@ const { render } = require('../server/render/html');
const mine = await t.get('/me', { as: kim });
assert.strictEqual(mine.status, 200);
-- assert.ok(mine.text.includes('<progress max="3" value="2">2 of 3</progress>'), 'a real progress bar for the started quest');
++ assert.ok(mine.text.includes('role="progressbar"') && mine.text.includes('aria-valuenow="2"') && mine.text.includes('aria-valuemax="3"'), 'a real progress bar with its ARIA values');
++ assert.ok(mine.text.includes('2/3 streams'), 'the fraction with its unit');
++ assert.ok(mine.text.includes('<span class="quest-state quest-state--active">Active</span>'), 'the started quest says Active');
assert.ok(mine.text.includes('First Broadcast'), 'the badge');
assert.ok(mine.text.includes('Completed'), 'the completion');
assert.ok(mine.text.includes(`/api/v1/profiles/${kim.subject}/badges`), 'it says what is public');