openvibers/openvibe.reviews Commits
Reviews: a method box, transaction evidence and sources side by side (#157)
Changes
nnew file mode 100644@@@ -0,0 +1,20 @@++/* Entity-page outcomes from the market research (2026-10-31): each source keeps its own count beside the others,++ and every review row carries its provenance. The rows and the fold are openvibe-shared/kit, loaded on entity++ pages only; these rules use the site's theme tokens, so no colour is introduced here. */++.rv-source-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); }++.rv-source-card { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 12px 14px; border: 1px solid var(--rv-line); border-radius: 12px; background: var(--rv-panel); overflow-wrap: anywhere; }++.rv-source-head { margin: 0; font-size: 1rem; line-height: 1.3; }++.rv-source-figure { margin: 0; }++.rv-source-figure .rv-big { font-size: 1.45rem; }++.rv-source-card details { margin-top: 4px; }++.rv-source-card details p { margin: 6px 0 0; }++.rv-review-list .ovk-row:target { outline: 2px solid var(--rv-accent); outline-offset: 2px; }++/* A review row's provenance is a full sentence, not a one-line list subtitle: let it wrap on a phone. */++.rv-review-list .ovk-row-top { flex-wrap: wrap; }++.rv-review-list .ovk-row-top strong { white-space: normal; overflow: visible; text-overflow: clip; }++.rv-review-list .ovk-row-top time { margin-left: 0; }++.rv-review-list .ovk-row-sub { align-items: flex-start; }++.rv-review-list .ovk-row-sub > span:first-child { white-space: normal; overflow: visible; text-overflow: clip; }++/* The method's rows are sentences too; without this the kit's one-line rule ellipsises them. */++.rv-method .ovk-row-sub { align-items: flex-start; }++.rv-method .ovk-row-sub > span:first-child { white-space: normal; overflow: visible; text-overflow: clip; }@@@ -299,6 +299,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limiconst signedIn = !!req.actor.subject;send(req, res, status, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null, members, elsewhere, seen, records, reviewError, reviewValues }), {decision: p.decision, title: e.name, cache: discussion.state === 'ok' || signedIn ? null : 'public', path: svc.entityPath(e),++ styles: ['kit.css'], localStyles: ['css/outcomes.css'],description: e.description && !svc.LOCAL_KINDS.has(e.kind) ? e.description: `${e.name}${e.description ? ` (${e.description})` : ''}: ${members.stats.count ? `${members.stats.average} out of 5 from ${members.stats.count} OpenVibe member review${members.stats.count === 1 ? '' : 's'}, ` : ''}review signals${p.sources.length ? ` from ${p.sources.map((s) => s.name || s.key).join(', ')}` : ''}, and every other review site in one place.`,canonical: p.entity.url, jsonLd: structuredData(p, config, members),@@@ -9,8 +9,11 @@*/const ssr = require('openvibe-publishing/ssr');const showcase = require('openvibe-shared/showcase');++const kit = require('openvibe-shared/kit');// The short home (the owner's rule, 2026-10-30): pick blocks and folds.const { pickBlock, foldBlock } = require('./short');++// Per-source counts on an entity page use the same method as the page's aggregate.++const { computeAggregate } = require('../reviews/aggregate');const { html, raw } = ssr;const t = (v) => raw(ssr.timeTag(v));@@@ -18,6 +21,29 @@ const tl = (v) => raw(ssr.timeTag(v, { label: v ? new Date(v).toISOString().replconst e = encodeURIComponent;const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));++const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];++/** A date people read: "20 Sep 2026". The page never prints a raw timestamp. */++function dateWords(v) {++ if (!v) return '';++ const d = new Date(v);++ if (Number.isNaN(d.getTime())) return '';++ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;++}++++// Steam's language field is a slug; people read a language name.++const LANGUAGE_LABEL = {++ english: 'English', german: 'German', french: 'French', italian: 'Italian', koreana: 'Korean', spanish: 'Spanish',++ schinese: 'Simplified Chinese', tchinese: 'Traditional Chinese', russian: 'Russian', thai: 'Thai', japanese: 'Japanese',++ portuguese: 'Portuguese', polish: 'Polish', danish: 'Danish', dutch: 'Dutch', finnish: 'Finnish', norwegian: 'Norwegian',++ swedish: 'Swedish', hungarian: 'Hungarian', czech: 'Czech', romanian: 'Romanian', turkish: 'Turkish', brazilian: 'Brazilian Portuguese',++ bulgarian: 'Bulgarian', greek: 'Greek', ukrainian: 'Ukrainian', vietnamese: 'Vietnamese', indonesian: 'Indonesian', latam: 'Latin American Spanish',++};++function languageLabel(v) {++ const s = String(v || '').trim();++ if (!s) return '';++ return LANGUAGE_LABEL[s.toLowerCase()] || s.charAt(0).toUpperCase() + s.slice(1);++}++const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other',employer: 'Employer', landlord: 'Landlord', restaurant: 'Restaurant', business: 'Local business', hosting: 'Hosting provider', app: 'App', vehicle: 'Vehicle',community: 'Community', platform: 'Platform' };@@@ -64,16 +90,57 @@ function signalValue(s) {}}--function trustFacts(trust) {++function trustBits(trust = {}) {const bits = [];-- if (trust.steam_purchase === true) bits.push('bought on Steam');-- if (trust.steam_purchase === false) bits.push('not bought on Steam');-- if (trust.received_for_free === true) bits.push('received for free');-- if (trust.written_during_early_access === true) bits.push('written during early access');-- if (trust.verified_purchase === true) bits.push('verified purchase');-- if (trust.playtime_at_review_min != null) bits.push(`${n(Math.round(trust.playtime_at_review_min / 60))} h played at review`);-- if (trust.language) bits.push(`language: ${trust.language}`);-- return bits.join(' · ');++ if (trust.steam_purchase === true) bits.push({ key: 'purchase', text: 'bought on Steam' });++ if (trust.steam_purchase === false) bits.push({ key: 'not-purchase', text: 'not bought on Steam' });++ if (trust.received_for_free === true) bits.push({ key: 'free', text: 'received for free' });++ if (trust.written_during_early_access === true) bits.push({ key: 'early', text: 'written during early access' });++ if (trust.verified_purchase === true) bits.push({ key: 'verified', text: 'verified purchase' });++ if (trust.playtime_at_review_min != null) bits.push({ key: 'playtime', text: `${n(Math.round(trust.playtime_at_review_min / 60))} h played` });++ if (trust.language) bits.push({ key: 'language', text: languageLabel(trust.language) });++ return bits;++}++function trustFacts(trust) { return trustBits(trust).map((b) => b.text).join(' · '); }++++/** The one-word name a source is known by here ("Steam", "NYC Health"), for a line beside a review. */++function sourceShortName(name, key) {++ if (name) {++ const s = String(name);++ const steam = s.match(/^(.+?)\s+user review signals:/i);++ if (steam) return steam[1];++ const colon = s.indexOf(':');++ if (colon > 0) return s.slice(0, colon).trim();++ return s.length > 42 ? `${s.slice(0, 39)}…` : s;++ }++ // A source with no recorded name still gets a human label, never its raw key on a page.++ const k = String(key || '');++ if (/^steam-reviews-/.test(k)) return 'Steam';++ if (k === 'cfpb-complaints-by-company') return 'CFPB';++ if (k === 'nyc-hpd-open-violations') return 'NYC HPD';++ if (k === 'nyc-restaurant-inspections') return 'NYC Health';++ return k.replace(/-/g, ' ');++}++++/** A purchase on the source's own record, never a check of ours. A free copy is not a purchase. */++function purchased(s) {++ const t = (s && s.trust) || {};++ return (t.verified_purchase === true || t.steam_purchase === true) && t.received_for_free !== true;++}++function receivedFree(s) { return !!((s && s.trust) || {}).received_for_free; }++++/** The transaction as the source recorded it: evidence beside the review, not a verdict on it. */++function evidenceBadge(s, source) {++ if (receivedFree(s)) return kit.badge('Received for free', 'warn');++ if (purchased(s)) return kit.badge(`Verified purchase · ${sourceShortName(source && source.name, s && s.source_key)}`, 'ok');++ return '';++}++++/** The facts beside a review that are not already the evidence badge. */++function reviewFacts(s) {++ return trustBits(s.trust)++ .filter((b) => !(b.key === 'purchase' || b.key === 'free' || b.key === 'verified'))++ .map((b) => b.text);}function errorBody({ status, title, message }) {@@@ -159,7 +226,7 @@ function paragraphs(text) {function memberReviewItem(r, { entity, editor }) {return html`<li><article class="rv-mr" id="${r.id}"><header>${stars(r.rating)} <b>${r.title}</b></header>--<p class="rv-muted">${r.author_name}${r.relation ? html` · ${r.relation}` : ''} · ${t(r.created_at)}${r.edited ? ' (edited)' : ''}</p>++<p class="rv-muted">Written here by ${r.author_name}${r.relation ? html` · ${r.relation}` : ''} · ${dateWords(r.created_at)}${r.edited ? ' (edited)' : ''}</p><div class="rv-mr-body">${paragraphs(r.body)}</div>${r.mine ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><button type="submit" class="rv-link-button">Delete my review</button></form>`: editor ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> <button type="submit" class="rv-link-button">Remove</button></form>` : ''}@@@ -204,7 +271,7 @@ function elsewhereSection(links, entity, { seen = {}, actor = null, editor = fal/** One site's rating as a member saw it, with when, how many members saw it, and a way to take it down. */function seenLine(r, { entity, actor, editor }) {const mine = actor && actor.subject && r.seen_by === actor.subject;-- return html`<span class="rv-seen"><b>${String(r.value)}</b> <span class="rv-muted">out of ${String(r.best)}${r.count != null ? ` from ${n(r.count)} rating${r.count === 1 ? '' : 's'}` : ''} · seen ${t(r.seen_at)}${r.members > 1 ? ` (${r.members} members)` : ''}</span>${mine || editor ? html` <form method="post" action="/save/${r.id}/remove" class="rv-inline"><input type="hidden" name="entity" value="${entity.slug}">${mine ? '' : html`<input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> `}<button type="submit" class="rv-link-button">${mine ? 'Remove mine' : 'Remove'}</button></form>` : ''}</span>`;++ return html`<span class="rv-seen"><b>${String(r.value)}</b> <span class="rv-muted">out of ${String(r.best)}${r.count != null ? ` from ${n(r.count)} rating${r.count === 1 ? '' : 's'}` : ''} · seen ${dateWords(r.seen_at)}${r.members > 1 ? ` (${r.members} members)` : ''}</span>${mine || editor ? html` <form method="post" action="/save/${r.id}/remove" class="rv-inline"><input type="hidden" name="entity" value="${entity.slug}">${mine ? '' : html`<input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> `}<button type="submit" class="rv-link-button">${mine ? 'Remove mine' : 'Remove'}</button></form>` : ''}</span>`;}/** /save: a member checks the rating another site's page shows (filled in by the Companion, or typed) and saves it. */@@@ -336,7 +403,7 @@ function home({ entities, total, editor, recent = [], kinds = {}, stats = null,<ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul><p><a class="rv-button" href="/review">Write a review</a> <a href="/everywhere">Search every review site</a></p></section>${recent.length ? html`<section class="sc-sec" aria-labelledby="rv-recent-h"><h2 id="rv-recent-h">Latest reviews from members</h2>--<ul class="rv-recent">${recent.map((r) => html`<li><a href="/e/${r.entity.slug}#${r.id}"><span class="rv-card-head"><b>${r.entity.name}</b> <span class="rv-tag">${KIND_LABEL[r.entity.kind]}</span></span>${stars(r.rating)} <b>${r.title}</b><br><span class="rv-muted">${r.body.length > 160 ? `${r.body.slice(0, 157)}…` : r.body}</span></a></li>`)}</ul></section>` : ''}++<ul class="rv-recent">${recent.map((r) => html`<li><a href="/e/${r.entity.slug}#${r.id}"><span class="rv-card-head"><b>${r.entity.name}</b> <span class="rv-tag">${KIND_LABEL[r.entity.kind]}</span></span>${stars(r.rating)} <b>${r.title}</b><br><span class="rv-muted">${r.body.length > 160 ? `${r.body.slice(0, 157)}…` : r.body}</span><br><span class="rv-muted">Written here by ${r.author_name} · ${dateWords(r.created_at)}</span></a></li>`)}</ul></section>` : ''}<section id="entities" class="sc-sec" aria-labelledby="rv-board-h"><h2 id="rv-board-h">Scores from sources</h2>${entities.length ? html`<p class="sc-lede">Each score comes from named sources. Open one to see every signal behind it, when it was read, and how the number was worked out.</p><ul class="rv-board">${entities.map(boardCard)}</ul>`: html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}@@@ -393,6 +460,18 @@ function aboutPage() {<li>With no qualifying signal there is no aggregate. Not zero, not a placeholder: none.</li></ul><p>Each change to the inputs produces a new aggregate revision; the entity history lists them all.</p>++<h2 id="retention">Retention and removal</h2>++<p>No deletion window is set for a signal. When a source updates an item the old signal is marked replaced; when it removes one the signal is withdrawn. Both stay in the entity's history and keep their provenance. An editor can exclude a source or a signal, always with a reason shown on the page. A member can delete their own review, and an editor can remove one with a reason; the reason stays in the editorial log.</p>++<h2 id="checks">What we check</h2>++<ul>++<li>The source, the item, its revision and when it was read are on every signal.</li>++<li>A rating counts only when its source stated the scale and the count; otherwise it is left out, and the page says why.</li>++<li>A source's own tally is counted once; the individual reviews it covers are not added on top.</li>++<li>An editor can exclude a source or a signal only with a reason, shown on the page.</li>++<li>The source's own purchase record is shown beside a review as evidence. It says the reviewer bought it; it does not say the review is true.</li>++</ul>++<p>We do not score how likely a review is to be fake. We do not read review text, IP addresses, devices or writing style, and we have no measured fake-review detection rate: none is published.</p>++<p>There is no invitation or import feed: a review's provenance says it came from a source item, was written here, or is a rating seen on another site by a member.</p><h2 id="summaries">Summaries</h2><p>A summary is text written by an editor (or drafted by OpenVibe.AI and approved by an editor) whose pros and cons each cite the signals they rest on. A summary never carries a rating. If a cited signal is withdrawn or replaced, the summary is flagged on the page and a revised version waits for an editor.</p><h2 id="entities">Entities, merges and splits</h2>@@@ -416,13 +495,25 @@ function aggregateSection(p) {const r = p.aggregate.result;const c = r.components;const byId = new Map([...p.signals, ...p.inactive_signals].map((s) => [s.signal_id, s]));++ // The sample and, where the source recorded one, the purchase evidence inside it. Never a count we did not keep.++ const inComponent = (x) => x.inputs.map((id) => byId.get(id)).filter(Boolean);++ const bought = (x) => inComponent(x).filter(purchased).length;++ const allSteam = (x) => inComponent(x).length > 0 && inComponent(x).every((s) => /^steam-/.test(s.source_key));++ const purchasePhrase = (x) => {++ const k = bought(x);++ if (!k) return '';++ return allSteam(x) ? `${n(k)} bought on Steam` : `${n(k)} with a purchase on record`;++ };++ const ratePurchase = c.rating ? purchasePhrase(c.rating) : '';++ const recPurchase = c.recommendation ? purchasePhrase(c.recommendation) : '';++ // One plain line first; the revision, when it was computed and the method live in the Computation fold.++ const recLine = c.recommendation ? html`<p class="rv-agg"><strong>${c.recommendation.percent}%</strong> recommend · ${n(c.recommendation.total)} review${c.recommendation.total === 1 ? '' : 's'}${recPurchase ? html` · ${recPurchase}` : ''}</p>` : '';++ const rateLine = c.rating ? html`<p class="rv-agg"><strong>${c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best}` : `${c.rating.percent}%`}</strong> · ${n(c.rating.count)} rating${c.rating.count === 1 ? '' : 's'}${ratePurchase ? html` · ${ratePurchase}` : ''}</p>` : '';return html`<section class="rv-aggregate">${head}--<div class="rv-agg-box">--${c.rating ? html`<p class="rv-agg"><strong>${c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best}` : `${c.rating.percent}%`}</strong> from ${n(c.rating.count)} rating${c.rating.count === 1 ? '' : 's'}${c.rating.on_scale ? html` <span class="rv-muted">(${c.rating.percent}% of the best possible rating)</span>` : ''}</p>` : ''}--${c.recommendation ? html`<p class="rv-agg"><strong>${c.recommendation.percent}%</strong> recommend: ${n(c.recommendation.positive)} of ${n(c.recommendation.total)}</p>` : ''}--<p class="rv-muted">Aggregate revision ${String(p.aggregate.revision)}, computed ${tl(p.aggregate.computed_at)} from ${String(r.inputs.length)} signal${r.inputs.length === 1 ? '' : 's'} of ${String(r.sources.length)} source${r.sources.length === 1 ? '' : 's'}, observed ${t(r.observed.earliest)} to ${t(r.observed.latest)}. <a href="/about#method">Method ${r.method}</a>.</p>--</div>--<details><summary>Computation</summary><ul>${r.computation.map((line) => html`<li><code>${line}</code></li>`)}</ul>++<div class="rv-agg-box">${recLine}${rateLine}</div>++<details><summary>Computation</summary>++<p class="rv-muted">Aggregate revision ${String(p.aggregate.revision)}, computed ${dateWords(p.aggregate.computed_at)} from ${String(r.inputs.length)} signal${r.inputs.length === 1 ? '' : 's'} of ${String(r.sources.length)} source${r.sources.length === 1 ? '' : 's'}, observed ${dateWords(r.observed.earliest)} to ${dateWords(r.observed.latest)}. <a href="/about#method">Method ${r.method}</a>.</p>++<ul>${r.computation.map((line) => html`<li><code>${line}</code></li>`)}</ul><p>Inputs: ${r.inputs.map((i, k) => html`${k ? ', ' : ''}<a href="#${i.signal_id}">${i.signal_id}</a>`)}</p>${r.excluded.length ? html`<p>Left out:</p><ul>${r.excluded.map((x) => html`<li><a href="#${x.signal_id}">${x.signal_id}</a>${byId.get(x.signal_id) ? html` (${byId.get(x.signal_id).source_key})` : ''}: ${REASON_LABEL[x.reason] || x.reason}${x.note ? html` — ${x.note}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">Nothing was left out.</p>`}</details></section>`;@@@ -506,17 +597,109 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>${form}</section>`;}++/**++ * The method box every entity page publishes (the market research's first outcome): where the reviews come from,++ * how they are collected and kept, how one is removed, the checks we really run, and the detection rate we do not++ * have. The source names and count are the page's own; the rest is how server/reviews/aggregate.js and the signal++ * tables work, so the page never claims a number or a policy we do not hold.++ */++function methodBox(p) {++ const sources = p.sources.length++ ? `${n(p.sources.length)} source${p.sources.length === 1 ? '' : 's'} on this page: ${p.sources.map((s) => sourceShortName(s.name, s.key)).join(', ')}.`++ : 'No source has stated anything about this entity yet.';++ const rows = [++ kit.row({ title: 'Where reviews come from', sub: `${sources} Members' reviews are written here and kept apart. Every review says where it came from: a source item, written here, or a rating seen on another site by a member. We have no invitation or import feed, so no review claims one.` }),++ kit.row({ title: 'How we collect them', sub: 'OpenVibe.Sources retrieves each source item. We keep the source, the item, its revision and when it was read; the review text stays with the source.' }),++ kit.row({ title: 'How long we keep them', sub: 'No deletion window is set. A replaced or withdrawn signal stays in this page\'s history.' }),++ kit.row({ title: 'How we remove one', sub: 'A source that removes an item withdraws its signal; a changed item replaces the old one. An editor can exclude a signal or a source, and the reason is shown beside it.' }),++ kit.row({ title: 'What we check', sub: 'The source, item and read time on every signal; a rating\'s stated scale and count; one tally per source; an editor\'s exclusion and its reason; the source\'s own purchase record.' }),++ kit.row({ title: 'What we do not measure', sub: 'No fake-review detection rate: none is measured, so none is published. We do not read review text, IP addresses, devices or writing style.' }),++ ];++ return raw(kit.fold({++ id: 'method', title: 'How this page\'s numbers are made', meta: 'method, retention, checks',++ body: `<div class="rv-method">${kit.list(rows, { flush: true })}</div><p class="rv-muted"><a href="/about#checks">The full method, retention and checks</a></p>`,++ }));++}++++/** One source's own reviews, counted with the same method as the page's aggregate. */++function sourceCard(src, signals) {++ // The signal view says `recommended: true | false`; computeAggregate reads the stored 1 | 0.++ const rows = signals.map((s) => ({ ...s, id: s.signal_id, recommended: s.recommended === true ? 1 : s.recommended === false ? 0 : s.recommended }));++ const result = computeAggregate(rows);++ const c = result && result.components;++ const figures = [];++ if (c && c.rating) figures.push(html`<p class="rv-source-figure"><span class="rv-big">${c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best}` : `${c.rating.percent}%`}</span> <span class="rv-muted">from ${n(c.rating.count)} rating${c.rating.count === 1 ? '' : 's'}</span></p>`);++ if (c && c.recommendation) figures.push(html`<p class="rv-source-figure"><span class="rv-big">${c.recommendation.percent}%</span> <span class="rv-muted">recommend from ${n(c.recommendation.total)} review${c.recommendation.total === 1 ? '' : 's'}</span></p>`);++ const readAt = signals.map((s) => s.provenance && s.provenance.retrieved_at).filter(Boolean).sort().pop();++ const limits = [];++ if (src.stale) limits.push('the source was stale when last checked');++ if (src.license_note) limits.push(`licence: ${src.license_note}`);++ if (src.trust && src.trust.limitation) limits.push(src.trust.limitation.value);++ if (src.trust && src.trust.verification) limits.push(`verification: ${src.trust.verification.value}`);++ const site = safeUrl(src.homepage_url);++ return html`<li class="rv-source-card">++<p class="rv-source-head"><b>${src.name || sourceShortName(null, src.key)}</b></p>++${figures.length ? figures : html`<p class="rv-muted">No qualifying signal: the source did not state a scale and a count. ${n(signals.length)} signal${signals.length === 1 ? '' : 's'}.</p>`}++<p class="rv-muted">Read${readAt ? html` ${dateWords(readAt)}` : ''}${site ? html` · <a href="${site}" rel="nofollow noopener">source site</a>` : ''}</p>++${src.trust && src.trust.aggregate ? html`<p>${raw(kit.badge('Excluded from the aggregate', 'warn'))} <span class="rv-muted">${src.trust.aggregate.note || 'an editor excluded this source'}</span></p>` : ''}++${limits.length ? html`<p class="rv-muted">${limits.join(' · ')}</p>` : ''}++${src.terms_note ? html`<details class="rv-source-terms"><summary>Terms</summary><p class="rv-muted">${src.terms_note}</p></details>` : ''}++</li>`;++}++++/** Every source's own count side by side, so disagreement stays visible instead of being averaged away. */++function sourcesSection(p) {++ if (!p.sources.length) return '';++ const byKey = new Map();++ for (const s of p.signals) {++ if (!byKey.has(s.source_key)) byKey.set(s.source_key, []);++ byKey.get(s.source_key).push(s);++ }++ return html`<section id="sources" aria-labelledby="sources-h"><h2 id="sources-h">Sources side by side</h2>++<p class="rv-muted">Each source's own reviews and its own count. A source with more reviews does not outweigh another.</p>++<ul class="rv-source-cards">${p.sources.map((src) => sourceCard(src, byKey.get(src.key) || []))}</ul>++${p.entity_trust.limitation ? html`<p class="rv-limits">${p.entity_trust.limitation.value}</p>` : ''}++<p class="rv-muted">Coverage is what the sources above returned, nothing more: a source that samples recent reviews, one language or one shop describes that sample, not everyone's opinion.${p.open_corrections ? ` ${p.open_corrections} correction(s) are waiting for an editor.` : ''}</p>++</section>`;++}++++/** Every source review as a row: its value, the transaction the source recorded, and its provenance. */++function sourceReviewRows(p) {++ const byKey = new Map(p.sources.map((s) => [s.key, s]));++ return p.signals.map((s) => {++ const src = byKey.get(s.source_key);++ const badge = evidenceBadge(s, src);++ const facts = reviewFacts(s);++ const reviewed = s.source_published_at ? kit.when(s.source_published_at) : null;++ const read = dateWords(s.provenance && s.provenance.retrieved_at);++ const provenance = html`${sourceShortName(src && src.name, s.source_key)}${read ? html` · read ${read}` : ''}${facts.length ? html` · ${facts.join(' · ')}` : ''}`;++ const link = safeUrl(s.canonical_url) || safeUrl(src && src.homepage_url);++ return kit.row({++ attrs: { id: s.signal_id, 'data-item-id': s.source_item_id, 'data-item-revision': String(s.item_revision) },++ title: signalValue(s),++ badge,++ meta: reviewed ? reviewed.text : '',++ metaIso: reviewed ? reviewed.iso : '',++ subHtml: String(provenance),++ trail: link ? `<a href="${ssr.escapeHtml(link)}" rel="nofollow noopener">Open</a>` : '',++ });++ });++}++++function sourceReviewsSection(p) {++ if (!p.signals.length) return '';++ const rows = sourceReviewRows(p);++ return raw(kit.section({++ id: 'source-reviews', title: 'Reviews from sources', count: p.signals.length,++ note: 'Each row keeps its source, when it was read, and whether the source recorded a purchase. A purchase on the source\'s record is evidence the reviewer bought it, not that the review is true. The words stay with the people who wrote them.',++ cls: 'rv-review-list', body: kit.list(rows),++ }));++}++function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], seen = {}, records = null, reviewError = null, reviewValues = null }) {const en = p.entity;// Something of its own to show (members' reviews or official records): the empty source sections stay out.const own = (members && members.stats.count) || (records && (records.complaints || records.housing || records.inspections.length));-- const limits = [];-- for (const s of p.sources) {-- if (s.stale) limits.push(html`${s.name || s.key}: the source was stale when last checked${s.last_success_at ? html` (last successful fetch ${t(s.last_success_at)})` : ''}.`);-- if (s.trust.limitation) limits.push(html`${s.name || s.key}: ${s.trust.limitation.value}`);-- if (s.trust.verification) limits.push(html`${s.name || s.key} verification: ${s.trust.verification.value}`);-- }-- if (p.entity_trust.limitation) limits.push(html`${p.entity_trust.limitation.value}`);const otherNames = p.aliases.filter((a) => a.type === 'name' && a.value !== en.name);return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name }])}<article class="rv-entity">@@@ -527,16 +710,11 @@ ${records ? recordsSection(records, en) : ''}${members ? memberSection(members, { entity: en, actor, editor: p.editor, error: reviewError, values: reviewValues }) : ''}${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}${p.aggregate || p.signals.length || p.sources.length || !own ? aggregateSection(p) : ''}++${methodBox(p)}${p.summary || p.editor || !own ? summarySection(p) : ''}--${p.signals.length || p.sources.length || p.inactive_signals.length ? html`<section><h2 id="signals">Signals from sources (${String(p.signals.length)})</h2>--${p.signals.length ? signalTable(p.signals, { caption: 'Signals from sources, with provenance' }) : html`<p class="rv-muted">No source has stated anything about this entity yet.</p>`}++${sourcesSection(p)}++${sourceReviewsSection(p)}${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}--</section>--<section><h2 id="limits">Sources and limitations</h2>--${p.sources.length ? html`<ul>${p.sources.map((s) => html`<li><strong>${s.name || s.key}</strong>${safeUrl(s.homepage_url) ? html` (<a href="${safeUrl(s.homepage_url)}" rel="nofollow noopener">site</a>)` : ''}${s.terms_note ? html`<br><span class="rv-muted">Terms: ${s.terms_note}</span>` : ''}${s.trust.aggregate ? html`<br><span class="rv-tag">excluded from the aggregate</span> ${s.trust.aggregate.note || ''}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No sources yet.</p>`}--${limits.length ? html`<ul class="rv-limits">${limits.map((l) => html`<li>${l}</li>`)}</ul>` : ''}--<p class="rv-muted">Coverage is what the sources above returned, nothing more: a source that samples recent reviews, one language or one shop describes that sample, not everyone's opinion.${p.open_corrections ? ` ${p.open_corrections} correction(s) are waiting for an editor.` : ''}</p>--</section>` : ''}${elsewhereSection(elsewhere, en, { seen, actor, editor: p.editor })}${otherNames.length || p.links.length ? html`<section><h2>Also known as and related</h2>${otherNames.length ? html`<p>Also known as ${otherNames.map((a, i) => html`${i ? ', ' : ''}${a.value}`)}</p>` : ''}@@@ -58,8 +58,8 @@ t('AggregateRating JSON-LD is absent without signals, present only while signalsassert.deepStrictEqual(product.aggregateRating, { '@type': 'AggregateRating', ratingValue: 4.4, ratingCount: 321, bestRating: 5 });assert.ok(!ld.some((x) => x['@type'] === 'Review'), 'no Review without a published summary');const visible = noScripts(page.text);-- assert.match(visible, /4\.4 \/ 5<\/strong> from 321 ratings/);-- assert.match(visible, new RegExp(item.id));++ assert.match(visible, /<strong>4\.4 \/ 5<\/strong> · 321 ratings/);++ assert.match(visible, new RegExp(`data-item-id="${item.id}"`));await deliver(h, sourcesEvent('sources.item.removed', h.sources.remove(item.id, 'takedown'), { reason: 'takedown' }));page = await req(h, 'GET', `/e/${e.slug}`);@@@ -80,7 +80,7 @@ t('a recommendation-only entity: AggregateRating is the real share and count, onconst page = await req(h, 'GET', `/e/${e.slug}`);const game = jsonLd(page.text).find((x) => x['@type'] === 'VideoGame');assert.deepStrictEqual(game.aggregateRating, { '@type': 'AggregateRating', ratingValue: 75, ratingCount: 4, bestRating: 100, worstRating: 0 });-- assert.match(noScripts(page.text), /75%<\/strong> recommend: 3 of 4/);++ assert.match(noScripts(page.text), /75%<\/strong> recommend · 4 reviews · 4 bought on Steam/);assert.ok(!/★/.test(page.text));} finally { await h.stop(); }});@@@ -105,7 +105,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots followassert.strictEqual(robots(page.text), 'index, follow');assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');const text = noScripts(page.text);-- for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Retrieved', 'steam-reviews-portal-2', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);++ for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Steam · read', 'Steam', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));sm = await req(h, 'GET', '/sitemaps/entities-1.xml');assert.ok(sm.text.includes(`http://reviews.test/e/${e.slug}`));nnew file mode 100644@@@ -0,0 +1,172 @@++'use strict';++/**++ * The market research's outcomes for Reviews (2026-10-30), built 2026-10-31: a method box on every entity page, a++ * verified transaction shown as evidence, a rating with its sample, provenance on every card, the checks we really++ * run (and no detection rate we cannot measure), and sources side by side with their own counts.++ *++ * Outcome 5 (a removal changelog) is not here: the database has no removal changelog table.++ */++const assert = require('assert');++const {++ boot, req, suite, steamItem, productItem, baseItem, importItem, createEntity, userToken, cookieFor, subject,++} = require('./helpers');++++const t = suite('outcomes');++const BODY = 'The staff were kind and the work was steady, but the rota changed at the last minute more than once.';++++function addSteam(h) {++ h.sources.addSource({ key: 'steam-reviews-portal-2', name: 'Steam user review signals: Portal 2 (app 620)', homepage_url: 'https://store.steampowered.com/app/620/', category: 'reviews' });++}++function addShop(h, key = 'shop-example', name = 'Example shop ratings', url = 'https://shop.example/') {++ h.sources.addSource({ key, name, homepage_url: url, category: 'reviews' });++}++function signal(source, identity, fields) {++ return baseItem({ source, kind: 'review_signal', identity, fields });++}++async function portal(h, aliases = [{ type: 'source', value: 'steam-reviews-portal-2' }]) {++ return await createEntity(h, { name: 'Portal 2', kind: 'game', aliases });++}++++t('outcome 1: the method box is on every entity page and names that page\'s own sources', async () => {++ const h = await boot();++ try {++ addSteam(h);++ await importItem(h, steamItem({ votedUp: true }));++ const e = await portal(h);++ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;++ assert.match(page, /<details class="ovk-fold" id="method"/, 'the method box');++ assert.match(page, /How this page(?:'|')s numbers are made/);++ assert.match(page, /Where reviews come from/);++ assert.match(page, /1 source on this page: Steam/, 'the source is the page\'s own');++ assert.match(page, /Members(?:'|') reviews are written here and kept apart/);++ assert.match(page, /How we collect them/);++ assert.match(page, /How long we keep them/);++ assert.match(page, /No deletion window is set/);++ assert.match(page, /How we remove one/);++ assert.match(page, /no invitation or import feed/, 'we say which origins we do not have');++ assert.match(page, /href="\/about#checks"/);++++ // An entity with no source at all still publishes the method, and says so rather than borrowing a source.++ const empty = await createEntity(h, { name: 'Nothing Yet', kind: 'other' });++ const emptyPage = (await req(h, 'GET', `/e/${empty.slug}`)).text;++ const box = emptyPage.slice(emptyPage.indexOf('id="method"'), emptyPage.indexOf('</details>', emptyPage.indexOf('id="method"')));++ assert.match(box, /No source has stated anything about this entity yet/);++ assert.ok(!box.includes('Steam'), 'no source is named on a page that has none');++++ const about = (await req(h, 'GET', '/about')).text;++ assert.match(about, /<h2 id="retention">Retention and removal<\/h2>/);++ assert.match(about, /No deletion window is set/);++ assert.match(about, /<h2 id="checks">What we check<\/h2>/);++ } finally { await h.stop(); }++});++++t('outcome 2: a purchase the source recorded is shown as evidence, never as truth', async () => {++ const h = await boot();++ try {++ addSteam(h);++ await importItem(h, steamItem({ votedUp: true })); // bought on Steam++ await importItem(h, signal('steam-reviews-portal-2', 'not-bought', { voted_up: true, steam_purchase: false, received_for_free: false }));++ await importItem(h, signal('steam-reviews-portal-2', 'free-copy', { voted_up: true, steam_purchase: true, received_for_free: true }));++ const e = await portal(h);++ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;++ assert.strictEqual((page.match(/Verified purchase · Steam/g) || []).length, 1, 'one verified purchase, not the false or free ones');++ assert.match(page, /Received for free/, 'a free copy is labelled as such, not as a purchase');++ assert.match(page, /not bought on Steam/, 'the source\'s no-purchase record stays a fact');++ assert.match(page, /evidence the reviewer bought it, not that the review is true/);++ } finally { await h.stop(); }++});++++t('outcome 3: every rating carries its sample, and the purchases inside the sample are counted', async () => {++ const h = await boot();++ try {++ addSteam(h);++ await importItem(h, steamItem({ votedUp: true }));++ await importItem(h, steamItem({ votedUp: true }));++ await importItem(h, signal('steam-reviews-portal-2', 'nay', { voted_up: false, steam_purchase: false, received_for_free: false }));++ const game = await portal(h);++ const page = (await req(h, 'GET', `/e/${game.slug}`)).text;++ assert.match(page, /<strong>66\.7%<\/strong> recommend · 3 reviews · 2 bought on Steam/);++++ addShop(h);++ await importItem(h, productItem({ value: 4.2, best: 5, count: 120, url: 'https://shop.example/p/portal-2' }));++ const product = await createEntity(h, { name: 'Widget 3000', kind: 'product', aliases: [{ type: 'source', value: 'shop-example' }] });++ const shopPage = (await req(h, 'GET', `/e/${product.slug}`)).text;++ assert.match(shopPage, /<strong>4\.2 \/ 5<\/strong> · 120 ratings/);++++ // A members' rating carries its own sample too.++ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));++ const employer = await createEntity(h, { name: 'Acme Corp', kind: 'employer' });++ assert.strictEqual((await req(h, 'POST', `/e/${employer.slug}/reviews`, { cookie: ana, form: { rating: '4', title: 'Steady', body: BODY } })).status, 303);++ assert.match((await req(h, 'GET', `/e/${employer.slug}`)).text, /from 1 review written here by people with an OpenVibe account/);++++ // The front page's score cards keep the count too.++ assert.match((await req(h, 'GET', '/')).text, /120 ratings/);++ } finally { await h.stop(); }++});++++t('outcome 4: every card says where the review came from and when', async () => {++ const h = await boot();++ try {++ addSteam(h);++ await importItem(h, steamItem({ votedUp: true }));++ const e = await portal(h);++ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));++ assert.strictEqual((await req(h, 'POST', `/e/${e.slug}/reviews`, { cookie: ana, form: { rating: 4, title: 'Still good', body: BODY, relation: 'Played it' } })).status, 303);++ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;++ assert.match(page, /Steam · read 20 Sep 2026 · 10 h played · English/, 'a source review names its source, when it was read, and its facts');++ assert.match(page, /data-item-id="itm_[0-9A-Z]+"/, 'the item id is kept as a data attribute');++ assert.ok(!/from a source item itm_/.test(page), 'the raw item id is not read by people');++ assert.match(page, /<time datetime="2026-09-01T00:00:00.000Z">/, 'the source date is on the row');++ assert.match(page, /<article class="rv-mr" id="mrv_[0-9A-Z]{26}">[\s\S]{0,700}Written here by ana · Played it · /, 'a member review keeps its origin, author, relation and date');++ assert.match((await req(h, 'GET', '/')).text, /Written here by ana · /, 'the home card keeps it too');++ } finally { await h.stop(); }++});++++t('outcome 6: the checks we use are published, and the detection rate we do not have is not claimed', async () => {++ const h = await boot();++ try {++ addSteam(h);++ await importItem(h, steamItem({ votedUp: true }));++ const e = await portal(h);++ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;++ assert.match(page, /<strong>What we check<\/strong>/);++ assert.match(page, /The source, item and read time on every signal/);++ assert.match(page, /stated scale and count/);++ assert.match(page, /one tally per source/);++ assert.match(page, /the source's own purchase record/);++ assert.match(page, /No fake-review detection rate: none is measured, so none is published/);++ assert.match(page, /We do not read review text, IP addresses, devices or writing style/);++ assert.ok(!/\d+(?:\.\d+)?% (?:accuracy|precision|recall)/i.test(page), 'no unmeasured detection rate');++++ const about = (await req(h, 'GET', '/about')).text;++ assert.match(about, /We do not score how likely a review is to be fake/);++ assert.match(about, /no measured fake-review detection rate: none is published/);++ } finally { await h.stop(); }++});++++t('outcome 7: sources sit side by side, each with its own count', async () => {++ const h = await boot();++ try {++ addSteam(h);++ addShop(h);++ await importItem(h, steamItem({ votedUp: true }));++ await importItem(h, steamItem({ votedUp: false }));++ await importItem(h, productItem({ value: 4.2, best: 5, count: 120, url: 'https://shop.example/p/portal-2' }));++ const e = await portal(h, [{ type: 'source', value: 'steam-reviews-portal-2' }, { type: 'source', value: 'shop-example' }]);++ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;++ assert.match(page, /<h2 id="sources-h">Sources side by side<\/h2>/);++ const start = page.indexOf('<ul class="rv-source-cards">');++ const end = page.indexOf('</ul>', start);++ const cards = page.slice(start, end);++ assert.strictEqual((cards.match(/<li class="rv-source-card">/g) || []).length, 2, 'one card per source');++ const shopAt = cards.indexOf('Example shop ratings');++ const steamAt = cards.indexOf('Steam user review signals');++ assert.ok(shopAt >= 0 && steamAt >= 0, 'both sources are named');++ const shopCard = cards.slice(shopAt, steamAt);++ const steamCard = cards.slice(steamAt);++ assert.match(shopCard, /4\.2 \/ 5<\/span> <span class="rv-muted">from 120 ratings/);++ assert.match(steamCard, /50%<\/span> <span class="rv-muted">recommend from 2 reviews/);++ assert.match(page, /<strong>4\.2 \/ 5<\/strong> · 120 ratings/, 'the aggregate keeps the rating sample');++ assert.match(page, /<strong>50%<\/strong> recommend · 2 reviews · 2 bought on Steam/, 'the aggregate keeps the recommendation sample');++ } finally { await h.stop(); }++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+/* Entity-page outcomes from the market research (2026-10-31): each source keeps its own count beside the others,
+ and every review row carries its provenance. The rows and the fold are openvibe-shared/kit, loaded on entity
+ pages only; these rules use the site's theme tokens, so no colour is introduced here. */
+.rv-source-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); }
+.rv-source-card { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 12px 14px; border: 1px solid var(--rv-line); border-radius: 12px; background: var(--rv-panel); overflow-wrap: anywhere; }
+.rv-source-head { margin: 0; font-size: 1rem; line-height: 1.3; }
+.rv-source-figure { margin: 0; }
+.rv-source-figure .rv-big { font-size: 1.45rem; }
+.rv-source-card details { margin-top: 4px; }
+.rv-source-card details p { margin: 6px 0 0; }
+.rv-review-list .ovk-row:target { outline: 2px solid var(--rv-accent); outline-offset: 2px; }
+/* A review row's provenance is a full sentence, not a one-line list subtitle: let it wrap on a phone. */
+.rv-review-list .ovk-row-top { flex-wrap: wrap; }
+.rv-review-list .ovk-row-top strong { white-space: normal; overflow: visible; text-overflow: clip; }
+.rv-review-list .ovk-row-top time { margin-left: 0; }
+.rv-review-list .ovk-row-sub { align-items: flex-start; }
+.rv-review-list .ovk-row-sub > span:first-child { white-space: normal; overflow: visible; text-overflow: clip; }
+/* The method's rows are sentences too; without this the kit's one-line rule ellipsises them. */
+.rv-method .ovk-row-sub { align-items: flex-start; }
+.rv-method .ovk-row-sub > span:first-child { white-space: normal; overflow: visible; text-overflow: clip; }
@@ -299,6 +299,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -299,6 +299,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const signedIn = !!req.actor.subject;
const signedIn = !!req.actor.subject;
send(req, res, status, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null, members, elsewhere, seen, records, reviewError, reviewValues }), {
send(req, res, status, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null, members, elsewhere, seen, records, reviewError, reviewValues }), {
decision: p.decision, title: e.name, cache: discussion.state === 'ok' || signedIn ? null : 'public', path: svc.entityPath(e),
decision: p.decision, title: e.name, cache: discussion.state === 'ok' || signedIn ? null : 'public', path: svc.entityPath(e),
+ styles: ['kit.css'], localStyles: ['css/outcomes.css'],
description: e.description && !svc.LOCAL_KINDS.has(e.kind) ? e.description
description: e.description && !svc.LOCAL_KINDS.has(e.kind) ? e.description
: `${e.name}${e.description ? ` (${e.description})` : ''}: ${members.stats.count ? `${members.stats.average} out of 5 from ${members.stats.count} OpenVibe member review${members.stats.count === 1 ? '' : 's'}, ` : ''}review signals${p.sources.length ? ` from ${p.sources.map((s) => s.name || s.key).join(', ')}` : ''}, and every other review site in one place.`,
: `${e.name}${e.description ? ` (${e.description})` : ''}: ${members.stats.count ? `${members.stats.average} out of 5 from ${members.stats.count} OpenVibe member review${members.stats.count === 1 ? '' : 's'}, ` : ''}review signals${p.sources.length ? ` from ${p.sources.map((s) => s.name || s.key).join(', ')}` : ''}, and every other review site in one place.`,
canonical: p.entity.url, jsonLd: structuredData(p, config, members),
canonical: p.entity.url, jsonLd: structuredData(p, config, members),
@@ -9,8 +9,11 @@
@@ -9,8 +9,11 @@
*/
*/
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const kit = require('openvibe-shared/kit');
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
const { pickBlock, foldBlock } = require('./short');
const { pickBlock, foldBlock } = require('./short');
+// Per-source counts on an entity page use the same method as the page's aggregate.
+const { computeAggregate } = require('../reviews/aggregate');
const { html, raw } = ssr;
const { html, raw } = ssr;
const t = (v) => raw(ssr.timeTag(v));
const t = (v) => raw(ssr.timeTag(v));
@@ -18,6 +21,29 @@ const tl = (v) => raw(ssr.timeTag(v, { label: v ? new Date(v).toISOString().repl
@@ -18,6 +21,29 @@ const tl = (v) => raw(ssr.timeTag(v, { label: v ? new Date(v).toISOString().repl
const e = encodeURIComponent;
const e = encodeURIComponent;
const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));
const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+/** A date people read: "20 Sep 2026". The page never prints a raw timestamp. */
+function dateWords(v) {
+ if (!v) return '';
+ const d = new Date(v);
+ if (Number.isNaN(d.getTime())) return '';
+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
+}
+
+// Steam's language field is a slug; people read a language name.
+const LANGUAGE_LABEL = {
+ english: 'English', german: 'German', french: 'French', italian: 'Italian', koreana: 'Korean', spanish: 'Spanish',
+ schinese: 'Simplified Chinese', tchinese: 'Traditional Chinese', russian: 'Russian', thai: 'Thai', japanese: 'Japanese',
+ portuguese: 'Portuguese', polish: 'Polish', danish: 'Danish', dutch: 'Dutch', finnish: 'Finnish', norwegian: 'Norwegian',
+ swedish: 'Swedish', hungarian: 'Hungarian', czech: 'Czech', romanian: 'Romanian', turkish: 'Turkish', brazilian: 'Brazilian Portuguese',
+ bulgarian: 'Bulgarian', greek: 'Greek', ukrainian: 'Ukrainian', vietnamese: 'Vietnamese', indonesian: 'Indonesian', latam: 'Latin American Spanish',
+};
+function languageLabel(v) {
+ const s = String(v || '').trim();
+ if (!s) return '';
+ return LANGUAGE_LABEL[s.toLowerCase()] || s.charAt(0).toUpperCase() + s.slice(1);
+}
+
const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other',
const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other',
employer: 'Employer', landlord: 'Landlord', restaurant: 'Restaurant', business: 'Local business', hosting: 'Hosting provider', app: 'App', vehicle: 'Vehicle',
employer: 'Employer', landlord: 'Landlord', restaurant: 'Restaurant', business: 'Local business', hosting: 'Hosting provider', app: 'App', vehicle: 'Vehicle',
community: 'Community', platform: 'Platform' };
community: 'Community', platform: 'Platform' };
@@ -64,16 +90,57 @@ function signalValue(s) {
@@ -64,16 +90,57 @@ function signalValue(s) {
}
}
}
}
-function trustFacts(trust) {
+function trustBits(trust = {}) {
const bits = [];
const bits = [];
- if (trust.steam_purchase === true) bits.push('bought on Steam');
- if (trust.steam_purchase === false) bits.push('not bought on Steam');
- if (trust.received_for_free === true) bits.push('received for free');
- if (trust.written_during_early_access === true) bits.push('written during early access');
- if (trust.verified_purchase === true) bits.push('verified purchase');
- if (trust.playtime_at_review_min != null) bits.push(`${n(Math.round(trust.playtime_at_review_min / 60))} h played at review`);
- if (trust.language) bits.push(`language: ${trust.language}`);
- return bits.join(' · ');
+ if (trust.steam_purchase === true) bits.push({ key: 'purchase', text: 'bought on Steam' });
+ if (trust.steam_purchase === false) bits.push({ key: 'not-purchase', text: 'not bought on Steam' });
+ if (trust.received_for_free === true) bits.push({ key: 'free', text: 'received for free' });
+ if (trust.written_during_early_access === true) bits.push({ key: 'early', text: 'written during early access' });
+ if (trust.verified_purchase === true) bits.push({ key: 'verified', text: 'verified purchase' });
+ if (trust.playtime_at_review_min != null) bits.push({ key: 'playtime', text: `${n(Math.round(trust.playtime_at_review_min / 60))} h played` });
+ if (trust.language) bits.push({ key: 'language', text: languageLabel(trust.language) });
+ return bits;
+}
+function trustFacts(trust) { return trustBits(trust).map((b) => b.text).join(' · '); }
+
+/** The one-word name a source is known by here ("Steam", "NYC Health"), for a line beside a review. */
+function sourceShortName(name, key) {
+ if (name) {
+ const s = String(name);
+ const steam = s.match(/^(.+?)\s+user review signals:/i);
+ if (steam) return steam[1];
+ const colon = s.indexOf(':');
+ if (colon > 0) return s.slice(0, colon).trim();
+ return s.length > 42 ? `${s.slice(0, 39)}…` : s;
+ }
+ // A source with no recorded name still gets a human label, never its raw key on a page.
+ const k = String(key || '');
+ if (/^steam-reviews-/.test(k)) return 'Steam';
+ if (k === 'cfpb-complaints-by-company') return 'CFPB';
+ if (k === 'nyc-hpd-open-violations') return 'NYC HPD';
+ if (k === 'nyc-restaurant-inspections') return 'NYC Health';
+ return k.replace(/-/g, ' ');
+}
+
+/** A purchase on the source's own record, never a check of ours. A free copy is not a purchase. */
+function purchased(s) {
+ const t = (s && s.trust) || {};
+ return (t.verified_purchase === true || t.steam_purchase === true) && t.received_for_free !== true;
+}
+function receivedFree(s) { return !!((s && s.trust) || {}).received_for_free; }
+
+/** The transaction as the source recorded it: evidence beside the review, not a verdict on it. */
+function evidenceBadge(s, source) {
+ if (receivedFree(s)) return kit.badge('Received for free', 'warn');
+ if (purchased(s)) return kit.badge(`Verified purchase · ${sourceShortName(source && source.name, s && s.source_key)}`, 'ok');
+ return '';
+}
+
+/** The facts beside a review that are not already the evidence badge. */
+function reviewFacts(s) {
+ return trustBits(s.trust)
+ .filter((b) => !(b.key === 'purchase' || b.key === 'free' || b.key === 'verified'))
+ .map((b) => b.text);
}
}
function errorBody({ status, title, message }) {
function errorBody({ status, title, message }) {
@@ -159,7 +226,7 @@ function paragraphs(text) {
@@ -159,7 +226,7 @@ function paragraphs(text) {
function memberReviewItem(r, { entity, editor }) {
function memberReviewItem(r, { entity, editor }) {
return html`<li><article class="rv-mr" id="${r.id}"><header>${stars(r.rating)} <b>${r.title}</b></header>
return html`<li><article class="rv-mr" id="${r.id}"><header>${stars(r.rating)} <b>${r.title}</b></header>
-<p class="rv-muted">${r.author_name}${r.relation ? html` · ${r.relation}` : ''} · ${t(r.created_at)}${r.edited ? ' (edited)' : ''}</p>
+<p class="rv-muted">Written here by ${r.author_name}${r.relation ? html` · ${r.relation}` : ''} · ${dateWords(r.created_at)}${r.edited ? ' (edited)' : ''}</p>
<div class="rv-mr-body">${paragraphs(r.body)}</div>
<div class="rv-mr-body">${paragraphs(r.body)}</div>
${r.mine ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><button type="submit" class="rv-link-button">Delete my review</button></form>`
${r.mine ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><button type="submit" class="rv-link-button">Delete my review</button></form>`
: editor ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> <button type="submit" class="rv-link-button">Remove</button></form>` : ''}
: editor ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> <button type="submit" class="rv-link-button">Remove</button></form>` : ''}
@@ -204,7 +271,7 @@ function elsewhereSection(links, entity, { seen = {}, actor = null, editor = fal
@@ -204,7 +271,7 @@ function elsewhereSection(links, entity, { seen = {}, actor = null, editor = fal
/** One site's rating as a member saw it, with when, how many members saw it, and a way to take it down. */
/** One site's rating as a member saw it, with when, how many members saw it, and a way to take it down. */
function seenLine(r, { entity, actor, editor }) {
function seenLine(r, { entity, actor, editor }) {
const mine = actor && actor.subject && r.seen_by === actor.subject;
const mine = actor && actor.subject && r.seen_by === actor.subject;
- return html`<span class="rv-seen"><b>${String(r.value)}</b> <span class="rv-muted">out of ${String(r.best)}${r.count != null ? ` from ${n(r.count)} rating${r.count === 1 ? '' : 's'}` : ''} · seen ${t(r.seen_at)}${r.members > 1 ? ` (${r.members} members)` : ''}</span>${mine || editor ? html` <form method="post" action="/save/${r.id}/remove" class="rv-inline"><input type="hidden" name="entity" value="${entity.slug}">${mine ? '' : html`<input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> `}<button type="submit" class="rv-link-button">${mine ? 'Remove mine' : 'Remove'}</button></form>` : ''}</span>`;
+ return html`<span class="rv-seen"><b>${String(r.value)}</b> <span class="rv-muted">out of ${String(r.best)}${r.count != null ? ` from ${n(r.count)} rating${r.count === 1 ? '' : 's'}` : ''} · seen ${dateWords(r.seen_at)}${r.members > 1 ? ` (${r.members} members)` : ''}</span>${mine || editor ? html` <form method="post" action="/save/${r.id}/remove" class="rv-inline"><input type="hidden" name="entity" value="${entity.slug}">${mine ? '' : html`<input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> `}<button type="submit" class="rv-link-button">${mine ? 'Remove mine' : 'Remove'}</button></form>` : ''}</span>`;
}
}
/** /save: a member checks the rating another site's page shows (filled in by the Companion, or typed) and saves it. */
/** /save: a member checks the rating another site's page shows (filled in by the Companion, or typed) and saves it. */
@@ -336,7 +403,7 @@ function home({ entities, total, editor, recent = [], kinds = {}, stats = null,
@@ -336,7 +403,7 @@ function home({ entities, total, editor, recent = [], kinds = {}, stats = null,
<ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>
<ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>
<p><a class="rv-button" href="/review">Write a review</a> <a href="/everywhere">Search every review site</a></p></section>
<p><a class="rv-button" href="/review">Write a review</a> <a href="/everywhere">Search every review site</a></p></section>
${recent.length ? html`<section class="sc-sec" aria-labelledby="rv-recent-h"><h2 id="rv-recent-h">Latest reviews from members</h2>
${recent.length ? html`<section class="sc-sec" aria-labelledby="rv-recent-h"><h2 id="rv-recent-h">Latest reviews from members</h2>
-<ul class="rv-recent">${recent.map((r) => html`<li><a href="/e/${r.entity.slug}#${r.id}"><span class="rv-card-head"><b>${r.entity.name}</b> <span class="rv-tag">${KIND_LABEL[r.entity.kind]}</span></span>${stars(r.rating)} <b>${r.title}</b><br><span class="rv-muted">${r.body.length > 160 ? `${r.body.slice(0, 157)}…` : r.body}</span></a></li>`)}</ul></section>` : ''}
+<ul class="rv-recent">${recent.map((r) => html`<li><a href="/e/${r.entity.slug}#${r.id}"><span class="rv-card-head"><b>${r.entity.name}</b> <span class="rv-tag">${KIND_LABEL[r.entity.kind]}</span></span>${stars(r.rating)} <b>${r.title}</b><br><span class="rv-muted">${r.body.length > 160 ? `${r.body.slice(0, 157)}…` : r.body}</span><br><span class="rv-muted">Written here by ${r.author_name} · ${dateWords(r.created_at)}</span></a></li>`)}</ul></section>` : ''}
<section id="entities" class="sc-sec" aria-labelledby="rv-board-h"><h2 id="rv-board-h">Scores from sources</h2>
<section id="entities" class="sc-sec" aria-labelledby="rv-board-h"><h2 id="rv-board-h">Scores from sources</h2>
${entities.length ? html`<p class="sc-lede">Each score comes from named sources. Open one to see every signal behind it, when it was read, and how the number was worked out.</p><ul class="rv-board">${entities.map(boardCard)}</ul>`
${entities.length ? html`<p class="sc-lede">Each score comes from named sources. Open one to see every signal behind it, when it was read, and how the number was worked out.</p><ul class="rv-board">${entities.map(boardCard)}</ul>`
: html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}
: html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}
@@ -393,6 +460,18 @@ function aboutPage() {
@@ -393,6 +460,18 @@ function aboutPage() {
<li>With no qualifying signal there is no aggregate. Not zero, not a placeholder: none.</li>
<li>With no qualifying signal there is no aggregate. Not zero, not a placeholder: none.</li>
</ul>
</ul>
<p>Each change to the inputs produces a new aggregate revision; the entity history lists them all.</p>
<p>Each change to the inputs produces a new aggregate revision; the entity history lists them all.</p>
+<h2 id="retention">Retention and removal</h2>
+<p>No deletion window is set for a signal. When a source updates an item the old signal is marked replaced; when it removes one the signal is withdrawn. Both stay in the entity's history and keep their provenance. An editor can exclude a source or a signal, always with a reason shown on the page. A member can delete their own review, and an editor can remove one with a reason; the reason stays in the editorial log.</p>
+<h2 id="checks">What we check</h2>
+<ul>
+<li>The source, the item, its revision and when it was read are on every signal.</li>
+<li>A rating counts only when its source stated the scale and the count; otherwise it is left out, and the page says why.</li>
+<li>A source's own tally is counted once; the individual reviews it covers are not added on top.</li>
+<li>An editor can exclude a source or a signal only with a reason, shown on the page.</li>
+<li>The source's own purchase record is shown beside a review as evidence. It says the reviewer bought it; it does not say the review is true.</li>
+</ul>
+<p>We do not score how likely a review is to be fake. We do not read review text, IP addresses, devices or writing style, and we have no measured fake-review detection rate: none is published.</p>
+<p>There is no invitation or import feed: a review's provenance says it came from a source item, was written here, or is a rating seen on another site by a member.</p>
<h2 id="summaries">Summaries</h2>
<h2 id="summaries">Summaries</h2>
<p>A summary is text written by an editor (or drafted by OpenVibe.AI and approved by an editor) whose pros and cons each cite the signals they rest on. A summary never carries a rating. If a cited signal is withdrawn or replaced, the summary is flagged on the page and a revised version waits for an editor.</p>
<p>A summary is text written by an editor (or drafted by OpenVibe.AI and approved by an editor) whose pros and cons each cite the signals they rest on. A summary never carries a rating. If a cited signal is withdrawn or replaced, the summary is flagged on the page and a revised version waits for an editor.</p>
<h2 id="entities">Entities, merges and splits</h2>
<h2 id="entities">Entities, merges and splits</h2>
@@ -416,13 +495,25 @@ function aggregateSection(p) {
@@ -416,13 +495,25 @@ function aggregateSection(p) {
const r = p.aggregate.result;
const r = p.aggregate.result;
const c = r.components;
const c = r.components;
const byId = new Map([...p.signals, ...p.inactive_signals].map((s) => [s.signal_id, s]));
const byId = new Map([...p.signals, ...p.inactive_signals].map((s) => [s.signal_id, s]));
+ // The sample and, where the source recorded one, the purchase evidence inside it. Never a count we did not keep.
+ const inComponent = (x) => x.inputs.map((id) => byId.get(id)).filter(Boolean);
+ const bought = (x) => inComponent(x).filter(purchased).length;
+ const allSteam = (x) => inComponent(x).length > 0 && inComponent(x).every((s) => /^steam-/.test(s.source_key));
+ const purchasePhrase = (x) => {
+ const k = bought(x);
+ if (!k) return '';
+ return allSteam(x) ? `${n(k)} bought on Steam` : `${n(k)} with a purchase on record`;
+ };
+ const ratePurchase = c.rating ? purchasePhrase(c.rating) : '';
+ const recPurchase = c.recommendation ? purchasePhrase(c.recommendation) : '';
+ // One plain line first; the revision, when it was computed and the method live in the Computation fold.
+ const recLine = c.recommendation ? html`<p class="rv-agg"><strong>${c.recommendation.percent}%</strong> recommend · ${n(c.recommendation.total)} review${c.recommendation.total === 1 ? '' : 's'}${recPurchase ? html` · ${recPurchase}` : ''}</p>` : '';
+ const rateLine = c.rating ? html`<p class="rv-agg"><strong>${c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best}` : `${c.rating.percent}%`}</strong> · ${n(c.rating.count)} rating${c.rating.count === 1 ? '' : 's'}${ratePurchase ? html` · ${ratePurchase}` : ''}</p>` : '';
return html`<section class="rv-aggregate">${head}
return html`<section class="rv-aggregate">${head}
-<div class="rv-agg-box">
-${c.rating ? html`<p class="rv-agg"><strong>${c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best}` : `${c.rating.percent}%`}</strong> from ${n(c.rating.count)} rating${c.rating.count === 1 ? '' : 's'}${c.rating.on_scale ? html` <span class="rv-muted">(${c.rating.percent}% of the best possible rating)</span>` : ''}</p>` : ''}
-${c.recommendation ? html`<p class="rv-agg"><strong>${c.recommendation.percent}%</strong> recommend: ${n(c.recommendation.positive)} of ${n(c.recommendation.total)}</p>` : ''}
-<p class="rv-muted">Aggregate revision ${String(p.aggregate.revision)}, computed ${tl(p.aggregate.computed_at)} from ${String(r.inputs.length)} signal${r.inputs.length === 1 ? '' : 's'} of ${String(r.sources.length)} source${r.sources.length === 1 ? '' : 's'}, observed ${t(r.observed.earliest)} to ${t(r.observed.latest)}. <a href="/about#method">Method ${r.method}</a>.</p>
-</div>
-<details><summary>Computation</summary><ul>${r.computation.map((line) => html`<li><code>${line}</code></li>`)}</ul>
+<div class="rv-agg-box">${recLine}${rateLine}</div>
+<details><summary>Computation</summary>
+<p class="rv-muted">Aggregate revision ${String(p.aggregate.revision)}, computed ${dateWords(p.aggregate.computed_at)} from ${String(r.inputs.length)} signal${r.inputs.length === 1 ? '' : 's'} of ${String(r.sources.length)} source${r.sources.length === 1 ? '' : 's'}, observed ${dateWords(r.observed.earliest)} to ${dateWords(r.observed.latest)}. <a href="/about#method">Method ${r.method}</a>.</p>
+<ul>${r.computation.map((line) => html`<li><code>${line}</code></li>`)}</ul>
<p>Inputs: ${r.inputs.map((i, k) => html`${k ? ', ' : ''}<a href="#${i.signal_id}">${i.signal_id}</a>`)}</p>
<p>Inputs: ${r.inputs.map((i, k) => html`${k ? ', ' : ''}<a href="#${i.signal_id}">${i.signal_id}</a>`)}</p>
${r.excluded.length ? html`<p>Left out:</p><ul>${r.excluded.map((x) => html`<li><a href="#${x.signal_id}">${x.signal_id}</a>${byId.get(x.signal_id) ? html` (${byId.get(x.signal_id).source_key})` : ''}: ${REASON_LABEL[x.reason] || x.reason}${x.note ? html` — ${x.note}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">Nothing was left out.</p>`}
${r.excluded.length ? html`<p>Left out:</p><ul>${r.excluded.map((x) => html`<li><a href="#${x.signal_id}">${x.signal_id}</a>${byId.get(x.signal_id) ? html` (${byId.get(x.signal_id).source_key})` : ''}: ${REASON_LABEL[x.reason] || x.reason}${x.note ? html` — ${x.note}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">Nothing was left out.</p>`}
</details></section>`;
</details></section>`;
@@ -506,17 +597,109 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>
@@ -506,17 +597,109 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>
${form}</section>`;
${form}</section>`;
}
}
+/**
+ * The method box every entity page publishes (the market research's first outcome): where the reviews come from,
+ * how they are collected and kept, how one is removed, the checks we really run, and the detection rate we do not
+ * have. The source names and count are the page's own; the rest is how server/reviews/aggregate.js and the signal
+ * tables work, so the page never claims a number or a policy we do not hold.
+ */
+function methodBox(p) {
+ const sources = p.sources.length
+ ? `${n(p.sources.length)} source${p.sources.length === 1 ? '' : 's'} on this page: ${p.sources.map((s) => sourceShortName(s.name, s.key)).join(', ')}.`
+ : 'No source has stated anything about this entity yet.';
+ const rows = [
+ kit.row({ title: 'Where reviews come from', sub: `${sources} Members' reviews are written here and kept apart. Every review says where it came from: a source item, written here, or a rating seen on another site by a member. We have no invitation or import feed, so no review claims one.` }),
+ kit.row({ title: 'How we collect them', sub: 'OpenVibe.Sources retrieves each source item. We keep the source, the item, its revision and when it was read; the review text stays with the source.' }),
+ kit.row({ title: 'How long we keep them', sub: 'No deletion window is set. A replaced or withdrawn signal stays in this page\'s history.' }),
+ kit.row({ title: 'How we remove one', sub: 'A source that removes an item withdraws its signal; a changed item replaces the old one. An editor can exclude a signal or a source, and the reason is shown beside it.' }),
+ kit.row({ title: 'What we check', sub: 'The source, item and read time on every signal; a rating\'s stated scale and count; one tally per source; an editor\'s exclusion and its reason; the source\'s own purchase record.' }),
+ kit.row({ title: 'What we do not measure', sub: 'No fake-review detection rate: none is measured, so none is published. We do not read review text, IP addresses, devices or writing style.' }),
+ ];
+ return raw(kit.fold({
+ id: 'method', title: 'How this page\'s numbers are made', meta: 'method, retention, checks',
+ body: `<div class="rv-method">${kit.list(rows, { flush: true })}</div><p class="rv-muted"><a href="/about#checks">The full method, retention and checks</a></p>`,
+ }));
+}
+
+/** One source's own reviews, counted with the same method as the page's aggregate. */
+function sourceCard(src, signals) {
+ // The signal view says `recommended: true | false`; computeAggregate reads the stored 1 | 0.
+ const rows = signals.map((s) => ({ ...s, id: s.signal_id, recommended: s.recommended === true ? 1 : s.recommended === false ? 0 : s.recommended }));
+ const result = computeAggregate(rows);
+ const c = result && result.components;
+ const figures = [];
+ if (c && c.rating) figures.push(html`<p class="rv-source-figure"><span class="rv-big">${c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best}` : `${c.rating.percent}%`}</span> <span class="rv-muted">from ${n(c.rating.count)} rating${c.rating.count === 1 ? '' : 's'}</span></p>`);
+ if (c && c.recommendation) figures.push(html`<p class="rv-source-figure"><span class="rv-big">${c.recommendation.percent}%</span> <span class="rv-muted">recommend from ${n(c.recommendation.total)} review${c.recommendation.total === 1 ? '' : 's'}</span></p>`);
+ const readAt = signals.map((s) => s.provenance && s.provenance.retrieved_at).filter(Boolean).sort().pop();
+ const limits = [];
+ if (src.stale) limits.push('the source was stale when last checked');
+ if (src.license_note) limits.push(`licence: ${src.license_note}`);
+ if (src.trust && src.trust.limitation) limits.push(src.trust.limitation.value);
+ if (src.trust && src.trust.verification) limits.push(`verification: ${src.trust.verification.value}`);
+ const site = safeUrl(src.homepage_url);
+ return html`<li class="rv-source-card">
+<p class="rv-source-head"><b>${src.name || sourceShortName(null, src.key)}</b></p>
+${figures.length ? figures : html`<p class="rv-muted">No qualifying signal: the source did not state a scale and a count. ${n(signals.length)} signal${signals.length === 1 ? '' : 's'}.</p>`}
+<p class="rv-muted">Read${readAt ? html` ${dateWords(readAt)}` : ''}${site ? html` · <a href="${site}" rel="nofollow noopener">source site</a>` : ''}</p>
+${src.trust && src.trust.aggregate ? html`<p>${raw(kit.badge('Excluded from the aggregate', 'warn'))} <span class="rv-muted">${src.trust.aggregate.note || 'an editor excluded this source'}</span></p>` : ''}
+${limits.length ? html`<p class="rv-muted">${limits.join(' · ')}</p>` : ''}
+${src.terms_note ? html`<details class="rv-source-terms"><summary>Terms</summary><p class="rv-muted">${src.terms_note}</p></details>` : ''}
+</li>`;
+}
+
+/** Every source's own count side by side, so disagreement stays visible instead of being averaged away. */
+function sourcesSection(p) {
+ if (!p.sources.length) return '';
+ const byKey = new Map();
+ for (const s of p.signals) {
+ if (!byKey.has(s.source_key)) byKey.set(s.source_key, []);
+ byKey.get(s.source_key).push(s);
+ }
+ return html`<section id="sources" aria-labelledby="sources-h"><h2 id="sources-h">Sources side by side</h2>
+<p class="rv-muted">Each source's own reviews and its own count. A source with more reviews does not outweigh another.</p>
+<ul class="rv-source-cards">${p.sources.map((src) => sourceCard(src, byKey.get(src.key) || []))}</ul>
+${p.entity_trust.limitation ? html`<p class="rv-limits">${p.entity_trust.limitation.value}</p>` : ''}
+<p class="rv-muted">Coverage is what the sources above returned, nothing more: a source that samples recent reviews, one language or one shop describes that sample, not everyone's opinion.${p.open_corrections ? ` ${p.open_corrections} correction(s) are waiting for an editor.` : ''}</p>
+</section>`;
+}
+
+/** Every source review as a row: its value, the transaction the source recorded, and its provenance. */
+function sourceReviewRows(p) {
+ const byKey = new Map(p.sources.map((s) => [s.key, s]));
+ return p.signals.map((s) => {
+ const src = byKey.get(s.source_key);
+ const badge = evidenceBadge(s, src);
+ const facts = reviewFacts(s);
+ const reviewed = s.source_published_at ? kit.when(s.source_published_at) : null;
+ const read = dateWords(s.provenance && s.provenance.retrieved_at);
+ const provenance = html`${sourceShortName(src && src.name, s.source_key)}${read ? html` · read ${read}` : ''}${facts.length ? html` · ${facts.join(' · ')}` : ''}`;
+ const link = safeUrl(s.canonical_url) || safeUrl(src && src.homepage_url);
+ return kit.row({
+ attrs: { id: s.signal_id, 'data-item-id': s.source_item_id, 'data-item-revision': String(s.item_revision) },
+ title: signalValue(s),
+ badge,
+ meta: reviewed ? reviewed.text : '',
+ metaIso: reviewed ? reviewed.iso : '',
+ subHtml: String(provenance),
+ trail: link ? `<a href="${ssr.escapeHtml(link)}" rel="nofollow noopener">Open</a>` : '',
+ });
+ });
+}
+
+function sourceReviewsSection(p) {
+ if (!p.signals.length) return '';
+ const rows = sourceReviewRows(p);
+ return raw(kit.section({
+ id: 'source-reviews', title: 'Reviews from sources', count: p.signals.length,
+ note: 'Each row keeps its source, when it was read, and whether the source recorded a purchase. A purchase on the source\'s record is evidence the reviewer bought it, not that the review is true. The words stay with the people who wrote them.',
+ cls: 'rv-review-list', body: kit.list(rows),
+ }));
+}
+
function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], seen = {}, records = null, reviewError = null, reviewValues = null }) {
function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], seen = {}, records = null, reviewError = null, reviewValues = null }) {
const en = p.entity;
const en = p.entity;
// Something of its own to show (members' reviews or official records): the empty source sections stay out.
// Something of its own to show (members' reviews or official records): the empty source sections stay out.
const own = (members && members.stats.count) || (records && (records.complaints || records.housing || records.inspections.length));
const own = (members && members.stats.count) || (records && (records.complaints || records.housing || records.inspections.length));
- const limits = [];
- for (const s of p.sources) {
- if (s.stale) limits.push(html`${s.name || s.key}: the source was stale when last checked${s.last_success_at ? html` (last successful fetch ${t(s.last_success_at)})` : ''}.`);
- if (s.trust.limitation) limits.push(html`${s.name || s.key}: ${s.trust.limitation.value}`);
- if (s.trust.verification) limits.push(html`${s.name || s.key} verification: ${s.trust.verification.value}`);
- }
- if (p.entity_trust.limitation) limits.push(html`${p.entity_trust.limitation.value}`);
const otherNames = p.aliases.filter((a) => a.type === 'name' && a.value !== en.name);
const otherNames = p.aliases.filter((a) => a.type === 'name' && a.value !== en.name);
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name }])}
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name }])}
<article class="rv-entity">
<article class="rv-entity">
@@ -527,16 +710,11 @@ ${records ? recordsSection(records, en) : ''}
@@ -527,16 +710,11 @@ ${records ? recordsSection(records, en) : ''}
${members ? memberSection(members, { entity: en, actor, editor: p.editor, error: reviewError, values: reviewValues }) : ''}
${members ? memberSection(members, { entity: en, actor, editor: p.editor, error: reviewError, values: reviewValues }) : ''}
${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}
${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}
${p.aggregate || p.signals.length || p.sources.length || !own ? aggregateSection(p) : ''}
${p.aggregate || p.signals.length || p.sources.length || !own ? aggregateSection(p) : ''}
+${methodBox(p)}
${p.summary || p.editor || !own ? summarySection(p) : ''}
${p.summary || p.editor || !own ? summarySection(p) : ''}
-${p.signals.length || p.sources.length || p.inactive_signals.length ? html`<section><h2 id="signals">Signals from sources (${String(p.signals.length)})</h2>
-${p.signals.length ? signalTable(p.signals, { caption: 'Signals from sources, with provenance' }) : html`<p class="rv-muted">No source has stated anything about this entity yet.</p>`}
+${sourcesSection(p)}
+${sourceReviewsSection(p)}
${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}
${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}
-</section>
-<section><h2 id="limits">Sources and limitations</h2>
-${p.sources.length ? html`<ul>${p.sources.map((s) => html`<li><strong>${s.name || s.key}</strong>${safeUrl(s.homepage_url) ? html` (<a href="${safeUrl(s.homepage_url)}" rel="nofollow noopener">site</a>)` : ''}${s.terms_note ? html`<br><span class="rv-muted">Terms: ${s.terms_note}</span>` : ''}${s.trust.aggregate ? html`<br><span class="rv-tag">excluded from the aggregate</span> ${s.trust.aggregate.note || ''}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No sources yet.</p>`}
-${limits.length ? html`<ul class="rv-limits">${limits.map((l) => html`<li>${l}</li>`)}</ul>` : ''}
-<p class="rv-muted">Coverage is what the sources above returned, nothing more: a source that samples recent reviews, one language or one shop describes that sample, not everyone's opinion.${p.open_corrections ? ` ${p.open_corrections} correction(s) are waiting for an editor.` : ''}</p>
-</section>` : ''}
${elsewhereSection(elsewhere, en, { seen, actor, editor: p.editor })}
${elsewhereSection(elsewhere, en, { seen, actor, editor: p.editor })}
${otherNames.length || p.links.length ? html`<section><h2>Also known as and related</h2>
${otherNames.length || p.links.length ? html`<section><h2>Also known as and related</h2>
${otherNames.length ? html`<p>Also known as ${otherNames.map((a, i) => html`${i ? ', ' : ''}${a.value}`)}</p>` : ''}
${otherNames.length ? html`<p>Also known as ${otherNames.map((a, i) => html`${i ? ', ' : ''}${a.value}`)}</p>` : ''}
@@ -58,8 +58,8 @@ t('AggregateRating JSON-LD is absent without signals, present only while signals
@@ -58,8 +58,8 @@ t('AggregateRating JSON-LD is absent without signals, present only while signals
assert.deepStrictEqual(product.aggregateRating, { '@type': 'AggregateRating', ratingValue: 4.4, ratingCount: 321, bestRating: 5 });
assert.deepStrictEqual(product.aggregateRating, { '@type': 'AggregateRating', ratingValue: 4.4, ratingCount: 321, bestRating: 5 });
assert.ok(!ld.some((x) => x['@type'] === 'Review'), 'no Review without a published summary');
assert.ok(!ld.some((x) => x['@type'] === 'Review'), 'no Review without a published summary');
const visible = noScripts(page.text);
const visible = noScripts(page.text);
- assert.match(visible, /4\.4 \/ 5<\/strong> from 321 ratings/);
- assert.match(visible, new RegExp(item.id));
+ assert.match(visible, /<strong>4\.4 \/ 5<\/strong> · 321 ratings/);
+ assert.match(visible, new RegExp(`data-item-id="${item.id}"`));
await deliver(h, sourcesEvent('sources.item.removed', h.sources.remove(item.id, 'takedown'), { reason: 'takedown' }));
await deliver(h, sourcesEvent('sources.item.removed', h.sources.remove(item.id, 'takedown'), { reason: 'takedown' }));
page = await req(h, 'GET', `/e/${e.slug}`);
page = await req(h, 'GET', `/e/${e.slug}`);
@@ -80,7 +80,7 @@ t('a recommendation-only entity: AggregateRating is the real share and count, on
@@ -80,7 +80,7 @@ t('a recommendation-only entity: AggregateRating is the real share and count, on
const page = await req(h, 'GET', `/e/${e.slug}`);
const page = await req(h, 'GET', `/e/${e.slug}`);
const game = jsonLd(page.text).find((x) => x['@type'] === 'VideoGame');
const game = jsonLd(page.text).find((x) => x['@type'] === 'VideoGame');
assert.deepStrictEqual(game.aggregateRating, { '@type': 'AggregateRating', ratingValue: 75, ratingCount: 4, bestRating: 100, worstRating: 0 });
assert.deepStrictEqual(game.aggregateRating, { '@type': 'AggregateRating', ratingValue: 75, ratingCount: 4, bestRating: 100, worstRating: 0 });
- assert.match(noScripts(page.text), /75%<\/strong> recommend: 3 of 4/);
+ assert.match(noScripts(page.text), /75%<\/strong> recommend · 4 reviews · 4 bought on Steam/);
assert.ok(!/★/.test(page.text));
assert.ok(!/★/.test(page.text));
} finally { await h.stop(); }
} finally { await h.stop(); }
});
});
@@ -105,7 +105,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
@@ -105,7 +105,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
assert.strictEqual(robots(page.text), 'index, follow');
assert.strictEqual(robots(page.text), 'index, follow');
assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
const text = noScripts(page.text);
const text = noScripts(page.text);
- for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Retrieved', 'steam-reviews-portal-2', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);
+ for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Steam · read', 'Steam', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);
assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));
assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));
sm = await req(h, 'GET', '/sitemaps/entities-1.xml');
sm = await req(h, 'GET', '/sitemaps/entities-1.xml');
assert.ok(sm.text.includes(`http://reviews.test/e/${e.slug}`));
assert.ok(sm.text.includes(`http://reviews.test/e/${e.slug}`));
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
+'use strict';
+/**
+ * The market research's outcomes for Reviews (2026-10-30), built 2026-10-31: a method box on every entity page, a
+ * verified transaction shown as evidence, a rating with its sample, provenance on every card, the checks we really
+ * run (and no detection rate we cannot measure), and sources side by side with their own counts.
+ *
+ * Outcome 5 (a removal changelog) is not here: the database has no removal changelog table.
+ */
+const assert = require('assert');
+const {
+ boot, req, suite, steamItem, productItem, baseItem, importItem, createEntity, userToken, cookieFor, subject,
+} = require('./helpers');
+
+const t = suite('outcomes');
+const BODY = 'The staff were kind and the work was steady, but the rota changed at the last minute more than once.';
+
+function addSteam(h) {
+ h.sources.addSource({ key: 'steam-reviews-portal-2', name: 'Steam user review signals: Portal 2 (app 620)', homepage_url: 'https://store.steampowered.com/app/620/', category: 'reviews' });
+}
+function addShop(h, key = 'shop-example', name = 'Example shop ratings', url = 'https://shop.example/') {
+ h.sources.addSource({ key, name, homepage_url: url, category: 'reviews' });
+}
+function signal(source, identity, fields) {
+ return baseItem({ source, kind: 'review_signal', identity, fields });
+}
+async function portal(h, aliases = [{ type: 'source', value: 'steam-reviews-portal-2' }]) {
+ return await createEntity(h, { name: 'Portal 2', kind: 'game', aliases });
+}
+
+t('outcome 1: the method box is on every entity page and names that page\'s own sources', async () => {
+ const h = await boot();
+ try {
+ addSteam(h);
+ await importItem(h, steamItem({ votedUp: true }));
+ const e = await portal(h);
+ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;
+ assert.match(page, /<details class="ovk-fold" id="method"/, 'the method box');
+ assert.match(page, /How this page(?:'|')s numbers are made/);
+ assert.match(page, /Where reviews come from/);
+ assert.match(page, /1 source on this page: Steam/, 'the source is the page\'s own');
+ assert.match(page, /Members(?:'|') reviews are written here and kept apart/);
+ assert.match(page, /How we collect them/);
+ assert.match(page, /How long we keep them/);
+ assert.match(page, /No deletion window is set/);
+ assert.match(page, /How we remove one/);
+ assert.match(page, /no invitation or import feed/, 'we say which origins we do not have');
+ assert.match(page, /href="\/about#checks"/);
+
+ // An entity with no source at all still publishes the method, and says so rather than borrowing a source.
+ const empty = await createEntity(h, { name: 'Nothing Yet', kind: 'other' });
+ const emptyPage = (await req(h, 'GET', `/e/${empty.slug}`)).text;
+ const box = emptyPage.slice(emptyPage.indexOf('id="method"'), emptyPage.indexOf('</details>', emptyPage.indexOf('id="method"')));
+ assert.match(box, /No source has stated anything about this entity yet/);
+ assert.ok(!box.includes('Steam'), 'no source is named on a page that has none');
+
+ const about = (await req(h, 'GET', '/about')).text;
+ assert.match(about, /<h2 id="retention">Retention and removal<\/h2>/);
+ assert.match(about, /No deletion window is set/);
+ assert.match(about, /<h2 id="checks">What we check<\/h2>/);
+ } finally { await h.stop(); }
+});
+
+t('outcome 2: a purchase the source recorded is shown as evidence, never as truth', async () => {
+ const h = await boot();
+ try {
+ addSteam(h);
+ await importItem(h, steamItem({ votedUp: true })); // bought on Steam
+ await importItem(h, signal('steam-reviews-portal-2', 'not-bought', { voted_up: true, steam_purchase: false, received_for_free: false }));
+ await importItem(h, signal('steam-reviews-portal-2', 'free-copy', { voted_up: true, steam_purchase: true, received_for_free: true }));
+ const e = await portal(h);
+ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;
+ assert.strictEqual((page.match(/Verified purchase · Steam/g) || []).length, 1, 'one verified purchase, not the false or free ones');
+ assert.match(page, /Received for free/, 'a free copy is labelled as such, not as a purchase');
+ assert.match(page, /not bought on Steam/, 'the source\'s no-purchase record stays a fact');
+ assert.match(page, /evidence the reviewer bought it, not that the review is true/);
+ } finally { await h.stop(); }
+});
+
+t('outcome 3: every rating carries its sample, and the purchases inside the sample are counted', async () => {
+ const h = await boot();
+ try {
+ addSteam(h);
+ await importItem(h, steamItem({ votedUp: true }));
+ await importItem(h, steamItem({ votedUp: true }));
+ await importItem(h, signal('steam-reviews-portal-2', 'nay', { voted_up: false, steam_purchase: false, received_for_free: false }));
+ const game = await portal(h);
+ const page = (await req(h, 'GET', `/e/${game.slug}`)).text;
+ assert.match(page, /<strong>66\.7%<\/strong> recommend · 3 reviews · 2 bought on Steam/);
+
+ addShop(h);
+ await importItem(h, productItem({ value: 4.2, best: 5, count: 120, url: 'https://shop.example/p/portal-2' }));
+ const product = await createEntity(h, { name: 'Widget 3000', kind: 'product', aliases: [{ type: 'source', value: 'shop-example' }] });
+ const shopPage = (await req(h, 'GET', `/e/${product.slug}`)).text;
+ assert.match(shopPage, /<strong>4\.2 \/ 5<\/strong> · 120 ratings/);
+
+ // A members' rating carries its own sample too.
+ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));
+ const employer = await createEntity(h, { name: 'Acme Corp', kind: 'employer' });
+ assert.strictEqual((await req(h, 'POST', `/e/${employer.slug}/reviews`, { cookie: ana, form: { rating: '4', title: 'Steady', body: BODY } })).status, 303);
+ assert.match((await req(h, 'GET', `/e/${employer.slug}`)).text, /from 1 review written here by people with an OpenVibe account/);
+
+ // The front page's score cards keep the count too.
+ assert.match((await req(h, 'GET', '/')).text, /120 ratings/);
+ } finally { await h.stop(); }
+});
+
+t('outcome 4: every card says where the review came from and when', async () => {
+ const h = await boot();
+ try {
+ addSteam(h);
+ await importItem(h, steamItem({ votedUp: true }));
+ const e = await portal(h);
+ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));
+ assert.strictEqual((await req(h, 'POST', `/e/${e.slug}/reviews`, { cookie: ana, form: { rating: 4, title: 'Still good', body: BODY, relation: 'Played it' } })).status, 303);
+ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;
+ assert.match(page, /Steam · read 20 Sep 2026 · 10 h played · English/, 'a source review names its source, when it was read, and its facts');
+ assert.match(page, /data-item-id="itm_[0-9A-Z]+"/, 'the item id is kept as a data attribute');
+ assert.ok(!/from a source item itm_/.test(page), 'the raw item id is not read by people');
+ assert.match(page, /<time datetime="2026-09-01T00:00:00.000Z">/, 'the source date is on the row');
+ assert.match(page, /<article class="rv-mr" id="mrv_[0-9A-Z]{26}">[\s\S]{0,700}Written here by ana · Played it · /, 'a member review keeps its origin, author, relation and date');
+ assert.match((await req(h, 'GET', '/')).text, /Written here by ana · /, 'the home card keeps it too');
+ } finally { await h.stop(); }
+});
+
+t('outcome 6: the checks we use are published, and the detection rate we do not have is not claimed', async () => {
+ const h = await boot();
+ try {
+ addSteam(h);
+ await importItem(h, steamItem({ votedUp: true }));
+ const e = await portal(h);
+ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;
+ assert.match(page, /<strong>What we check<\/strong>/);
+ assert.match(page, /The source, item and read time on every signal/);
+ assert.match(page, /stated scale and count/);
+ assert.match(page, /one tally per source/);
+ assert.match(page, /the source's own purchase record/);
+ assert.match(page, /No fake-review detection rate: none is measured, so none is published/);
+ assert.match(page, /We do not read review text, IP addresses, devices or writing style/);
+ assert.ok(!/\d+(?:\.\d+)?% (?:accuracy|precision|recall)/i.test(page), 'no unmeasured detection rate');
+
+ const about = (await req(h, 'GET', '/about')).text;
+ assert.match(about, /We do not score how likely a review is to be fake/);
+ assert.match(about, /no measured fake-review detection rate: none is published/);
+ } finally { await h.stop(); }
+});
+
+t('outcome 7: sources sit side by side, each with its own count', async () => {
+ const h = await boot();
+ try {
+ addSteam(h);
+ addShop(h);
+ await importItem(h, steamItem({ votedUp: true }));
+ await importItem(h, steamItem({ votedUp: false }));
+ await importItem(h, productItem({ value: 4.2, best: 5, count: 120, url: 'https://shop.example/p/portal-2' }));
+ const e = await portal(h, [{ type: 'source', value: 'steam-reviews-portal-2' }, { type: 'source', value: 'shop-example' }]);
+ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;
+ assert.match(page, /<h2 id="sources-h">Sources side by side<\/h2>/);
+ const start = page.indexOf('<ul class="rv-source-cards">');
+ const end = page.indexOf('</ul>', start);
+ const cards = page.slice(start, end);
+ assert.strictEqual((cards.match(/<li class="rv-source-card">/g) || []).length, 2, 'one card per source');
+ const shopAt = cards.indexOf('Example shop ratings');
+ const steamAt = cards.indexOf('Steam user review signals');
+ assert.ok(shopAt >= 0 && steamAt >= 0, 'both sources are named');
+ const shopCard = cards.slice(shopAt, steamAt);
+ const steamCard = cards.slice(steamAt);
+ assert.match(shopCard, /4\.2 \/ 5<\/span> <span class="rv-muted">from 120 ratings/);
+ assert.match(steamCard, /50%<\/span> <span class="rv-muted">recommend from 2 reviews/);
+ assert.match(page, /<strong>4\.2 \/ 5<\/strong> · 120 ratings/, 'the aggregate keeps the rating sample');
+ assert.match(page, /<strong>50%<\/strong> recommend · 2 reviews · 2 bought on Steam/, 'the aggregate keeps the recommendation sample');
+ } finally { await h.stop(); }
+});