openvibers/openvibe.quest Commits
Quest cards: state, progress with units, reward amounts, terms (#169)
Changes
@@@ -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; } }@@@ -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>@@@ -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 togenever 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 };@@@ -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, cassert.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(); }@@@ -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.8jsBrotliKB: 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.5cssFiles: 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.6externalFiles: 0, // 0};nnew file mode 100644@@@ -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); });@@@ -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');
@@ -67,7 +67,7 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
@@ -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-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-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 { 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); }
.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); }
.step-count { font-size: .78rem; font-weight: 700; color: var(--text-muted, #7386a3); }
.quest-reward { margin: 0; }
.quest-reward { margin: 0; }
@@ -78,6 +78,21 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
@@ -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 { 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-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-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 { border-style: dashed; }
.quest-series .medal { font-size: 1.15rem; }
.quest-series .medal { font-size: 1.15rem; }
@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }
@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }
@@ -227,7 +227,20 @@ ${raw(showcase.cta({
@@ -227,7 +227,20 @@ ${raw(showcase.cta({
const keys = ['all', ...groups.map((g) => siteKey(g.site))];
const keys = ['all', ...groups.map((g) => siteKey(g.site))];
const active = kit.tabOf(req.query, keys, 'site');
const active = kit.tabOf(req.query, keys, 'site');
const shown = active === 'all' ? groups : groups.filter((g) => siteKey(g.site) === active);
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 count = (g) => g.quests.length + (g.series || []).reduce((n, x) => n + x.quests.length, 0);
const total = catalog.all().length;
const total = catalog.all().length;
const one = active === 'all' ? null : shown[0];
const one = active === 'all' ? null : shown[0];
@@ -242,10 +255,10 @@ ${raw(showcase.cta({
@@ -242,10 +255,10 @@ ${raw(showcase.cta({
body: html`<h1>${one ? html`Quests on ${one.name}` : 'All quests'}</h1>
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>
<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) }))] }))}
${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>
${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>
<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)}
${quests.coinsNote(coinsUi)}
<p><a href="/">Quests home</a> · <a href="/how-it-works">How it works</a></p>`,
<p><a href="/">Quests home</a> · <a href="/how-it-works">How it works</a></p>`,
});
});
@@ -274,6 +287,7 @@ ${quests.coinsNote(coinsUi)}
@@ -274,6 +287,7 @@ ${quests.coinsNote(coinsUi)}
store.badgeHolders(s.db, quest.reward.badge.id),
store.badgeHolders(s.db, quest.reward.badge.id),
]);
]);
const seen = new Map(rows.map((x) => [Number(x.step), Number(x.count)]));
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, {
return page(req, res, {
index: quest.status === 'active', cache: mine ? null : PUBLIC_CACHE,
index: quest.status === 'active', cache: mine ? null : PUBLIC_CACHE,
title: quest.title, description: quest.why || `${quest.title}: a quest an OpenVibe app made.`,
title: quest.title, description: quest.why || `${quest.title}: a quest an OpenVibe app made.`,
@@ -281,12 +295,15 @@ ${quests.coinsNote(coinsUi)}
@@ -281,12 +295,15 @@ ${quests.coinsNote(coinsUi)}
body: html`<h1>${quest.title}</h1>
body: html`<h1>${quest.title}</h1>
${quest.why ? html`<p class="lede">${quest.why}</p>` : ''}
${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 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>
<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>
<h2>The reward</h2>
<p>The badge <strong>${quest.reward.badge.name}</strong>. App quests never pay OpenCoins.</p>
<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>
<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>`,
<p><a href="/quests">All quests</a></p>`,
});
});
});
});
@@ -310,23 +327,31 @@ ${completion ? html`<div class="notice" role="status">You completed this quest o
@@ -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 { 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="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 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>
<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>
<h2>The reward</h2>
<p>${quests.reward(quest, coinsUi)}</p>
<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>
<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)}
${quests.coinsNote(coinsUi)}
<p><a href="/quests">All quests</a></p>`,
<p><a href="/quests">All quests</a></p>`,
- }));
+ });
+ });
});
});
// ── A series: its quests, and which of them the person has done ──
// ── A series: its quests, and which of them the person has done ──
@@ -337,8 +362,17 @@ ${quests.coinsNote(coinsUi)}
@@ -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>`,
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);
}, 404);
const mine = signedIn(req) && SUBJECT_RE.test(req.viewer.subject);
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, {
return page(req, res, {
index: true, cache: mine ? null : PUBLIC_CACHE,
index: true, cache: mine ? null : PUBLIC_CACHE,
title: series.title,
title: series.title,
@@ -347,8 +381,8 @@ ${quests.coinsNote(coinsUi)}
@@ -347,8 +381,8 @@ ${quests.coinsNote(coinsUi)}
body: html`<h1>${series.title}</h1>
body: html`<h1>${series.title}</h1>
<p class="lede">${series.why}</p>
<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>
<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)}
${quests.coinsNote(coinsUi)}
<p><a href="/quests">All quests</a>${mine ? html` · <a href="/me">Your quest log</a>` : ''}</p>`,
<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>
@@ -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>
<p class="muted">Signed in as <strong>${req.viewer.displayName || req.viewer.username || 'you'}</strong>. One log across every OpenVibe site.</p>
${yourStuff(req)}
${yourStuff(req)}
<h2>In progress</h2>
<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>`}
<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>
<h2>Completed</h2>
${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
const c = done.get(q.id);
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>`}
})}</ul>` : html`<p class="muted">No quests completed yet.</p>`}
<h2>Your badges</h2>
<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>
${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>
@@ -11,20 +11,60 @@ const art = require('./badge-art');
@@ -11,20 +11,60 @@ const art = require('./badge-art');
const { html } = require('./html');
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 } = {}) {
function reward(quest, { coinsEnabled = false } = {}) {
const badge = quest.reward.badge.name;
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.
// 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 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 (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>`;
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. */
/** A badge's colour, fixed by its quest id, so a badge looks the same wherever it is shown. */
function hueOf(id) {
function hueOf(id) {
let h = 0;
let h = 0;
@@ -82,15 +122,48 @@ function earnedBy(n) {
@@ -82,15 +122,48 @@ function earnedBy(n) {
return n > 0 ? html`<p class="quest-count">Earned by ${n} ${n === 1 ? 'person' : 'people'}</p>` : '';
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">
<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>
<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>
<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) : ''}
${counts ? earnedBy(counts.get(quest.id) || 0) : ''}
</div>
</div>
</li>`;
</li>`;
@@ -106,42 +179,70 @@ function seriesCard(series, { coinsEnabled = false, counts = null } = {}) {
@@ -106,42 +179,70 @@ function seriesCard(series, { coinsEnabled = false, counts = null } = {}) {
<div class="quest-main">
<div class="quest-main">
<a class="quest-title" href="/series/${series.id}">${series.title}</a>
<a class="quest-title" href="/series/${series.id}">${series.title}</a>
<p class="quest-why">${series.why}</p>
<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>` : ''}
${done > 0 ? html`<p class="quest-count">${done} ${done === 1 ? 'badge' : 'badges'} earned in this series</p>` : ''}
</div>
</div>
</li>`;
</li>`;
}
}
/** A quest a developer app made, in a list (the full index, /quests): its badge, what it is, and where to do it. */
/** 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">
<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>` : ''}
${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>` : ''}
${q.url ? html`<p class="quest-count"><a href="${q.url}" rel="nofollow noopener">Where to do it</a></p>` : ''}
</div>
</div>
</li>`;
</li>`;
}
}
/** The catalog grouped by the site whose events each quest counts (a folded catalog shows each series as one card). */
/** 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.
// 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 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);
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)}">
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>
<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>`)}`;
</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>
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. */
/** 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
@@ -155,4 +256,4 @@ ${dailyCap} OpenCoins per person per day and ${globalDailyCap} for everyone toge
never money — and never Vibes.</p>`;
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 };
@@ -165,7 +165,7 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
@@ -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.
// 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 });
const page = await t.get('/series/live-setup', { as: u });
assert.strictEqual(page.status, 200);
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"'));
assert.ok(page.text.includes('href="/quests/live-setup-emote"'));
const home = await t.get('/');
const home = await t.get('/');
assert.ok(home.text.includes('href="/series/live-setup"'), 'the home page links the series');
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
@@ -258,7 +258,7 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
assert.strictEqual(settleIn.state, 'in_progress');
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]]);
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 });
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');
assert.ok(page.text.includes('First Broadcast'), 'the badge it earned');
});
});
} finally { await t.close(); }
} finally { await t.close(); }
@@ -27,7 +27,7 @@ const BUDGETS = {
@@ -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
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
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)
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
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
externalFiles: 0, // 0
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -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); });
@@ -182,7 +182,9 @@ const { render } = require('../server/render/html');
@@ -182,7 +182,9 @@ const { render } = require('../server/render/html');
const mine = await t.get('/me', { as: kim });
const mine = await t.get('/me', { as: kim });
assert.strictEqual(mine.status, 200);
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('First Broadcast'), 'the badge');
assert.ok(mine.text.includes('Completed'), 'the completion');
assert.ok(mine.text.includes('Completed'), 'the completion');
assert.ok(mine.text.includes(`/api/v1/profiles/${kim.subject}/badges`), 'it says what is public');
assert.ok(mine.text.includes(`/api/v1/profiles/${kim.subject}/badges`), 'it says what is public');