openvibers/openvibe.reviews Commits
The reviews front page shows what is behind it: the numbers, the latest NYC restaurant inspections, the buildings with the most hazardous open violations, the companies with the most CFPB complaints, how it works, questions and links around the site (#89)
Changes
@@@ -143,3 +143,31 @@ code { overflow-wrap: anywhere; }.rv-violations li { margin: 2px 0; }.rv-critical { background: color-mix(in srgb, #dc2626 16%, transparent); color: inherit; }.rv-grade { font-weight: 700; }++++/* The front page (views.home): the numbers beside the hero, inspections, ranked records, questions, links around. */++/* The showcase sections sit in the page column, which has its own gutter (a second one left 240px of a 320px phone). */++.rv-main .sc-hero, .rv-main .sc-sec { padding-left: 0; padding-right: 0; }++.rv-main .sc-hero { padding-top: 16px; }++.rv-h-panel { padding: 18px; border: 1px solid var(--rv-line); border-radius: 16px; background: var(--rv-panel); }++main .rv-h-panel-h { margin: 0 0 12px; padding: 0; border: 0; font-size: 1rem; }++.rv-h-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }++.rv-h-stats div { display: flex; flex-direction: column-reverse; padding: 10px 12px; border: 1px solid var(--rv-line); border-radius: 12px; background: var(--rv-bg); }++.rv-h-stats dt { font-size: 13px; line-height: 1.35; color: var(--rv-muted); }++.rv-h-stats dd { margin: 0; font-size: 1.55rem; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; }++.rv-h-list, .rv-h-rank { list-style: none; margin: 0 0 10px; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); }++.rv-h-list a, .rv-h-rank a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 12px 14px; border: 1px solid var(--rv-line); border-radius: 12px; background: var(--rv-panel); color: var(--rv-text); text-decoration: none; font-size: 14.5px; }++.rv-h-list a:hover, .rv-h-rank a:hover { border-color: var(--rv-accent); }++.rv-h-list b, .rv-h-rank b { font-size: 1rem; }++.rv-h-crit { color: var(--rv-warn); }++.rv-h-rank { counter-reset: rv-rank; }++.rv-h-rank li { counter-increment: rv-rank; }++.rv-h-rank b::before { content: counter(rv-rank) ". "; color: var(--rv-muted); font-weight: 600; }++.rv-h-faq details { border-bottom: 1px solid var(--rv-line); padding: 10px 0; }++.rv-h-faq summary { cursor: pointer; font-weight: 600; }++.rv-h-faq details p { margin: 8px 0 0; color: var(--rv-muted); max-width: 760px; }++.rv-h-links { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 230px), 1fr)); }++.rv-h-links a { display: block; height: 100%; padding: 12px 14px; border: 1px solid var(--rv-line); border-radius: 12px; background: var(--rv-panel); color: var(--rv-text); text-decoration: none; }++.rv-h-links a:hover { border-color: var(--rv-accent); }++.rv-h-links b { display: block; }++.rv-h-links small { display: block; color: var(--rv-muted); font-size: 13.5px; }++@media (max-width: 560px) { .rv-h-stats dd { font-size: 1.3rem; } }@@@ -19,6 +19,7 @@ const { http } = require('openvibe-contracts');const { createSessionRoutes } = require('./auth/session');const { createApi } = require('./http/api');const { createPages } = require('./http/pages');++const { createHome } = require('./reviews/home');const { createMachine } = require('./http/machine');const { createEvents } = require('./http/events');const { createActorLimits } = require('./http/actor-limits');@@@ -169,7 +170,7 @@ function createApp({ config, telemetry = null, svc, viewers, platform, sources,app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));app.post('/e/:slug/correct', limiter(60 * 60000, 20));-- const pages = createPages({ svc, platform, sync, viewers, config, log, limits });++ const pages = createPages({ svc, platform, sync, viewers, config, log, limits, home: createHome({ db }) });app.use(pages.router);app.use((req, res) => pages.errorPage(req, res, 404, 'Not found', 'Nothing lives at that address.'));@@@ -30,6 +30,10 @@ const { renderPage, SITE_SUMMARY } = require('../render/layout');const views = require('../render/views');const { ratingForStructuredData } = require('../reviews/aggregate');const { actorMiddleware, crossSite } = require('./common');++const everywhere = require('../reviews/everywhere');++++// The front page in search results and share cards (≤ 160 characters).++const HOME_DESCRIPTION = 'Reviews of employers, landlords, restaurants and more, beside ratings from named sources, official records and a link to every review site.';const SCHEMA_TYPE = { product: 'Product', game: 'VideoGame', software: 'SoftwareApplication', service: 'Service', place: 'Place', organization: 'Organization', media: 'CreativeWork', other: 'Thing',employer: 'Organization', landlord: 'Organization', restaurant: 'Restaurant', business: 'LocalBusiness', hosting: 'Organization', app: 'SoftwareApplication', vehicle: 'Product' };@@@ -89,7 +93,7 @@ function summaryFromForm(b) {};}--function createPages({ svc, platform, sync, viewers, config, log = console, limits }) {++function createPages({ svc, platform, sync, viewers, config, log = console, limits, home = null }) {const router = express.Router();const form = express.urlencoded({ extended: false, limit: '200kb' });router.use(actorMiddleware(viewers, { services: false }));@@@ -147,13 +151,16 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi}// ── Public ───────────────────────────────────────────────-- router.get('/', async (req, res) => {-- const all = await svc.listEntities({ limit: 1 });-- send(req, res, 200, views.home({ entities: await svc.entitiesWithData(60), total: all.total, editor: isEditor(req), recent: await svc.recentMemberReviews(6), kinds: await svc.kindCounts() }), {-- robots: 'index, follow', cache: 'public', active: 'home', path: '/', summary: SITE_SUMMARY, styles: [showcase.STYLESHEET],-- jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Reviews', description: 'Review signals from named sources, with provenance and honest aggregates.' }),++ router.get('/', wrap(async (req, res) => {++ const [all, entities, recent, kinds, stats, inspections, hazardous, complaints] = await Promise.all([++ svc.listEntities({ limit: 1 }), svc.entitiesWithData(60), svc.recentMemberReviews(6), svc.kindCounts(),++ home ? home.stats() : null, home ? home.latestInspections(8) : [], home ? home.mostHazardous(6) : [], home ? home.mostComplaints(6) : [],++ ]);++ send(req, res, 200, views.home({ entities, total: all.total, editor: isEditor(req), recent, kinds, stats, sites: everywhere.SITES.length, inspections, hazardous, complaints }), {++ robots: 'index, follow', cache: 'public', active: 'home', path: '/', summary: SITE_SUMMARY, styles: [showcase.STYLESHEET], description: HOME_DESCRIPTION,++ jsonLd: views.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), entities }),});-- });++ }));// What shipped on OpenVibe.Reviews: the shared update log every OpenVibe site has.router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'reviews', siteName: 'OpenVibe.Reviews' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Reviews', robots: 'index, follow', cache: 'public', path: '/updates' }));router.get('/everywhere', (req, res) => {@@@ -67,7 +67,7 @@ function renderPage(o) {const { config } = o;if (!o.robots && !o.decision) throw new TypeError('renderPage needs explicit robots (or the gate decision)');// The front page's title says what the site is (search results show it); every other page is "<page> · OpenVibe.Reviews".-- const title = o.title ? `${o.title} · ${SITE_NAME}` : o.path === '/' ? `${SITE_NAME}: ratings from named, linked sources` : SITE_NAME;++ const title = o.title ? `${o.title} · ${SITE_NAME}` : o.path === '/' ? `${SITE_NAME}: reviews, ratings and public records` : SITE_NAME;const feeds = [{ type: 'atom', title: `${SITE_NAME}: published summaries (Atom)`, href: '/feed.atom' },{ type: 'json', title: `${SITE_NAME}: published summaries (JSON Feed)`, href: '/feed.json' },@@@ -92,8 +92,9 @@ function aggregateLine(agg) {}/** The front page opens with what OpenVibe.Reviews is for (openvibe-shared/showcase), then the entities. */--function reviewsHero() {++function reviewsHero(aside = null) {return showcase.hero({++ aside: aside ? { html: aside } : undefined,eyebrow: 'OpenVibe.Reviews',title: 'Employers, landlords, restaurants, hosts.', accent: 'Every review in one place.',lede: 'Read and write reviews of where you work, rent, eat and host, beside the scores other sites publish and every review site\'s own page for it. Every number says where it came from.',@@@ -286,19 +287,81 @@ ${pages > 1 ? html`<nav class="rv-pager" aria-label="Pages">${page > 1 ? html`<a</article>`;}--function home({ entities, total, editor, recent = [], kinds = {} }) {++// The questions the front page answers, shown on it and given to search engines as FAQPage (only what is shown).++const HOME_FAQ = [++ { q: 'Where do the scores come from?', a: 'From named sources that OpenVibe.Sources reads within their terms, such as Steam\'s review counts. Each signal keeps its source, the item and when it was read, and the aggregate lists its inputs and method. With no qualifying signal there is no score at all.' },++ { q: 'What are the official records?', a: 'What public bodies published: NYC Health restaurant inspections, NYC HPD open housing violations and the complaints consumers sent the CFPB. They sit in their own section of a page and never feed a score.' },++ { q: 'Can I write a review?', a: 'Yes. Sign in with your OpenVibe account, name the employer, landlord, restaurant or anything else, and write. Members\' reviews are labelled as theirs and kept apart from the sources\' numbers.' },++ { q: 'Do you copy reviews from Glassdoor, Yelp or Google?', a: 'No. Every page links each review site\'s own page for it, and a member can save the rating a site shows (with the date and a link). The words stay with the people who wrote them.' },++ { q: 'What if a page is wrong?', a: 'Anyone signed in can send the editors a correction from the page. An accepted correction is published with a note, and every earlier version stays in the page\'s history.' },++];++const BORO = (s) => String(s || '').toLowerCase().replace(/\b[a-z]/g, (c) => c.toUpperCase());++++function homeStats(stats, sites) {++ const tiles = [++ ['Pages', stats.pages],++ stats.records ? ['Official records', stats.records] : null,++ stats.signals ? ['Ratings from sources', stats.signals] : null,++ ['Review sites one search opens', sites],++ stats.memberReviews ? ['Members\' reviews', stats.memberReviews] : null,++ stats.seen ? ['Ratings saved from other sites', stats.seen] : null,++ ].filter(Boolean);++ return String(html`<div class="rv-h-panel"><h2 class="rv-h-panel-h">On OpenVibe.Reviews</h2><dl class="rv-h-stats">${tiles.map(([k, v]) => html`<div><dt>${k}</dt><dd>${n(v)}</dd></div>`)}</dl></div>`);++}++++function home({ entities, total, editor, recent = [], kinds = {}, stats = null, sites = 0, inspections = [], hazardous = [], complaints = [] }) {const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'organization', 'product', 'app', 'game', 'vehicle', 'community', 'platform'];-- return html`${raw(reviewsHero())}++ return html`${raw(reviewsHero(stats ? homeStats(stats, sites) : null))}<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2><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>` : ''}--${raw(reviewsFeatures())}<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>`}--<p class="rv-muted">${n(total)} reviewed in total. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>`;++<p class="rv-muted">${n(total)} pages in all. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>++${inspections.length ? html`<section class="sc-sec" aria-labelledby="rv-insp-h"><h2 id="rv-insp-h">Latest restaurant inspections</h2>++<p class="sc-lede">New York City restaurants by their newest NYC Health inspection, as the inspector recorded it. A letter grade is given only at some inspections.</p>++<ul class="rv-h-list">${inspections.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${[x.cuisine, BORO(x.boro)].filter(Boolean).join(' · ')}</span>++<span>Inspected ${t(x.occurred_on)}: ${n(x.violations)} violation${x.violations === 1 ? '' : 's'}${x.critical ? html`, <b class="rv-h-crit">${n(x.critical)} critical</b>` : ''}</span>${x.grade ? html`<span class="rv-muted">Latest grade on record: ${GRADE_TEXT[x.grade] || `Grade ${x.grade}`}</span>` : ''}</a></li>`)}</ul>++<p><a href="/k/restaurant">Every restaurant (${n(kinds.restaurant || 0)})</a></p></section>` : ''}++${hazardous.length ? html`<section class="sc-sec" aria-labelledby="rv-hpd-h"><h2 id="rv-hpd-h">Buildings with the most hazardous open violations</h2>++<p class="sc-lede">Buildings by their open class C (immediately hazardous) violations in NYC HPD's records. Open means HPD has not recorded a violation as corrected, so the count can run ahead of a building's condition today.</p>++<ol class="rv-h-rank">${hazardous.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${BORO(x.boro)}</span><span>${n(x.c)} class C · ${n(x.open)} open in all</span></a></li>`)}</ol>++<p><a href="/k/landlord">Every building (${n(kinds.landlord || 0)})</a></p></section>` : ''}++${complaints.length ? html`<section class="sc-sec" aria-labelledby="rv-cfpb-h"><h2 id="rv-cfpb-h">Companies with the most CFPB complaints</h2>++<p class="sc-lede">Complaints consumers sent the Consumer Financial Protection Bureau about financial products. They are not findings: the CFPB does not verify them, and a company with more customers gets more.</p>++<ol class="rv-h-rank">${complaints.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span>${n(x.complaints)} complaints</span></a></li>`)}</ol>++<p><a href="/k/organization">Every company (${n(kinds.organization || 0)})</a></p></section>` : ''}++${raw(reviewsFeatures())}++${raw(showcase.steps({++ title: 'How it works',++ items: [++ { title: 'Sources are read within their terms', text: 'OpenVibe.Sources collects public ratings and official records, each with its source, item and the time it was read.' },++ { title: 'Every number shows its inputs', text: 'A score is worked out from the signals by a published method, and the page lists them all. No signals, no score.', href: '/about' },++ { title: 'Members add their own reviews', text: 'Signed-in members write reviews and save the ratings other sites show, kept apart from the sources\' numbers.', href: '/review' },++ { title: 'Every review site is one click away', text: 'Each page links the review sites that cover its kind (Google, Yelp, Glassdoor, the BBB, Trustpilot, Reddit and more), searched for that name.', href: '/everywhere' },++ ],++ }))}++<section class="sc-sec rv-h-faq" aria-labelledby="rv-faq-h"><h2 id="rv-faq-h">Questions</h2>${HOME_FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>++<section class="sc-sec" aria-labelledby="rv-links-h"><h2 id="rv-links-h">Find your way around</h2><ul class="rv-h-links">++<li><a href="/review"><b>Write a review</b><small>Employers, landlords, restaurants, hosts and more.</small></a></li>++<li><a href="/everywhere"><b>Every review site, one search</b><small>${n(sites)} sites, each searched for the name you type.</small></a></li>++<li><a href="/about"><b>How it works</b><small>Signals, the aggregate, summaries and corrections.</small></a></li>++<li><a href="/k/restaurant"><b>Restaurants</b><small>NYC inspections, grades and critical violations.</small></a></li>++<li><a href="/k/landlord"><b>Landlords</b><small>Buildings by their open HPD violations.</small></a></li>++<li><a href="/updates"><b>What shipped</b><small>Every change to OpenVibe.Reviews itself.</small></a></li>++</ul></section>`;++}++++/** The front page's structured data: the site and its search, the scored pages as an ItemList, the questions shown. */++function homeJsonLd({ base, entities }) {++ const out = [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Reviews', url: `${base}/`,++ potentialAction: { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${base}/search?q={search_term_string}` }, 'query-input': 'required name=search_term_string' } }];++ if (entities.length) out.push({ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Scores from sources on OpenVibe.Reviews', numberOfItems: entities.length, itemListElement: entities.map((x, i) => ({ '@type': 'ListItem', position: i + 1, name: x.entity.name, url: `${base}${epath(x.entity)}` })) });++ out.push({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) });++ return out;}/** /everywhere: one name opens every review site's own search for it (server/reviews/everywhere.js). */@@@ -663,7 +726,7 @@ function itemPage({ item, candidates, error = null }) {module.exports = {everywherePage,-- home, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,++ home, homeJsonLd, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,editorHome, newEntityPage, editEntityPage, itemPage, signInPage, errorBody, epath, signalValue, KIND_LABEL,reviewStartPage, kindPage, elsewhereLinks, savePage, KIND_PLURAL,};nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++/**++ * What the front page counts and ranks, read from Reviews' own database (active entities and records only; the++ * front page is cached for everyone, so nothing here depends on who is looking):++ *++ * stats() pages, official records, ratings from sources (active signals), members' reviews and the++ * ratings members saved from other sites++ * latestInspections(n) restaurants by their newest NYC Health inspection: that day's violations, how many were++ * critical, the newest grade on record, the borough and cuisine++ * mostHazardous(n) buildings by NYC HPD's open class C (immediately hazardous) violations++ * mostComplaints(n) companies by the complaints consumers sent the CFPB++ */++const ACTIVE_RECORD = "r.status = 'active'";++++function createHome({ db }) {++ const q = {++ stats: db.prepare(`SELECT (SELECT COUNT(*) FROM review_entities WHERE state = 'active') AS pages,++ (SELECT COUNT(*) FROM review_records r WHERE ${ACTIVE_RECORD}) AS records,++ (SELECT COUNT(*) FROM review_signals WHERE status = 'active') AS signals,++ (SELECT COUNT(*) FROM review_member_reviews WHERE status = 'published') AS member_reviews,++ (SELECT COUNT(*) FROM review_seen_ratings WHERE status = 'shown') AS seen`),++ inspections: db.prepare(`WITH latest AS (++ SELECT DISTINCT ON (r.entity_id) r.entity_id, r.occurred_on FROM review_records r++ WHERE r.kind = 'inspection_violation' AND ${ACTIVE_RECORD} AND r.occurred_on IS NOT NULL++ ORDER BY r.entity_id, r.occurred_on DESC)++ SELECT e.slug, e.name, l.occurred_on,++ (SELECT COUNT(*) FROM review_records r WHERE r.entity_id = l.entity_id AND r.kind = 'inspection_violation' AND ${ACTIVE_RECORD} AND r.occurred_on = l.occurred_on) AS violations,++ (SELECT COUNT(*) FROM review_records r WHERE r.entity_id = l.entity_id AND r.kind = 'inspection_violation' AND ${ACTIVE_RECORD} AND r.occurred_on = l.occurred_on++ AND r.fields->>'critical_flag' = 'Critical') AS critical,++ (SELECT r.fields->>'grade' FROM review_records r WHERE r.entity_id = l.entity_id AND r.kind = 'inspection_violation' AND ${ACTIVE_RECORD}++ AND r.fields->>'grade' IS NOT NULL ORDER BY r.occurred_on DESC LIMIT 1) AS grade,++ (SELECT r.fields->>'boro' FROM review_records r WHERE r.entity_id = l.entity_id AND r.kind = 'inspection_violation' AND ${ACTIVE_RECORD} LIMIT 1) AS boro,++ (SELECT r.fields->>'cuisine' FROM review_records r WHERE r.entity_id = l.entity_id AND r.kind = 'inspection_violation' AND ${ACTIVE_RECORD} LIMIT 1) AS cuisine++ FROM latest l JOIN review_entities e ON e.id = l.entity_id AND e.state = 'active'++ ORDER BY l.occurred_on DESC, e.name LIMIT ?`),++ hazardous: db.prepare(`SELECT e.slug, e.name, (r.fields->>'c')::bigint AS c, (r.fields->>'b')::bigint AS b, (r.fields->>'open')::bigint AS open, r.fields->>'boro' AS boro++ FROM review_records r JOIN review_entities e ON e.id = r.entity_id AND e.state = 'active'++ WHERE r.kind = 'housing_violation_tally' AND ${ACTIVE_RECORD} AND r.fields->>'c' IS NOT NULL++ ORDER BY (r.fields->>'c')::bigint DESC, e.name LIMIT ?`),++ complaints: db.prepare(`SELECT e.slug, e.name, (r.fields->>'complaints')::bigint AS complaints++ FROM review_records r JOIN review_entities e ON e.id = r.entity_id AND e.state = 'active'++ WHERE r.kind = 'complaint_tally' AND ${ACTIVE_RECORD} AND r.fields->>'complaints' IS NOT NULL++ ORDER BY (r.fields->>'complaints')::bigint DESC, e.name LIMIT ?`),++ };++ const n = (v) => (v == null ? null : Number(v));++ const lim = (x, d) => Math.min(50, Math.max(1, Number(x) || d));++ return {++ async stats() {++ const r = (await q.stats.get()) || {};++ return { pages: n(r.pages) || 0, records: n(r.records) || 0, signals: n(r.signals) || 0, memberReviews: n(r.member_reviews) || 0, seen: n(r.seen) || 0 };++ },++ async latestInspections(limit = 8) {++ return (await q.inspections.all(lim(limit, 8))).map((x) => ({ ...x, violations: n(x.violations) || 0, critical: n(x.critical) || 0 }));++ },++ async mostHazardous(limit = 6) {++ return (await q.hazardous.all(lim(limit, 6))).map((x) => ({ ...x, c: n(x.c), b: n(x.b), open: n(x.open) }));++ },++ async mostComplaints(limit = 6) {++ return (await q.complaints.all(lim(limit, 6))).map((x) => ({ ...x, complaints: n(x.complaints) }));++ },++ };++}++++module.exports = { createHome };nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * The front page: its own title and description, the numbers (pages, official records, review sites), the latest++ * restaurant inspections (that day's violations and critical ones, the newest grade on record), the buildings with++ * the most open class C violations, the companies with the most CFPB complaints, the questions shown and given as++ * FAQPage, WebSite with its search, and links around the site. An empty site leaves the record sections out.++ */++const assert = require('assert');++const { boot, req, suite, baseItem, importItem } = require('./helpers');++++const t = suite('home');++const cfpb = (company, complaints) => baseItem({ source: 'cfpb-complaints-by-company', kind: 'complaint_tally', identity: company, title: company, fields: { company, complaints } });++const nyc = (fields) => baseItem({ source: 'nyc-restaurant-inspections', kind: 'inspection_violation', identity: `row-${Math.random().toString(36).slice(2, 10)}`, title: fields.dba, fields: {++ camis: '50071318', dba: 'THE COFFEE CLUB', boro: 'Brooklyn', building: '9526', street: '4 AVENUE', zipcode: '11209', cuisine: 'Coffee/Tea',++ inspection_type: 'Cycle Inspection / Initial Inspection', action: 'Violations were cited in the following area(s).', critical_flag: 'Not Critical', score: '20', grade: null, ...fields } });++const hpd = (fields) => baseItem({ source: 'nyc-hpd-open-violations', kind: 'housing_violation_tally', identity: fields.buildingid, title: fields.streetname, fields: {++ boro: 'BROOKLYN', zip: '11208', last: '2026-10-12T00:00:00.000', ...fields } });++const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++++t('an empty site: the hero and its numbers, no record sections', async () => {++ const h = await boot();++ try {++ const text = (await req(h, 'GET', '/')).text;++ assert.match(text, /<title>OpenVibe\.Reviews: reviews, ratings and public records<\/title>/);++ const desc = /<meta name="description" content="([^"]*)"/.exec(text)[1];++ assert.ok(desc.length <= 160 && /employers, landlords, restaurants/.test(desc), desc);++ assert.strictEqual((text.match(/<h1\b/g) || []).length, 1);++ assert.match(text, /<dt>Review sites one search opens<\/dt><dd>16<\/dd>/);++ assert.ok(!text.includes('Latest restaurant inspections') && !text.includes('most CFPB complaints') && !text.includes('most hazardous open violations'));++ } finally { await h.stop(); }++});++++t('records: the latest inspections, the most hazardous buildings, the most complaints; questions; structured data', async () => {++ const h = await boot();++ try {++ await importItem(h, nyc({ inspection_date: '2026-09-01T00:00:00.000', violation_code: '10F', violation: 'An older visit.', grade: 'A', score: '9' }));++ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '06D', violation: 'Food contact surface not washed.', critical_flag: 'Critical' }));++ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '10B', violation: 'Plumbing not properly installed.' }));++ await importItem(h, hpd({ buildingid: '288857', housenumber: '760', streetname: 'ELDERT LANE', open: '1794', c: '423', b: '1118', a: '252' }));++ await importItem(h, hpd({ buildingid: '100', housenumber: '765', streetname: 'LINCOLN AVENUE', open: '900', c: '421', b: '300', a: '179' }));++ await importItem(h, cfpb('CAPITAL ONE FINANCIAL CORPORATION', 34100));++ await importItem(h, cfpb('TRANSUNION INTERMEDIATE HOLDINGS, INC.', 5119486));++ const text = (await req(h, 'GET', '/')).text;++++ assert.match(text, /<dt>Official records<\/dt><dd>7<\/dd>/);++ const insp = (/<section class="sc-sec" aria-labelledby="rv-insp-h">[\s\S]*?<\/section>/.exec(text) || [''])[0];++ assert.match(insp, /The Coffee Club/i);++ assert.match(insp, /2 violations, <b class="rv-h-crit">1 critical<\/b>/, 'that day\'s violations, not the older visit\'s');++ assert.match(insp, /Latest grade on record: Grade A/);++ assert.match(insp, /Coffee\/Tea · Brooklyn/);++++ const hz = (/<section class="sc-sec" aria-labelledby="rv-hpd-h">[\s\S]*?<\/section>/.exec(text) || [''])[0];++ assert.ok(hz.indexOf('760 Eldert Lane') < hz.indexOf('765 Lincoln Avenue'), 'most class C first');++ assert.match(hz, /423 class C · 1,794 open in all/);++ assert.match(hz, /Open means HPD has not recorded a violation as corrected/);++++ const cp = (/<section class="sc-sec" aria-labelledby="rv-cfpb-h">[\s\S]*?<\/section>/.exec(text) || [''])[0];++ assert.ok(cp.indexOf('TransUnion Intermediate Holdings, Inc.') < cp.indexOf('Capital One'), 'most complaints first');++ assert.match(cp, /5,119,486 complaints/);++ assert.match(cp, /They are not findings/);++++ const blocks = ld(text);++ assert.ok(/\/search\?q=\{search_term_string\}$/.test(blocks.find((b) => b['@type'] === 'WebSite').potentialAction.target.urlTemplate));++ const faq = blocks.find((b) => b['@type'] === 'FAQPage');++ for (const q of faq.mainEntity) assert.ok(text.includes(`<summary>${q.name.replace(/'/g, ''')}</summary>`), q.name);++ ['/review', '/everywhere', '/about', '/k/restaurant', '/k/landlord', '/updates'].forEach((p) => assert.ok(text.includes(`href="${p}"`), p));++ } finally { await h.stop(); }++});@@@ -82,8 +82,10 @@ t('the document head and frame come from openvibe-publishing/layout, robots exacconst home = (await req(h, 'GET', '/')).text;const homeHead = home.slice(0, home.indexOf('</head>'));assert.ok(homeHead.includes('<meta name="robots" content="index, follow">'));-- // The home carries its own WebPage JSON-LD and, since llms-full.txt, the site summary's WebPage JSON-LD.-- assert.strictEqual(count(homeHead, /<script type="application\/ld\+json">/g), 2, 'the home page JSON-LD');++ // The home carries WebSite (with its search) and the FAQPage of the questions it shows, and the site summary's WebPage++ // JSON-LD; an ItemList of the scored pages joins them once there are any (none on an empty site).++ assert.strictEqual(count(homeHead, /<script type="application\/ld\+json">/g), 3, 'the home page JSON-LD');++ assert.ok(homeHead.includes('"@type":"WebSite"') && homeHead.includes('"@type":"FAQPage"') && homeHead.includes('"@type":"WebPage"'));assert.match(homeHead, /<meta name="ai-summary" content="[^"]+">/, 'the AI summary head');assert.ok(home.includes('Recently shipped on OpenVibe.Reviews'), 'the shipped line stays on the home page');} finally { await h.stop(); }@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 30, // measured 28.7 (fresh database; 27.0 before 2026-10-17's three more kinds to browse, 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)-- htmlBrotliKB: 7, // 6.6 (5.9, 4.6 before the showcase)++ htmlRawKB: 36, // 33.8 on 2026-10-22 (the front page's numbers, how it works, questions with their FAQPage JSON-LD, links); 28.7 (fresh database; 27.0 before 2026-10-17's three more kinds to browse, 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)++ htmlBrotliKB: 9, // 8.0 (2026-10-22 the richer front page); 6.6 (5.9, 4.6 before the showcase)jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime)jsRawKB: 257, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 61.5, // 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 2 (reviews.css, openvibe-shared showcase.css)-- cssRawKB: 23, // 22.1 with members' reviews, kinds and other review sites (2026-10-17); 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)-- cssBrotliKB: 5.5, // 5.1 with members' reviews (2026-10-17); 4.6 (4.3, 1.7 before showcase.css)++ cssRawKB: 27, // 25.4 on 2026-10-22 (the front page's numbers, inspections, ranked records, questions and links); 22.1 with members' reviews, kinds and other review sites (2026-10-17); 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)++ cssBrotliKB: 6.5, // 5.6 (2026-10-22 the richer front page); 5.1 with members' reviews (2026-10-17); 4.6 (4.3, 1.7 before showcase.css)externalFiles: 1, // 0};
@@ -143,3 +143,31 @@ code { overflow-wrap: anywhere; }
@@ -143,3 +143,31 @@ code { overflow-wrap: anywhere; }
.rv-violations li { margin: 2px 0; }
.rv-violations li { margin: 2px 0; }
.rv-critical { background: color-mix(in srgb, #dc2626 16%, transparent); color: inherit; }
.rv-critical { background: color-mix(in srgb, #dc2626 16%, transparent); color: inherit; }
.rv-grade { font-weight: 700; }
.rv-grade { font-weight: 700; }
+
+/* The front page (views.home): the numbers beside the hero, inspections, ranked records, questions, links around. */
+/* The showcase sections sit in the page column, which has its own gutter (a second one left 240px of a 320px phone). */
+.rv-main .sc-hero, .rv-main .sc-sec { padding-left: 0; padding-right: 0; }
+.rv-main .sc-hero { padding-top: 16px; }
+.rv-h-panel { padding: 18px; border: 1px solid var(--rv-line); border-radius: 16px; background: var(--rv-panel); }
+main .rv-h-panel-h { margin: 0 0 12px; padding: 0; border: 0; font-size: 1rem; }
+.rv-h-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
+.rv-h-stats div { display: flex; flex-direction: column-reverse; padding: 10px 12px; border: 1px solid var(--rv-line); border-radius: 12px; background: var(--rv-bg); }
+.rv-h-stats dt { font-size: 13px; line-height: 1.35; color: var(--rv-muted); }
+.rv-h-stats dd { margin: 0; font-size: 1.55rem; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; }
+.rv-h-list, .rv-h-rank { list-style: none; margin: 0 0 10px; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); }
+.rv-h-list a, .rv-h-rank a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 12px 14px; border: 1px solid var(--rv-line); border-radius: 12px; background: var(--rv-panel); color: var(--rv-text); text-decoration: none; font-size: 14.5px; }
+.rv-h-list a:hover, .rv-h-rank a:hover { border-color: var(--rv-accent); }
+.rv-h-list b, .rv-h-rank b { font-size: 1rem; }
+.rv-h-crit { color: var(--rv-warn); }
+.rv-h-rank { counter-reset: rv-rank; }
+.rv-h-rank li { counter-increment: rv-rank; }
+.rv-h-rank b::before { content: counter(rv-rank) ". "; color: var(--rv-muted); font-weight: 600; }
+.rv-h-faq details { border-bottom: 1px solid var(--rv-line); padding: 10px 0; }
+.rv-h-faq summary { cursor: pointer; font-weight: 600; }
+.rv-h-faq details p { margin: 8px 0 0; color: var(--rv-muted); max-width: 760px; }
+.rv-h-links { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 230px), 1fr)); }
+.rv-h-links a { display: block; height: 100%; padding: 12px 14px; border: 1px solid var(--rv-line); border-radius: 12px; background: var(--rv-panel); color: var(--rv-text); text-decoration: none; }
+.rv-h-links a:hover { border-color: var(--rv-accent); }
+.rv-h-links b { display: block; }
+.rv-h-links small { display: block; color: var(--rv-muted); font-size: 13.5px; }
+@media (max-width: 560px) { .rv-h-stats dd { font-size: 1.3rem; } }
@@ -19,6 +19,7 @@ const { http } = require('openvibe-contracts');
@@ -19,6 +19,7 @@ const { http } = require('openvibe-contracts');
const { createSessionRoutes } = require('./auth/session');
const { createSessionRoutes } = require('./auth/session');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPages } = require('./http/pages');
const { createPages } = require('./http/pages');
+const { createHome } = require('./reviews/home');
const { createMachine } = require('./http/machine');
const { createMachine } = require('./http/machine');
const { createEvents } = require('./http/events');
const { createEvents } = require('./http/events');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
@@ -169,7 +170,7 @@ function createApp({ config, telemetry = null, svc, viewers, platform, sources,
@@ -169,7 +170,7 @@ function createApp({ config, telemetry = null, svc, viewers, platform, sources,
app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));
app.post(['/e/*', '/editor/*'], limiter(10 * 60000, 120));
app.post('/e/:slug/correct', limiter(60 * 60000, 20));
app.post('/e/:slug/correct', limiter(60 * 60000, 20));
- const pages = createPages({ svc, platform, sync, viewers, config, log, limits });
+ const pages = createPages({ svc, platform, sync, viewers, config, log, limits, home: createHome({ db }) });
app.use(pages.router);
app.use(pages.router);
app.use((req, res) => pages.errorPage(req, res, 404, 'Not found', 'Nothing lives at that address.'));
app.use((req, res) => pages.errorPage(req, res, 404, 'Not found', 'Nothing lives at that address.'));
@@ -30,6 +30,10 @@ const { renderPage, SITE_SUMMARY } = require('../render/layout');
@@ -30,6 +30,10 @@ const { renderPage, SITE_SUMMARY } = require('../render/layout');
const views = require('../render/views');
const views = require('../render/views');
const { ratingForStructuredData } = require('../reviews/aggregate');
const { ratingForStructuredData } = require('../reviews/aggregate');
const { actorMiddleware, crossSite } = require('./common');
const { actorMiddleware, crossSite } = require('./common');
+const everywhere = require('../reviews/everywhere');
+
+// The front page in search results and share cards (≤ 160 characters).
+const HOME_DESCRIPTION = 'Reviews of employers, landlords, restaurants and more, beside ratings from named sources, official records and a link to every review site.';
const SCHEMA_TYPE = { product: 'Product', game: 'VideoGame', software: 'SoftwareApplication', service: 'Service', place: 'Place', organization: 'Organization', media: 'CreativeWork', other: 'Thing',
const SCHEMA_TYPE = { product: 'Product', game: 'VideoGame', software: 'SoftwareApplication', service: 'Service', place: 'Place', organization: 'Organization', media: 'CreativeWork', other: 'Thing',
employer: 'Organization', landlord: 'Organization', restaurant: 'Restaurant', business: 'LocalBusiness', hosting: 'Organization', app: 'SoftwareApplication', vehicle: 'Product' };
employer: 'Organization', landlord: 'Organization', restaurant: 'Restaurant', business: 'LocalBusiness', hosting: 'Organization', app: 'SoftwareApplication', vehicle: 'Product' };
@@ -89,7 +93,7 @@ function summaryFromForm(b) {
@@ -89,7 +93,7 @@ function summaryFromForm(b) {
};
};
}
}
-function createPages({ svc, platform, sync, viewers, config, log = console, limits }) {
+function createPages({ svc, platform, sync, viewers, config, log = console, limits, home = null }) {
const router = express.Router();
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '200kb' });
const form = express.urlencoded({ extended: false, limit: '200kb' });
router.use(actorMiddleware(viewers, { services: false }));
router.use(actorMiddleware(viewers, { services: false }));
@@ -147,13 +151,16 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
@@ -147,13 +151,16 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
}
}
// ── Public ───────────────────────────────────────────────
// ── Public ───────────────────────────────────────────────
- router.get('/', async (req, res) => {
- const all = await svc.listEntities({ limit: 1 });
- send(req, res, 200, views.home({ entities: await svc.entitiesWithData(60), total: all.total, editor: isEditor(req), recent: await svc.recentMemberReviews(6), kinds: await svc.kindCounts() }), {
- robots: 'index, follow', cache: 'public', active: 'home', path: '/', summary: SITE_SUMMARY, styles: [showcase.STYLESHEET],
- jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Reviews', description: 'Review signals from named sources, with provenance and honest aggregates.' }),
+ router.get('/', wrap(async (req, res) => {
+ const [all, entities, recent, kinds, stats, inspections, hazardous, complaints] = await Promise.all([
+ svc.listEntities({ limit: 1 }), svc.entitiesWithData(60), svc.recentMemberReviews(6), svc.kindCounts(),
+ home ? home.stats() : null, home ? home.latestInspections(8) : [], home ? home.mostHazardous(6) : [], home ? home.mostComplaints(6) : [],
+ ]);
+ send(req, res, 200, views.home({ entities, total: all.total, editor: isEditor(req), recent, kinds, stats, sites: everywhere.SITES.length, inspections, hazardous, complaints }), {
+ robots: 'index, follow', cache: 'public', active: 'home', path: '/', summary: SITE_SUMMARY, styles: [showcase.STYLESHEET], description: HOME_DESCRIPTION,
+ jsonLd: views.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), entities }),
});
});
- });
+ }));
// What shipped on OpenVibe.Reviews: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.Reviews: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'reviews', siteName: 'OpenVibe.Reviews' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Reviews', robots: 'index, follow', cache: 'public', path: '/updates' }));
router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'reviews', siteName: 'OpenVibe.Reviews' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Reviews', robots: 'index, follow', cache: 'public', path: '/updates' }));
router.get('/everywhere', (req, res) => {
router.get('/everywhere', (req, res) => {
@@ -67,7 +67,7 @@ function renderPage(o) {
@@ -67,7 +67,7 @@ function renderPage(o) {
const { config } = o;
const { config } = o;
if (!o.robots && !o.decision) throw new TypeError('renderPage needs explicit robots (or the gate decision)');
if (!o.robots && !o.decision) throw new TypeError('renderPage needs explicit robots (or the gate decision)');
// The front page's title says what the site is (search results show it); every other page is "<page> · OpenVibe.Reviews".
// The front page's title says what the site is (search results show it); every other page is "<page> · OpenVibe.Reviews".
- const title = o.title ? `${o.title} · ${SITE_NAME}` : o.path === '/' ? `${SITE_NAME}: ratings from named, linked sources` : SITE_NAME;
+ const title = o.title ? `${o.title} · ${SITE_NAME}` : o.path === '/' ? `${SITE_NAME}: reviews, ratings and public records` : SITE_NAME;
const feeds = [
const feeds = [
{ type: 'atom', title: `${SITE_NAME}: published summaries (Atom)`, href: '/feed.atom' },
{ type: 'atom', title: `${SITE_NAME}: published summaries (Atom)`, href: '/feed.atom' },
{ type: 'json', title: `${SITE_NAME}: published summaries (JSON Feed)`, href: '/feed.json' },
{ type: 'json', title: `${SITE_NAME}: published summaries (JSON Feed)`, href: '/feed.json' },
@@ -92,8 +92,9 @@ function aggregateLine(agg) {
@@ -92,8 +92,9 @@ function aggregateLine(agg) {
}
}
/** The front page opens with what OpenVibe.Reviews is for (openvibe-shared/showcase), then the entities. */
/** The front page opens with what OpenVibe.Reviews is for (openvibe-shared/showcase), then the entities. */
-function reviewsHero() {
+function reviewsHero(aside = null) {
return showcase.hero({
return showcase.hero({
+ aside: aside ? { html: aside } : undefined,
eyebrow: 'OpenVibe.Reviews',
eyebrow: 'OpenVibe.Reviews',
title: 'Employers, landlords, restaurants, hosts.', accent: 'Every review in one place.',
title: 'Employers, landlords, restaurants, hosts.', accent: 'Every review in one place.',
lede: 'Read and write reviews of where you work, rent, eat and host, beside the scores other sites publish and every review site\'s own page for it. Every number says where it came from.',
lede: 'Read and write reviews of where you work, rent, eat and host, beside the scores other sites publish and every review site\'s own page for it. Every number says where it came from.',
@@ -286,19 +287,81 @@ ${pages > 1 ? html`<nav class="rv-pager" aria-label="Pages">${page > 1 ? html`<a
@@ -286,19 +287,81 @@ ${pages > 1 ? html`<nav class="rv-pager" aria-label="Pages">${page > 1 ? html`<a
</article>`;
</article>`;
}
}
-function home({ entities, total, editor, recent = [], kinds = {} }) {
+// The questions the front page answers, shown on it and given to search engines as FAQPage (only what is shown).
+const HOME_FAQ = [
+ { q: 'Where do the scores come from?', a: 'From named sources that OpenVibe.Sources reads within their terms, such as Steam\'s review counts. Each signal keeps its source, the item and when it was read, and the aggregate lists its inputs and method. With no qualifying signal there is no score at all.' },
+ { q: 'What are the official records?', a: 'What public bodies published: NYC Health restaurant inspections, NYC HPD open housing violations and the complaints consumers sent the CFPB. They sit in their own section of a page and never feed a score.' },
+ { q: 'Can I write a review?', a: 'Yes. Sign in with your OpenVibe account, name the employer, landlord, restaurant or anything else, and write. Members\' reviews are labelled as theirs and kept apart from the sources\' numbers.' },
+ { q: 'Do you copy reviews from Glassdoor, Yelp or Google?', a: 'No. Every page links each review site\'s own page for it, and a member can save the rating a site shows (with the date and a link). The words stay with the people who wrote them.' },
+ { q: 'What if a page is wrong?', a: 'Anyone signed in can send the editors a correction from the page. An accepted correction is published with a note, and every earlier version stays in the page\'s history.' },
+];
+const BORO = (s) => String(s || '').toLowerCase().replace(/\b[a-z]/g, (c) => c.toUpperCase());
+
+function homeStats(stats, sites) {
+ const tiles = [
+ ['Pages', stats.pages],
+ stats.records ? ['Official records', stats.records] : null,
+ stats.signals ? ['Ratings from sources', stats.signals] : null,
+ ['Review sites one search opens', sites],
+ stats.memberReviews ? ['Members\' reviews', stats.memberReviews] : null,
+ stats.seen ? ['Ratings saved from other sites', stats.seen] : null,
+ ].filter(Boolean);
+ return String(html`<div class="rv-h-panel"><h2 class="rv-h-panel-h">On OpenVibe.Reviews</h2><dl class="rv-h-stats">${tiles.map(([k, v]) => html`<div><dt>${k}</dt><dd>${n(v)}</dd></div>`)}</dl></div>`);
+}
+
+function home({ entities, total, editor, recent = [], kinds = {}, stats = null, sites = 0, inspections = [], hazardous = [], complaints = [] }) {
const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'organization', 'product', 'app', 'game', 'vehicle', 'community', 'platform'];
const browse = ['employer', 'landlord', 'restaurant', 'hosting', 'business', 'organization', 'product', 'app', 'game', 'vehicle', 'community', 'platform'];
- return html`${raw(reviewsHero())}
+ return html`${raw(reviewsHero(stats ? homeStats(stats, sites) : null))}
<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2>
<section class="sc-sec" aria-labelledby="rv-kinds-h"><h2 id="rv-kinds-h">Browse and review</h2>
<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></a></li>`)}</ul></section>` : ''}
-${raw(reviewsFeatures())}
<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>`}
-<p class="rv-muted">${n(total)} reviewed in total. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>`;
+<p class="rv-muted">${n(total)} pages in all. <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a>${editor ? html` · <a href="/editor">Editor desk</a>` : ''}</p></section>
+${inspections.length ? html`<section class="sc-sec" aria-labelledby="rv-insp-h"><h2 id="rv-insp-h">Latest restaurant inspections</h2>
+<p class="sc-lede">New York City restaurants by their newest NYC Health inspection, as the inspector recorded it. A letter grade is given only at some inspections.</p>
+<ul class="rv-h-list">${inspections.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${[x.cuisine, BORO(x.boro)].filter(Boolean).join(' · ')}</span>
+<span>Inspected ${t(x.occurred_on)}: ${n(x.violations)} violation${x.violations === 1 ? '' : 's'}${x.critical ? html`, <b class="rv-h-crit">${n(x.critical)} critical</b>` : ''}</span>${x.grade ? html`<span class="rv-muted">Latest grade on record: ${GRADE_TEXT[x.grade] || `Grade ${x.grade}`}</span>` : ''}</a></li>`)}</ul>
+<p><a href="/k/restaurant">Every restaurant (${n(kinds.restaurant || 0)})</a></p></section>` : ''}
+${hazardous.length ? html`<section class="sc-sec" aria-labelledby="rv-hpd-h"><h2 id="rv-hpd-h">Buildings with the most hazardous open violations</h2>
+<p class="sc-lede">Buildings by their open class C (immediately hazardous) violations in NYC HPD's records. Open means HPD has not recorded a violation as corrected, so the count can run ahead of a building's condition today.</p>
+<ol class="rv-h-rank">${hazardous.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span class="rv-muted">${BORO(x.boro)}</span><span>${n(x.c)} class C · ${n(x.open)} open in all</span></a></li>`)}</ol>
+<p><a href="/k/landlord">Every building (${n(kinds.landlord || 0)})</a></p></section>` : ''}
+${complaints.length ? html`<section class="sc-sec" aria-labelledby="rv-cfpb-h"><h2 id="rv-cfpb-h">Companies with the most CFPB complaints</h2>
+<p class="sc-lede">Complaints consumers sent the Consumer Financial Protection Bureau about financial products. They are not findings: the CFPB does not verify them, and a company with more customers gets more.</p>
+<ol class="rv-h-rank">${complaints.map((x) => html`<li><a href="/e/${e(x.slug)}"><b>${x.name}</b><span>${n(x.complaints)} complaints</span></a></li>`)}</ol>
+<p><a href="/k/organization">Every company (${n(kinds.organization || 0)})</a></p></section>` : ''}
+${raw(reviewsFeatures())}
+${raw(showcase.steps({
+ title: 'How it works',
+ items: [
+ { title: 'Sources are read within their terms', text: 'OpenVibe.Sources collects public ratings and official records, each with its source, item and the time it was read.' },
+ { title: 'Every number shows its inputs', text: 'A score is worked out from the signals by a published method, and the page lists them all. No signals, no score.', href: '/about' },
+ { title: 'Members add their own reviews', text: 'Signed-in members write reviews and save the ratings other sites show, kept apart from the sources\' numbers.', href: '/review' },
+ { title: 'Every review site is one click away', text: 'Each page links the review sites that cover its kind (Google, Yelp, Glassdoor, the BBB, Trustpilot, Reddit and more), searched for that name.', href: '/everywhere' },
+ ],
+ }))}
+<section class="sc-sec rv-h-faq" aria-labelledby="rv-faq-h"><h2 id="rv-faq-h">Questions</h2>${HOME_FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>
+<section class="sc-sec" aria-labelledby="rv-links-h"><h2 id="rv-links-h">Find your way around</h2><ul class="rv-h-links">
+<li><a href="/review"><b>Write a review</b><small>Employers, landlords, restaurants, hosts and more.</small></a></li>
+<li><a href="/everywhere"><b>Every review site, one search</b><small>${n(sites)} sites, each searched for the name you type.</small></a></li>
+<li><a href="/about"><b>How it works</b><small>Signals, the aggregate, summaries and corrections.</small></a></li>
+<li><a href="/k/restaurant"><b>Restaurants</b><small>NYC inspections, grades and critical violations.</small></a></li>
+<li><a href="/k/landlord"><b>Landlords</b><small>Buildings by their open HPD violations.</small></a></li>
+<li><a href="/updates"><b>What shipped</b><small>Every change to OpenVibe.Reviews itself.</small></a></li>
+</ul></section>`;
+}
+
+/** The front page's structured data: the site and its search, the scored pages as an ItemList, the questions shown. */
+function homeJsonLd({ base, entities }) {
+ const out = [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Reviews', url: `${base}/`,
+ potentialAction: { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${base}/search?q={search_term_string}` }, 'query-input': 'required name=search_term_string' } }];
+ if (entities.length) out.push({ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Scores from sources on OpenVibe.Reviews', numberOfItems: entities.length, itemListElement: entities.map((x, i) => ({ '@type': 'ListItem', position: i + 1, name: x.entity.name, url: `${base}${epath(x.entity)}` })) });
+ out.push({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) });
+ return out;
}
}
/** /everywhere: one name opens every review site's own search for it (server/reviews/everywhere.js). */
/** /everywhere: one name opens every review site's own search for it (server/reviews/everywhere.js). */
@@ -663,7 +726,7 @@ function itemPage({ item, candidates, error = null }) {
@@ -663,7 +726,7 @@ function itemPage({ item, candidates, error = null }) {
module.exports = {
module.exports = {
everywherePage,
everywherePage,
- home, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,
+ home, homeJsonLd, aboutPage, searchPage, entityPage, historyPage, summaryRevisionPage, correctionPage,
editorHome, newEntityPage, editEntityPage, itemPage, signInPage, errorBody, epath, signalValue, KIND_LABEL,
editorHome, newEntityPage, editEntityPage, itemPage, signInPage, errorBody, epath, signalValue, KIND_LABEL,
reviewStartPage, kindPage, elsewhereLinks, savePage, KIND_PLURAL,
reviewStartPage, kindPage, elsewhereLinks, savePage, KIND_PLURAL,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+/**
+ * What the front page counts and ranks, read from Reviews' own database (active entities and records only; the
+ * front page is cached for everyone, so nothing here depends on who is looking):
+ *
+ * stats() pages, official records, ratings from sources (active signals), members' reviews and the
+ * ratings members saved from other sites
+ * latestInspections(n) restaurants by their newest NYC Health inspection: that day's violations, how many were
+ * critical, the newest grade on record, the borough and cuisine
+ * mostHazardous(n) buildings by NYC HPD's open class C (immediately hazardous) violations
+ * mostComplaints(n) companies by the complaints consumers sent the CFPB
+ */
+const ACTIVE_RECORD = "r.status = 'active'";
+
+function createHome({ db }) {
+ const q = {
+ stats: db.prepare(`SELECT (SELECT COUNT(*) FROM review_entities WHERE state = 'active') AS pages,
+ (SELECT COUNT(*) FROM review_records r WHERE ${ACTIVE_RECORD}) AS records,
+ (SELECT COUNT(*) FROM review_signals WHERE status = 'active') AS signals,
+ (SELECT COUNT(*) FROM review_member_reviews WHERE status = 'published') AS member_reviews,
+ (SELECT COUNT(*) FROM review_seen_ratings WHERE status = 'shown') AS seen`),
+ inspections: db.prepare(`WITH latest AS (
+ SELECT DISTINCT ON (r.entity_id) r.entity_id, r.occurred_on FROM review_records r
+ WHERE r.kind = 'inspection_violation' AND ${ACTIVE_RECORD} AND r.occurred_on IS NOT NULL
+ ORDER BY r.entity_id, r.occurred_on DESC)
+ SELECT e.slug, e.name, l.occurred_on,
+ (SELECT COUNT(*) FROM review_records r WHERE r.entity_id = l.entity_id AND r.kind = 'inspection_violation' AND ${ACTIVE_RECORD} AND r.occurred_on = l.occurred_on) AS violations,
+ (SELECT COUNT(*) FROM review_records r WHERE r.entity_id = l.entity_id AND r.kind = 'inspection_violation' AND ${ACTIVE_RECORD} AND r.occurred_on = l.occurred_on
+ AND r.fields->>'critical_flag' = 'Critical') AS critical,
+ (SELECT r.fields->>'grade' FROM review_records r WHERE r.entity_id = l.entity_id AND r.kind = 'inspection_violation' AND ${ACTIVE_RECORD}
+ AND r.fields->>'grade' IS NOT NULL ORDER BY r.occurred_on DESC LIMIT 1) AS grade,
+ (SELECT r.fields->>'boro' FROM review_records r WHERE r.entity_id = l.entity_id AND r.kind = 'inspection_violation' AND ${ACTIVE_RECORD} LIMIT 1) AS boro,
+ (SELECT r.fields->>'cuisine' FROM review_records r WHERE r.entity_id = l.entity_id AND r.kind = 'inspection_violation' AND ${ACTIVE_RECORD} LIMIT 1) AS cuisine
+ FROM latest l JOIN review_entities e ON e.id = l.entity_id AND e.state = 'active'
+ ORDER BY l.occurred_on DESC, e.name LIMIT ?`),
+ hazardous: db.prepare(`SELECT e.slug, e.name, (r.fields->>'c')::bigint AS c, (r.fields->>'b')::bigint AS b, (r.fields->>'open')::bigint AS open, r.fields->>'boro' AS boro
+ FROM review_records r JOIN review_entities e ON e.id = r.entity_id AND e.state = 'active'
+ WHERE r.kind = 'housing_violation_tally' AND ${ACTIVE_RECORD} AND r.fields->>'c' IS NOT NULL
+ ORDER BY (r.fields->>'c')::bigint DESC, e.name LIMIT ?`),
+ complaints: db.prepare(`SELECT e.slug, e.name, (r.fields->>'complaints')::bigint AS complaints
+ FROM review_records r JOIN review_entities e ON e.id = r.entity_id AND e.state = 'active'
+ WHERE r.kind = 'complaint_tally' AND ${ACTIVE_RECORD} AND r.fields->>'complaints' IS NOT NULL
+ ORDER BY (r.fields->>'complaints')::bigint DESC, e.name LIMIT ?`),
+ };
+ const n = (v) => (v == null ? null : Number(v));
+ const lim = (x, d) => Math.min(50, Math.max(1, Number(x) || d));
+ return {
+ async stats() {
+ const r = (await q.stats.get()) || {};
+ return { pages: n(r.pages) || 0, records: n(r.records) || 0, signals: n(r.signals) || 0, memberReviews: n(r.member_reviews) || 0, seen: n(r.seen) || 0 };
+ },
+ async latestInspections(limit = 8) {
+ return (await q.inspections.all(lim(limit, 8))).map((x) => ({ ...x, violations: n(x.violations) || 0, critical: n(x.critical) || 0 }));
+ },
+ async mostHazardous(limit = 6) {
+ return (await q.hazardous.all(lim(limit, 6))).map((x) => ({ ...x, c: n(x.c), b: n(x.b), open: n(x.open) }));
+ },
+ async mostComplaints(limit = 6) {
+ return (await q.complaints.all(lim(limit, 6))).map((x) => ({ ...x, complaints: n(x.complaints) }));
+ },
+ };
+}
+
+module.exports = { createHome };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * The front page: its own title and description, the numbers (pages, official records, review sites), the latest
+ * restaurant inspections (that day's violations and critical ones, the newest grade on record), the buildings with
+ * the most open class C violations, the companies with the most CFPB complaints, the questions shown and given as
+ * FAQPage, WebSite with its search, and links around the site. An empty site leaves the record sections out.
+ */
+const assert = require('assert');
+const { boot, req, suite, baseItem, importItem } = require('./helpers');
+
+const t = suite('home');
+const cfpb = (company, complaints) => baseItem({ source: 'cfpb-complaints-by-company', kind: 'complaint_tally', identity: company, title: company, fields: { company, complaints } });
+const nyc = (fields) => baseItem({ source: 'nyc-restaurant-inspections', kind: 'inspection_violation', identity: `row-${Math.random().toString(36).slice(2, 10)}`, title: fields.dba, fields: {
+ camis: '50071318', dba: 'THE COFFEE CLUB', boro: 'Brooklyn', building: '9526', street: '4 AVENUE', zipcode: '11209', cuisine: 'Coffee/Tea',
+ inspection_type: 'Cycle Inspection / Initial Inspection', action: 'Violations were cited in the following area(s).', critical_flag: 'Not Critical', score: '20', grade: null, ...fields } });
+const hpd = (fields) => baseItem({ source: 'nyc-hpd-open-violations', kind: 'housing_violation_tally', identity: fields.buildingid, title: fields.streetname, fields: {
+ boro: 'BROOKLYN', zip: '11208', last: '2026-10-12T00:00:00.000', ...fields } });
+const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+
+t('an empty site: the hero and its numbers, no record sections', async () => {
+ const h = await boot();
+ try {
+ const text = (await req(h, 'GET', '/')).text;
+ assert.match(text, /<title>OpenVibe\.Reviews: reviews, ratings and public records<\/title>/);
+ const desc = /<meta name="description" content="([^"]*)"/.exec(text)[1];
+ assert.ok(desc.length <= 160 && /employers, landlords, restaurants/.test(desc), desc);
+ assert.strictEqual((text.match(/<h1\b/g) || []).length, 1);
+ assert.match(text, /<dt>Review sites one search opens<\/dt><dd>16<\/dd>/);
+ assert.ok(!text.includes('Latest restaurant inspections') && !text.includes('most CFPB complaints') && !text.includes('most hazardous open violations'));
+ } finally { await h.stop(); }
+});
+
+t('records: the latest inspections, the most hazardous buildings, the most complaints; questions; structured data', async () => {
+ const h = await boot();
+ try {
+ await importItem(h, nyc({ inspection_date: '2026-09-01T00:00:00.000', violation_code: '10F', violation: 'An older visit.', grade: 'A', score: '9' }));
+ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '06D', violation: 'Food contact surface not washed.', critical_flag: 'Critical' }));
+ await importItem(h, nyc({ inspection_date: '2026-10-01T00:00:00.000', violation_code: '10B', violation: 'Plumbing not properly installed.' }));
+ await importItem(h, hpd({ buildingid: '288857', housenumber: '760', streetname: 'ELDERT LANE', open: '1794', c: '423', b: '1118', a: '252' }));
+ await importItem(h, hpd({ buildingid: '100', housenumber: '765', streetname: 'LINCOLN AVENUE', open: '900', c: '421', b: '300', a: '179' }));
+ await importItem(h, cfpb('CAPITAL ONE FINANCIAL CORPORATION', 34100));
+ await importItem(h, cfpb('TRANSUNION INTERMEDIATE HOLDINGS, INC.', 5119486));
+ const text = (await req(h, 'GET', '/')).text;
+
+ assert.match(text, /<dt>Official records<\/dt><dd>7<\/dd>/);
+ const insp = (/<section class="sc-sec" aria-labelledby="rv-insp-h">[\s\S]*?<\/section>/.exec(text) || [''])[0];
+ assert.match(insp, /The Coffee Club/i);
+ assert.match(insp, /2 violations, <b class="rv-h-crit">1 critical<\/b>/, 'that day\'s violations, not the older visit\'s');
+ assert.match(insp, /Latest grade on record: Grade A/);
+ assert.match(insp, /Coffee\/Tea · Brooklyn/);
+
+ const hz = (/<section class="sc-sec" aria-labelledby="rv-hpd-h">[\s\S]*?<\/section>/.exec(text) || [''])[0];
+ assert.ok(hz.indexOf('760 Eldert Lane') < hz.indexOf('765 Lincoln Avenue'), 'most class C first');
+ assert.match(hz, /423 class C · 1,794 open in all/);
+ assert.match(hz, /Open means HPD has not recorded a violation as corrected/);
+
+ const cp = (/<section class="sc-sec" aria-labelledby="rv-cfpb-h">[\s\S]*?<\/section>/.exec(text) || [''])[0];
+ assert.ok(cp.indexOf('TransUnion Intermediate Holdings, Inc.') < cp.indexOf('Capital One'), 'most complaints first');
+ assert.match(cp, /5,119,486 complaints/);
+ assert.match(cp, /They are not findings/);
+
+ const blocks = ld(text);
+ assert.ok(/\/search\?q=\{search_term_string\}$/.test(blocks.find((b) => b['@type'] === 'WebSite').potentialAction.target.urlTemplate));
+ const faq = blocks.find((b) => b['@type'] === 'FAQPage');
+ for (const q of faq.mainEntity) assert.ok(text.includes(`<summary>${q.name.replace(/'/g, ''')}</summary>`), q.name);
+ ['/review', '/everywhere', '/about', '/k/restaurant', '/k/landlord', '/updates'].forEach((p) => assert.ok(text.includes(`href="${p}"`), p));
+ } finally { await h.stop(); }
+});
@@ -82,8 +82,10 @@ t('the document head and frame come from openvibe-publishing/layout, robots exac
@@ -82,8 +82,10 @@ t('the document head and frame come from openvibe-publishing/layout, robots exac
const home = (await req(h, 'GET', '/')).text;
const home = (await req(h, 'GET', '/')).text;
const homeHead = home.slice(0, home.indexOf('</head>'));
const homeHead = home.slice(0, home.indexOf('</head>'));
assert.ok(homeHead.includes('<meta name="robots" content="index, follow">'));
assert.ok(homeHead.includes('<meta name="robots" content="index, follow">'));
- // The home carries its own WebPage JSON-LD and, since llms-full.txt, the site summary's WebPage JSON-LD.
- assert.strictEqual(count(homeHead, /<script type="application\/ld\+json">/g), 2, 'the home page JSON-LD');
+ // The home carries WebSite (with its search) and the FAQPage of the questions it shows, and the site summary's WebPage
+ // JSON-LD; an ItemList of the scored pages joins them once there are any (none on an empty site).
+ assert.strictEqual(count(homeHead, /<script type="application\/ld\+json">/g), 3, 'the home page JSON-LD');
+ assert.ok(homeHead.includes('"@type":"WebSite"') && homeHead.includes('"@type":"FAQPage"') && homeHead.includes('"@type":"WebPage"'));
assert.match(homeHead, /<meta name="ai-summary" content="[^"]+">/, 'the AI summary head');
assert.match(homeHead, /<meta name="ai-summary" content="[^"]+">/, 'the AI summary head');
assert.ok(home.includes('Recently shipped on OpenVibe.Reviews'), 'the shipped line stays on the home page');
assert.ok(home.includes('Recently shipped on OpenVibe.Reviews'), 'the shipped line stays on the home page');
} finally { await h.stop(); }
} finally { await h.stop(); }
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 30, // measured 28.7 (fresh database; 27.0 before 2026-10-17's three more kinds to browse, 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)
- htmlBrotliKB: 7, // 6.6 (5.9, 4.6 before the showcase)
+ htmlRawKB: 36, // 33.8 on 2026-10-22 (the front page's numbers, how it works, questions with their FAQPage JSON-LD, links); 28.7 (fresh database; 27.0 before 2026-10-17's three more kinds to browse, 23.7 before 2026-10-15's two more features with their ring icons, 17.8 before the showcase)
+ htmlBrotliKB: 9, // 8.0 (2026-10-22 the richer front page); 6.6 (5.9, 4.6 before the showcase)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime)
jsRawKB: 257, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 257, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 61.5, // 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 61.5, // 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (reviews.css, openvibe-shared showcase.css)
cssFiles: 2, // 2 (reviews.css, openvibe-shared showcase.css)
- cssRawKB: 23, // 22.1 with members' reviews, kinds and other review sites (2026-10-17); 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)
- cssBrotliKB: 5.5, // 5.1 with members' reviews (2026-10-17); 4.6 (4.3, 1.7 before showcase.css)
+ cssRawKB: 27, // 25.4 on 2026-10-22 (the front page's numbers, inspections, ranked records, questions and links); 22.1 with members' reviews, kinds and other review sites (2026-10-17); 19.4 (18.2 before 2026-10-15's scoreboard, 6.7 before showcase.css)
+ cssBrotliKB: 6.5, // 5.6 (2026-10-22 the richer front page); 5.1 with members' reviews (2026-10-17); 4.6 (4.3, 1.7 before showcase.css)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};