openvibers/openvibe.host Commits
Host: a rate per GB and a stale price are not a server price (#241)
Changes
nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++/**++ * What a stored plan price means (server/catalog/pricing.js), shared by the catalog store and the pages:++ *++ * - a monthly server price: what a plan costs a month, the only price a server comparison may use;++ * - a per-GB storage rate: Azure's per-GB tiers keep their rate in price_monthly (server/catalog/mapping.js writes++ * the rate there and names the plan "<tier> (per GB/month)"), so it is not a monthly price and never joins a++ * server comparison; it is shown as the rate per GB it is;++ * - nothing: no price stated.++ *++ * A price last checked more than a year ago is stale: it is still shown and marked as stale, but it is never counted++ * as the cheapest plan or as a provider's "from" price.++ */++const STALE_AFTER_MS = 365 * 86400000;++const PER_GB_SUFFIX = '(per gb/month)';++++const toMs = (v) => {++ if (v == null) return null;++ if (typeof v === 'number') return Number.isFinite(v) ? v : null;++ const t = Date.parse(v);++ return Number.isFinite(t) ? t : null;++};++/** The plan carries a per-GB rate, not a monthly price. */++const isStorageRate = (p) => Boolean(p) && p.kind === 'storage' && typeof p.name === 'string' && p.name.toLowerCase().includes(PER_GB_SUFFIX);++/** The price was last checked more than a year ago. */++const isStale = (checkedAt, now) => { const t = toMs(checkedAt); return t != null && Number(now) - t > STALE_AFTER_MS; };++/** The oldest check that still counts as fresh (a plan checked before this is stale). */++const freshCutoff = (now) => Number(now) - STALE_AFTER_MS;++/** A stated monthly price a comparison may use: not a per-GB rate, not stale. */++const hasServerPrice = (p, now) => Number(p && p.price_monthly) > 0 && !isStorageRate(p) && !isStale(p.checked_at, now);++++module.exports = { STALE_AFTER_MS, PER_GB_SUFFIX, isStorageRate, isStale, freshCutoff, hasServerPrice };@@@ -19,23 +19,28 @@const { createSearchFeed } = require('openvibe-publishing/search-feed');const { money, bytesMb, sizeGb } = require('../render/catalog');const { displayTld, tldKind, KIND_LABEL } = require('./domains');++const { isStorageRate, isStale, freshCutoff } = require('./pricing');const KINDS = { cloud: 'Cloud', vps: 'VPS', dedicated: 'Dedicated server', storage: 'Storage', domain: 'Domain' };const day = (ms) => (Number.isFinite(Number(ms)) ? new Date(Number(ms)).toISOString().slice(0, 10) : null);const iso = (ms) => (Number.isFinite(Number(ms)) && ms != null ? new Date(Number(ms)).toISOString() : null);function planDocument(row) {-- const price = money(row.price_monthly, row.currency);++ // A per-GB storage rate is not a monthly price: the document names it as the rate per GB it is.++ const storage = isStorageRate(row);++ const price = money(row.price_monthly, row.currency, storage ? 4 : 2);++ const priceText = price ? (storage ? `${price} a GB a month` : `${price} a month`) : null;const transfer = row.unmetered ? 'unmetered transfer' : (sizeGb(row.bandwidth_gb) ? `${sizeGb(row.bandwidth_gb)} transfer` : null);const specs = [row.vcpu != null ? `${Number(row.vcpu)} vCPU` : null, bytesMb(row.ram_mb) ? `${bytesMb(row.ram_mb)} RAM` : null,sizeGb(row.disk_gb) ? `${sizeGb(row.disk_gb)}${row.disk_type ? ` ${row.disk_type.toUpperCase()}` : ''} disk` : null, transfer].filter(Boolean);const checked = day(row.checked_at);++ const stale = isStale(row.checked_at, Date.now()) ? ', stale' : '';return {listed: row.status === 'active' && row.provider_status === 'active',-- title: `${row.provider_name} ${row.name}${price ? `: ${price} a month` : ''}`,-- summary: [...specs, checked ? `checked ${checked}` : null].filter(Boolean).join(' · ') || null,++ title: `${row.provider_name} ${row.name}${priceText ? `: ${priceText}` : ''}`,++ summary: [...specs, checked ? `checked ${checked}${stale}` : null].filter(Boolean).join(' · ') || null,body: [`${row.provider_name} ${row.name}`, KINDS[row.kind] || row.kind, specs.join(', '),-- price ? `${price} a month (${row.currency})${checked ? `, checked ${checked}` : ''}` : null,++ priceText ? `${priceText} (${row.currency})${checked ? `, checked ${checked}${stale}` : ''}` : null,Array.isArray(row.locations) && row.locations.length ? `Locations: ${row.locations.join(', ')}` : null].filter(Boolean).join('\n'),facets: { provider: row.provider_slug, kind: row.kind, currency: row.currency },authorship: { mode: row.origin === 'sources' ? 'imported' : 'human' },@@@ -107,9 +112,9 @@ function createCatalogSearch({ config, store, outbox, log = console }) {// The cheapest active plan is named in its own currency (the first currency by price, USD first when tied).rows: (after, limit) => db.many(`SELECT v.*,(SELECT COUNT(*) FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active') AS plan_count,-- (SELECT p.price_monthly FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active' ORDER BY (p.currency <> 'USD'), p.price_monthly, p.id LIMIT 1) AS from_price,-- (SELECT p.currency FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active' ORDER BY (p.currency <> 'USD'), p.price_monthly, p.id LIMIT 1) AS from_currency-- FROM catalog_providers v WHERE v.id > $1 ORDER BY v.id LIMIT $2`, [after, limit]),++ (SELECT p.price_monthly FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active' AND p.price_monthly > 0 AND NOT (p.kind = 'storage' AND p.name ILIKE '%(per gb/month)%') AND p.checked_at >= $3 ORDER BY (p.currency <> 'USD'), p.price_monthly, p.id LIMIT 1) AS from_price,++ (SELECT p.currency FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active' AND p.price_monthly > 0 AND NOT (p.kind = 'storage' AND p.name ILIKE '%(per gb/month)%') AND p.checked_at >= $3 ORDER BY (p.currency <> 'USD'), p.price_monthly, p.id LIMIT 1) AS from_currency++ FROM catalog_providers v WHERE v.id > $1 ORDER BY v.id LIMIT $2`, [after, limit, freshCutoff(store.now())]),exists: async (ids) => (await db.many('SELECT id FROM catalog_providers WHERE id = ANY($1)', [ids])).map((r) => r.id),},tld: {@@@ -7,6 +7,15 @@ const offerFeed = require('./offers');const domainFeed = require('./domains');const { createDomainStore } = require('./domain-store');const { planTrend } = require('./trend');++const { isStorageRate, isStale, freshCutoff } = require('./pricing');++++// A plan's stored price is a monthly price, a per-GB storage rate (Azure's per-GB tiers, server/catalog/mapping.js)++// or nothing: only a monthly price is a server price. A price last checked over a year ago is stale and is never++// counted as the cheapest. Both rules are in SQL here so the order and the "from" price are the same rule.++const STORAGE_RATE_SQL = "(p.kind = 'storage' AND p.name ILIKE '%(per gb/month)%')";++const UNPRICED_SQL = `(p.price_monthly IS NULL OR p.price_monthly = 0 OR ${STORAGE_RATE_SQL})`;++const SERVER_PRICE_SQL = `(p.price_monthly > 0 AND NOT ${STORAGE_RATE_SQL})`;++const STALE_SQL = '(p.checked_at < ?)';const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };const fields = {@@@ -197,15 +206,17 @@ function createCatalog({ store, community = null, log = console, indexnow = nullconst rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);return { offers: rows.slice(0, 50).map(offerOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };}-- const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString() });++ const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString(), stale: isStale(p.checked_at, store.now()), price_unit: isStorageRate(p) ? 'per_gb_month' : 'month' });async function plans(query = {}) {const allowed = new Set(['kind', 'min_vcpu', 'min_ram_mb', 'min_disk_gb', 'max_price', 'currency', 'location', 'provider', 'sort', 'cursor']);for (const k of Object.keys(query)) if (!allowed.has(k) || Array.isArray(query[k])) invalid(`Invalid filter: ${k}`);const sort = query.sort || 'price';-- const sorts = { price: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN 1 ELSE 0 END, p.price_monthly',-- price_per_vcpu: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly / NULLIF(p.vcpu, 0) END',-- price_per_gb_ram: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly * 1024 / NULLIF(p.ram_mb, 0) END',-- price_per_gb_disk: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly / NULLIF(p.disk_gb, 0) END',++ // A plan with no monthly price (none stated, or a per-GB storage rate) and a stale price sort after every plan++ // a comparison may count, so the head of a price order is always a fresh monthly price.++ const sorts = { price: `${UNPRICED_SQL}, ${STALE_SQL}, p.price_monthly`,++ price_per_vcpu: `${UNPRICED_SQL}, ${STALE_SQL}, CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly / NULLIF(p.vcpu, 0) END`,++ price_per_gb_ram: `${UNPRICED_SQL}, ${STALE_SQL}, CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly * 1024 / NULLIF(p.ram_mb, 0) END`,++ price_per_gb_disk: `${UNPRICED_SQL}, ${STALE_SQL}, CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly / NULLIF(p.disk_gb, 0) END`,checked_at: 'p.checked_at' };if (!sorts[sort]) invalid('Invalid sort');const args = [query.currency || 'USD'];@@@ -217,6 +228,8 @@ function createCatalog({ store, community = null, log = console, indexnow = nullconst n = Number(query[key]); if (!Number.isFinite(n) || n < 0 || query[key] === '') invalid(`Invalid ${key}`);where += ` AND ${col} ${key === 'max_price' ? '<=' : '>='} ?`; args.push(n);}++ // The stale cutoff is the last of the ORDER BY placeholders, so it goes after the filters.++ if (sorts[sort].includes('?')) args.push(freshCutoff(store.now()));const offset = query.cursor == null ? 0 : Number(query.cursor);if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');// Affiliation is deliberately absent from the ORDER BY expression.@@@ -265,7 +278,10 @@ function createCatalog({ store, community = null, log = console, indexnow = nullif (slug) {const p = await db.prepare("SELECT * FROM catalog_providers WHERE slug = ? AND status = 'active'").get(slug);if (!p) missing();-- return { ...providerOut(p), plans: (await db.prepare("SELECT * FROM catalog_plans WHERE provider_id = ? AND status = 'active' ORDER BY CASE WHEN price_monthly IS NULL OR price_monthly = 0 THEN 1 ELSE 0 END, price_monthly, id").all(p.id)).map((row) => planOut({ ...row, provider_name: p.name, provider_slug: p.slug, affiliate_url: p.affiliate_url, affiliate_note: p.affiliate_note })), promos: await promos(p.id), domains: await domains.countFor(p.id) };++ // The provider's own list: fresh monthly prices first, then stale ones, then the plans with no monthly++ // price (a per-GB storage rate), so a rate per GB never reads as the cheapest plan.++ const rows = await db.prepare(`SELECT p.* FROM catalog_plans p WHERE p.provider_id = ? AND p.status = 'active' ORDER BY ${UNPRICED_SQL}, ${STALE_SQL}, p.price_monthly, p.id`).all(p.id, freshCutoff(store.now()));++ return { ...providerOut(p), plans: rows.map((row) => planOut({ ...row, provider_name: p.name, provider_slug: p.slug, affiliate_url: p.affiliate_url, affiliate_note: p.affiliate_note })), promos: await promos(p.id), domains: await domains.countFor(p.id) };}return (await db.prepare("SELECT * FROM catalog_providers WHERE status = 'active' ORDER BY name, id").all()).map(providerOut);}@@@ -286,7 +302,11 @@ function createCatalog({ store, community = null, log = console, indexnow = null}/** Each provider with active plans: how many, the lowest monthly price and the newest check (the front page's cards). */async function providerSummary(currency = 'USD') {-- const rows = await db.prepare("SELECT v.slug, v.name, COUNT(*) AS plans, MIN(CASE WHEN p.price_monthly > 0 THEN p.price_monthly END) AS from_price, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' AND p.currency = ? GROUP BY v.slug, v.name ORDER BY MIN(CASE WHEN p.price_monthly > 0 THEN p.price_monthly END) ASC, v.name").all(currency);++ // "From $X" is the cheapest fresh monthly price a reader can compare: a per-GB storage rate and a price last++ // checked over a year ago are not one.++ const cutoff = freshCutoff(store.now());++ const priced = `CASE WHEN ${SERVER_PRICE_SQL} AND p.checked_at >= ? THEN p.price_monthly END`;++ const rows = await db.prepare(`SELECT v.slug, v.name, COUNT(*) AS plans, MIN(${priced}) AS from_price, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' AND p.currency = ? GROUP BY v.slug, v.name ORDER BY MIN(${priced}) ASC NULLS LAST, v.name`).all(cutoff, currency, cutoff);return rows.map((r) => ({ slug: r.slug, name: r.name, plans: Number(r.plans) || 0, from_price: r.from_price == null ? null : Number(r.from_price), currency, last_checked: r.last == null ? null : Number(r.last) }));}/** Every public plan for a form's choice list: provider by name, then cheapest first. */@@@ -19,6 +19,7 @@ const kit = require('openvibe-shared/kit');const seo = require('openvibe-shared/seo');const { esc } = require('./layout');const { MAX_AGE_DAYS } = require('../catalog/offers');++const { isStorageRate, isStale } = require('../catalog/pricing');const DISCLOSURE = 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.';const KINDS = [['', 'Any kind'], ['cloud', 'Cloud'], ['vps', 'VPS'], ['dedicated', 'Dedicated'], ['storage', 'Storage'], ['domain', 'Domains']];@@@ -47,6 +48,9 @@ function sizeGb(gb) {}const transfer = (p) => (p.unmetered ? 'Unmetered' : sizeGb(p.bandwidth_gb));const day = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null; };++/** A price last checked more than a year ago is stale: it is shown, marked, and never counted as the cheapest. */++const staleMark = (p, now) => (isStale(p.checked_at, now) ? ', <span class="hc-stale" title="This price was last checked more than a year ago">stale</span>' : '');++const checkedText = (p, now) => `${esc(day(p.checked_at) || 'unknown')}${staleMark(p, now)}`;const or = (v) => (v == null ? UNKNOWN : esc(v));const kindName = (k) => (KINDS.find(([v]) => v === k) || [k, k])[1];const link = (href, label, cls = '') => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''}>${label}</a>`;@@@ -62,6 +66,8 @@ function visit(p, label = 'Visit provider') {function priceCell(p) {const price = Number(p.price_monthly);++ // A per-GB rate is not a monthly price: it is shown as the rate per GB it is (server/catalog/pricing.js).++ if (isStorageRate(p)) return `<span class="hc-price">${Number.isFinite(price) ? esc(money(price, p.currency, 4)) : 'price not stated'}</span><span class="hc-per">/GB/month</span>`;const m = Number.isFinite(price) && price > 0 ? money(price, p.currency) : null;return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>`;}@@@ -134,7 +140,7 @@ function tldTrendFigure(trends, { title = 'Domain price over time' } = {}) {return `<div class="hc-trend">${chart}${all.map(([name, t]) => trendSummary(t, name)).join('')}</div>`;}--function planTable(plans, { caption = 'Hosting plans', showProvider = true } = {}) {++function planTable(plans, { caption = 'Hosting plans', showProvider = true, now = Date.now() } = {}) {if (!plans.length) return '';const rows = plans.map((p) => {const provider = showProvider ? `<td data-label="Provider">${link(`/catalog/providers/${encodeURIComponent(p.provider_slug)}`, esc(p.provider_name || p.provider_slug))}${p.affiliate_url ? ' <span class="hc-chip" title="This provider has an affiliate link">Affiliate</span>' : ''}</td>` : '';@@@ -147,7 +153,7 @@ ${provider}<th scope="row" data-label="Plan">${link(`/catalog/plans/${encodeURIC<td data-label="Price" class="hc-n">${priceCell(p)}</td><td data-label="Per vCPU" class="hc-n hc-dim">${or(money(p.price_per_vcpu, p.currency, 2))}</td><td data-label="Per GB RAM" class="hc-n hc-dim">${or(money(p.price_per_gb_ram, p.currency, 2))}</td>--<td data-label="Checked" class="hc-checked">${or(day(p.checked_at))} ${out(p.source_url, 'source', { cls: 'hc-src' })}</td>++<td data-label="Checked" class="hc-checked">${checkedText(p, now)} ${out(p.source_url, 'source', { cls: 'hc-src' })}</td></tr>`;}).join('');return `<div class="hc-table-wrap"><table class="hc-table">@@@ -264,15 +270,19 @@ function reviewsSection(p) {<p class="hc-more">${out(write, `Review ${esc(p.name)}`, { cls: 'button' })} ${out(elsewhere, 'Other review sites →')}</p></section>`;}--function provider(p, discussion = null, extra = '') {-- const cheapest = p.plans.length ? p.plans.reduce((m, x) => (Number(x.price_monthly) < Number(m.price_monthly) ? x : m), p.plans[0]) : null;++function provider(p, discussion = null, extra = '', now = Date.now()) {++ // "From $X" counts the plans a reader can compare today: a stated monthly price that is not a per-GB storage rate++ // and was not last checked over a year ago.++ const comparable = p.plans.filter((x) => Number(x.price_monthly) > 0 && !isStorageRate(x) && !isStale(x.checked_at, now));++ const cheapest = comparable.length ? comparable.reduce((m, x) => (Number(x.price_monthly) < Number(m.price_monthly) ? x : m), comparable[0]) : null;++ const skipped = p.plans.length - comparable.length;return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link('/catalog/providers', 'Providers')}</nav><header class="hc-head hc-head-row"><div><h1>${esc(p.name)}</h1>${p.description ? `<p class="hc-lede">${esc(p.description)}</p>` : ''}<p class="hc-facts">${p.plans.length || !p.domains ? `${p.plans.length} plan${p.plans.length === 1 ? '' : 's'} listed` : ''}${cheapest ? ` · from <strong>${esc(money(cheapest.price_monthly, cheapest.currency))}</strong>/mo` : ''}${p.domains ? `${p.plans.length ? ' · ' : ''}${link(`/catalog/domains?registrar=${encodeURIComponent(p.slug)}&sort=tld`, `${p.domains.toLocaleString('en')} domain endings priced`)}` : ''}${p.homepage_url ? ` · ${out(p.homepage_url, esc(new URL(p.homepage_url).hostname))}` : ''}</p></div>${visit(p)}</header>--${p.plans.length ? planTable(p.plans, { caption: `${p.name}'s plans, lowest price first`, showProvider: false }) : p.domains ? `<p class="hc-more">${link(`/catalog/domains?registrar=${encodeURIComponent(p.slug)}&sort=tld`, `See ${esc(p.name)}'s domain prices →`, 'button')}</p>` : '<div class="hc-empty"><p>No plan of this provider is listed right now.</p></div>'}++${p.plans.length ? planTable(p.plans, { caption: `${p.name}'s plans, lowest price first`, showProvider: false, now }) + (skipped ? `<p class="hc-dim">${skipped} of these plans carry no monthly price (a rate per GB) or one last checked over a year ago: they are listed, but not counted in the price above.</p>` : '') : p.domains ? `<p class="hc-more">${link(`/catalog/domains?registrar=${encodeURIComponent(p.slug)}&sort=tld`, `See ${esc(p.name)}'s domain prices →`, 'button')}</p>` : '<div class="hc-empty"><p>No plan of this provider is listed right now.</p></div>'}${extra}${reviewsSection(p)}${discussionSection(discussion)}@@@ -281,7 +291,7 @@ ${promoCards(p.promos, { showProvider: false })}${p.affiliate_url || p.promos.some((x) => x.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;}--function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' } = {}) {++function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '', now = Date.now() } = {}) {const spec = [['vCPU', p.vcpu == null ? null : num(p.vcpu)], ['RAM', bytesMb(p.ram_mb)],['Disk', p.disk_gb == null ? null : `${sizeGb(p.disk_gb)}${p.disk_type ? ` ${p.disk_type.toUpperCase()}` : ''}`],@@@ -298,10 +308,12 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' }<header class="hc-head"><p class="hc-eyebrow">${esc(p.provider_name)} · ${esc(kindName(p.kind))}</p><h1>${esc(p.name)}</h1></header><div class="hc-plan"><section class="hc-price-card" aria-label="Price">--<p class="hc-big">${esc((() => { const price = Number(p.price_monthly); return (Number.isFinite(price) && price > 0) ? money(price, p.currency) : 'price not stated'; })())}<span>/month</span></p>++<p class="hc-big">${esc((() => { const price = Number(p.price_monthly); if (isStorageRate(p)) return Number.isFinite(price) ? money(price, p.currency, 4) : 'price not stated'; return (Number.isFinite(price) && price > 0) ? money(price, p.currency) : 'price not stated'; })())}<span>${isStorageRate(p) ? '/GB/month' : '/month'}</span></p>++${isStorageRate(p) ? '<p class="hc-dim">A storage rate: the price per GB a month, not a monthly server price.</p>' : ''}${p.price_hourly != null ? `<p class="hc-dim">${esc(money(p.price_hourly, p.currency, 4))} an hour</p>` : ''}${p.setup_fee != null && Number(p.setup_fee) > 0 ? `<p class="hc-dim">Setup fee ${esc(money(p.setup_fee, p.currency))}</p>` : ''}--<p class="hc-checked-line">Checked <strong>${esc(day(p.checked_at) || 'unknown')}</strong> · ${out(p.source_url, 'source')}</p>++<p class="hc-checked-line">Checked <strong>${esc(day(p.checked_at) || 'unknown')}</strong>${staleMark(p, now)} · ${out(p.source_url, 'source')}</p>++${isStale(p.checked_at, now) ? '<p class="hc-dim">This price was last checked over a year ago, so it is not counted as a cheapest plan.</p>' : ''}${visit(p)}</section><section aria-labelledby="hc-spec-h"><h2 id="hc-spec-h">What you get</h2><dl class="hc-spec">${spec.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${or(v)}</dd></div>`).join('')}</dl></section>@@@ -393,10 +405,12 @@ const jsonLd = {/** The front page of the hosting comparison. *//** A plan as a small card: what it is, its size and its price, opening the plan's page. */--const planCard = (label, p, note = '') => {++const planCard = (label, p, note = '', now = Date.now()) => {const price = Number(p.price_monthly);-- const priceLabel = Number.isFinite(price) && price > 0 ? money(price, p.currency) : 'price not stated';-- return `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span class="hh-label">${esc(label)}</span><b>${esc(p.provider_name)} ${esc(p.name)}</b><span class="hh-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb), sizeGb(p.disk_gb) ? `${sizeGb(p.disk_gb)} disk` : null].filter(Boolean).map(esc).join(' · ')}</span><span class="hh-price"><strong>${esc(priceLabel)}</strong>${priceLabel === 'price not stated' ? '' : ' a month'}${note ? ` · ${esc(note)}` : ''}</span>`)}</li>`;++ const storage = isStorageRate(p);++ const priceLabel = storage ? (Number.isFinite(price) ? money(price, p.currency, 4) : 'price not stated') : (Number.isFinite(price) && price > 0 ? money(price, p.currency) : 'price not stated');++ const unit = storage ? ' a GB a month' : ' a month';++ return `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span class="hh-label">${esc(label)}</span><b>${esc(p.provider_name)} ${esc(p.name)}</b><span class="hh-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb), sizeGb(p.disk_gb) ? `${sizeGb(p.disk_gb)} disk` : null].filter(Boolean).map(esc).join(' · ')}</span><span class="hh-price"><strong>${esc(priceLabel)}</strong>${priceLabel === 'price not stated' ? '' : unit}${note ? ` · ${esc(note)}` : ''}</span>${day(p.checked_at) ? `<span class="hh-spec">checked ${esc(day(p.checked_at))}${isStale(p.checked_at, now) ? ', stale' : ''}</span>` : ''}`)}</li>`;};const perUnit = (p, by) => {const price = Number(p.price_monthly);@@@ -405,7 +419,7 @@ const perUnit = (p, by) => {};/** The home's extra blocks, each from real rows and left out when it has none. */--function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], faq = [] }) {++function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], faq = [] }, now = Date.now()) {const stat = counts.filter((c) => c && c.value);const best = [['vcpu', 'Lowest price per vCPU', 'per vCPU'], ['ram', 'Lowest price per GB of RAM', 'per GB of RAM'], ['disk', 'Lowest price per GB of disk', 'per GB of disk']].filter(([k]) => value[k]).map(([k, label, unit]) => { const u = perUnit(value[k], k); return planCard(label, value[k], u ? `${u} ${unit}` : ''); });@@@ -413,13 +427,13 @@ function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], fastats: stat.length ? `<div class="sc-sec hh-stats-wrap"><ul class="hh-stats" aria-label="OpenVibe.Host in numbers">${stat.map((c) => `<li>${link(c.href, `<b>${esc(c.value.toLocaleString('en'))}</b><span>${esc(c.label)}</span>`)}</li>`).join('')}</ul></div>` : '',sizes: tiers.length ? `<section class="sc-sec" aria-labelledby="hh-sizes"><h2 id="hh-sizes">The lowest price for each size</h2><p class="sc-lede">The cheapest plan with at least this much memory, from every provider in the catalog.</p><ul class="hh-cards">${tiers.map((x) => planCard(`${x.gb} GB of RAM or more`, x.plan)).join('')}</ul></section>` : '',value: best.length ? `<section class="sc-sec" aria-labelledby="hh-value"><h2 id="hh-value">The most for your money</h2><p class="sc-lede">The same catalog sorted by what each dollar buys. ${link('/catalog?sort=price_per_gb_ram', 'Sort every plan this way')}.</p><ul class="hh-cards">${best.join('')}</ul></section>` : '',-- providers: providerCards.length ? `<section class="sc-sec" aria-labelledby="hh-providers"><h2 id="hh-providers">Providers compared</h2><p class="sc-lede">Each provider's plans in the catalog, from its lowest monthly price, with the day it was last checked.</p><ul class="hh-cards">${providerCards.map((v) => `<li>${link(`/catalog/providers/${encodeURIComponent(v.slug)}`, `<b>${esc(v.name)}</b><span class="hh-spec">${esc(`${v.plans} plan${v.plans === 1 ? '' : 's'}`)}${v.domains ? esc(` · ${v.domains.toLocaleString('en')} domain endings`) : ''}</span><span class="hh-price">from <strong>${esc(money(v.from_price, v.currency))}</strong> a month</span>${v.last_checked ? `<span class="hh-spec">checked ${esc(day(v.last_checked))}</span>` : ''}`)}</li>`).join('')}</ul></section>` : '',++ providers: providerCards.length ? `<section class="sc-sec" aria-labelledby="hh-providers"><h2 id="hh-providers">Providers compared</h2><p class="sc-lede">Each provider's plans in the catalog, from its lowest monthly price, with the day it was last checked.</p><ul class="hh-cards">${providerCards.map((v) => `<li>${link(`/catalog/providers/${encodeURIComponent(v.slug)}`, `<b>${esc(v.name)}</b><span class="hh-spec">${esc(`${v.plans} plan${v.plans === 1 ? '' : 's'}`)}${v.domains ? esc(` · ${v.domains.toLocaleString('en')} domain endings`) : ''}</span><span class="hh-price">${v.from_price != null ? `from <strong>${esc(money(v.from_price, v.currency))}</strong> a month` : 'no fresh monthly price yet'}</span>${v.last_checked ? `<span class="hh-spec">checked ${esc(day(v.last_checked))}${isStale(v.last_checked, now) ? ', stale' : ''}</span>` : ''}`)}</li>`).join('')}</ul></section>` : '',faq: faq.length ? `<section class="sc-sec" aria-labelledby="hh-faq"><h2 id="hh-faq">Questions people ask</h2><div class="hh-faq">${faq.map((f) => `<details><summary>${esc(f.question)}</summary><p>${esc(f.answer)}</p></details>`).join('')}</div></section>` : '',};}function front({ plans, providers, promos: live, stats, offers: recent = [], domains: domainSection = '', changes = [], more = {}, now = Date.now() }) {-- const extra = frontMore(more);++ const extra = frontMore(more, now);const changeRail = changes.length ? `<section class="sc-sec hh-changes" aria-labelledby="hh-changes-h"><h2 id="hh-changes-h">Price changes this month</h2><p class="hc-dim">Plans and domain endings whose stated price moved this month, biggest move first. Nothing is converted between currencies.</p><ol class="hc-changes">${changes.map((c) => {@@@ -429,7 +443,7 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom}).join('')}</ol></section>` : '';// The aside is servers: a plan with vCPU and a price above zero. Per-GB storage (archive tiers at $0.00 a GB once// rounded) read as four free "plans" at the top of the home (2026-11-01).-- const servers = plans.filter((p) => p.vcpu != null && Number(p.price_monthly) > 0);++ const servers = plans.filter((p) => p.vcpu != null && Number(p.price_monthly) > 0 && !isStale(p.checked_at, now));const aside = servers.length ? `<div class="hc-aside" aria-label="Lowest prices right now"><p class="hc-aside-h">Cheapest servers right now</p><ol>${servers.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>@@@ -444,7 +458,7 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], domactions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Domain prices', href: '/catalog/domains' }],note: stats.plans ? `${stats.plans} plans from ${stats.providers} provider${stats.providers === 1 ? '' : 's'} so far. More providers join as their sources are checked.` : 'The catalog is filling up: providers join as their official sources are checked and enabled.',aside: aside ? { html: aside } : undefined,-- }) + extra.stats + changeRail + `<section class="sc-sec hh-short" aria-labelledby="hh-cheapest"><h2 id="hh-cheapest">The lowest monthly prices</h2>${planTable(plans.slice(0, 8), { caption: 'Lowest price first' })}++ }) + extra.stats + changeRail + `<section class="sc-sec hh-short" aria-labelledby="hh-cheapest"><h2 id="hh-cheapest">The lowest monthly prices</h2>${planTable(plans.slice(0, 8), { caption: 'Lowest price first', now })}<p class="hc-more">${link('/catalog', 'Filter and sort every plan →', 'button')}</p></section>`+ extra.sizes + extra.value + extra.providers+ domainSectionnnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++/**++ * What a stored price means in the catalog (server/catalog/pricing.js): a per-GB storage rate is not a monthly++ * server price, and a price last checked over a year ago is stale. Neither is the cheapest plan, neither is a++ * provider's "from" price, and both are shown as what they are (a rate per GB, a stale price).++ */++const assert = require('assert');++const { testDb } = require('./helpers/db');++const { createStore } = require('../server/db');++const { createCatalog } = require('../server/catalog/store');++const render = require('../server/render/catalog');++const { check, done } = require('./helpers/boot');++++const NOW = Date.parse('2026-11-01T00:00:00Z');++const YEAR = 365 * 86400000;++++(async () => {++ const db = await testDb();++ const store = createStore(db.db, { now: () => NOW });++ const catalog = createCatalog({ store });++ try {++ const provider = await catalog.save('providers', { slug: 'azure', name: 'Microsoft Azure', homepage_url: 'https://azure.microsoft.com/' }, null, 'tester');++ const plan = async (over) => await catalog.save('plans', {++ provider_id: provider.id, external_id: over.external_id, name: over.name, kind: over.kind || 'cloud',++ currency: 'USD', price_monthly: over.price_monthly, source_url: 'https://azure.microsoft.com/en-us/pricing/',++ vcpu: over.vcpu == null ? null : over.vcpu, ram_mb: over.ram_mb == null ? null : over.ram_mb,++ disk_gb: over.disk_gb == null ? null : over.disk_gb, checked_at: over.checked_at == null ? NOW : over.checked_at,++ }, null, 'tester');++++ const fresh = await plan({ external_id: 'fresh', name: 'Standard_D2s_v5', price_monthly: 5, vcpu: 2, ram_mb: 8192, disk_gb: 64 });++ const stale = await plan({ external_id: 'stale', name: 'Standard_D1s_v5', price_monthly: 1, vcpu: 1, ram_mb: 2048, disk_gb: 32, checked_at: NOW - (YEAR + 86400000) });++ const rate = await plan({ external_id: 'rate', name: 'Archive LRS (per GB/month)', kind: 'storage', price_monthly: 0.001 });++ const unpriced = await plan({ external_id: 'unpriced', name: 'Free tier', price_monthly: 0 });++++ await check('a price list puts the fresh monthly prices first and the rates and the stale price last', async () => {++ const rows = (await catalog.plans({ provider: 'azure' })).plans;++ assert.deepStrictEqual(rows.map((x) => x.external_id), ['fresh', 'stale', 'unpriced', 'rate']);++ const listed = (await catalog.providers('azure')).plans;++ assert.deepStrictEqual(listed.map((x) => x.external_id), ['fresh', 'stale', 'unpriced', 'rate']);++ assert.strictEqual(rows[0].stale, false);++ assert.strictEqual(rows[1].stale, true, 'a price checked over a year ago is stale');++ assert.strictEqual(rows[3].price_unit, 'per_gb_month', 'a per-GB rate is not a monthly price');++ });++++ await check("a provider's from price is the cheapest fresh monthly price, not a rate per GB and not a stale price", async () => {++ const summary = await catalog.providerSummary();++ assert.strictEqual(summary.find((x) => x.slug === 'azure').from_price, 5);++ });++++ await check('the provider page shows the from price, the rate per GB and the stale price', async () => {++ const html = render.provider(await catalog.providers('azure'), null, '', NOW);++ assert.ok(html.includes('from <strong>$5.00</strong>/mo'), 'the from price is the fresh monthly price');++ assert.ok(!html.includes('$0.00</span><span class="hc-per">/mo</span>'), 'no rate per GB reads as a monthly price');++ assert.ok(html.includes('$0.0010'), 'the rate is shown as the rate it is');++ assert.ok(html.includes('/GB/month'), 'the rate is shown per GB');++ assert.ok(html.includes('2025-10-31, <span class="hc-stale"'), 'a stale price is marked as stale');++ assert.ok(html.includes('not counted in the price above'), 'the page says what is not counted');++ });++++ await check('a plan page shows a rate per GB as a rate and marks a stale price', async () => {++ const ratePage = render.plan(await catalog.plan(rate.id), null, { now: NOW });++ assert.ok(ratePage.includes('$0.0010'), 'the rate is the stored rate');++ assert.ok(ratePage.includes('/GB/month'), 'the rate is a rate per GB');++ assert.ok(!ratePage.includes('$0.00</span>'), 'a rate never reads as a monthly price');++ const stalePage = render.plan(await catalog.plan(stale.id), null, { now: NOW });++ assert.ok(stalePage.includes('Checked <strong>2025-10-31</strong>, <span class="hc-stale"'), 'the price is marked as stale');++ assert.ok(stalePage.includes('not counted as a cheapest plan'));++ });++ } finally {++ await db.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+/**
+ * What a stored plan price means (server/catalog/pricing.js), shared by the catalog store and the pages:
+ *
+ * - a monthly server price: what a plan costs a month, the only price a server comparison may use;
+ * - a per-GB storage rate: Azure's per-GB tiers keep their rate in price_monthly (server/catalog/mapping.js writes
+ * the rate there and names the plan "<tier> (per GB/month)"), so it is not a monthly price and never joins a
+ * server comparison; it is shown as the rate per GB it is;
+ * - nothing: no price stated.
+ *
+ * A price last checked more than a year ago is stale: it is still shown and marked as stale, but it is never counted
+ * as the cheapest plan or as a provider's "from" price.
+ */
+const STALE_AFTER_MS = 365 * 86400000;
+const PER_GB_SUFFIX = '(per gb/month)';
+
+const toMs = (v) => {
+ if (v == null) return null;
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
+ const t = Date.parse(v);
+ return Number.isFinite(t) ? t : null;
+};
+/** The plan carries a per-GB rate, not a monthly price. */
+const isStorageRate = (p) => Boolean(p) && p.kind === 'storage' && typeof p.name === 'string' && p.name.toLowerCase().includes(PER_GB_SUFFIX);
+/** The price was last checked more than a year ago. */
+const isStale = (checkedAt, now) => { const t = toMs(checkedAt); return t != null && Number(now) - t > STALE_AFTER_MS; };
+/** The oldest check that still counts as fresh (a plan checked before this is stale). */
+const freshCutoff = (now) => Number(now) - STALE_AFTER_MS;
+/** A stated monthly price a comparison may use: not a per-GB rate, not stale. */
+const hasServerPrice = (p, now) => Number(p && p.price_monthly) > 0 && !isStorageRate(p) && !isStale(p.checked_at, now);
+
+module.exports = { STALE_AFTER_MS, PER_GB_SUFFIX, isStorageRate, isStale, freshCutoff, hasServerPrice };
@@ -19,23 +19,28 @@
@@ -19,23 +19,28 @@
const { createSearchFeed } = require('openvibe-publishing/search-feed');
const { createSearchFeed } = require('openvibe-publishing/search-feed');
const { money, bytesMb, sizeGb } = require('../render/catalog');
const { money, bytesMb, sizeGb } = require('../render/catalog');
const { displayTld, tldKind, KIND_LABEL } = require('./domains');
const { displayTld, tldKind, KIND_LABEL } = require('./domains');
+const { isStorageRate, isStale, freshCutoff } = require('./pricing');
const KINDS = { cloud: 'Cloud', vps: 'VPS', dedicated: 'Dedicated server', storage: 'Storage', domain: 'Domain' };
const KINDS = { cloud: 'Cloud', vps: 'VPS', dedicated: 'Dedicated server', storage: 'Storage', domain: 'Domain' };
const day = (ms) => (Number.isFinite(Number(ms)) ? new Date(Number(ms)).toISOString().slice(0, 10) : null);
const day = (ms) => (Number.isFinite(Number(ms)) ? new Date(Number(ms)).toISOString().slice(0, 10) : null);
const iso = (ms) => (Number.isFinite(Number(ms)) && ms != null ? new Date(Number(ms)).toISOString() : null);
const iso = (ms) => (Number.isFinite(Number(ms)) && ms != null ? new Date(Number(ms)).toISOString() : null);
function planDocument(row) {
function planDocument(row) {
- const price = money(row.price_monthly, row.currency);
+ // A per-GB storage rate is not a monthly price: the document names it as the rate per GB it is.
+ const storage = isStorageRate(row);
+ const price = money(row.price_monthly, row.currency, storage ? 4 : 2);
+ const priceText = price ? (storage ? `${price} a GB a month` : `${price} a month`) : null;
const transfer = row.unmetered ? 'unmetered transfer' : (sizeGb(row.bandwidth_gb) ? `${sizeGb(row.bandwidth_gb)} transfer` : null);
const transfer = row.unmetered ? 'unmetered transfer' : (sizeGb(row.bandwidth_gb) ? `${sizeGb(row.bandwidth_gb)} transfer` : null);
const specs = [row.vcpu != null ? `${Number(row.vcpu)} vCPU` : null, bytesMb(row.ram_mb) ? `${bytesMb(row.ram_mb)} RAM` : null,
const specs = [row.vcpu != null ? `${Number(row.vcpu)} vCPU` : null, bytesMb(row.ram_mb) ? `${bytesMb(row.ram_mb)} RAM` : null,
sizeGb(row.disk_gb) ? `${sizeGb(row.disk_gb)}${row.disk_type ? ` ${row.disk_type.toUpperCase()}` : ''} disk` : null, transfer].filter(Boolean);
sizeGb(row.disk_gb) ? `${sizeGb(row.disk_gb)}${row.disk_type ? ` ${row.disk_type.toUpperCase()}` : ''} disk` : null, transfer].filter(Boolean);
const checked = day(row.checked_at);
const checked = day(row.checked_at);
+ const stale = isStale(row.checked_at, Date.now()) ? ', stale' : '';
return {
return {
listed: row.status === 'active' && row.provider_status === 'active',
listed: row.status === 'active' && row.provider_status === 'active',
- title: `${row.provider_name} ${row.name}${price ? `: ${price} a month` : ''}`,
- summary: [...specs, checked ? `checked ${checked}` : null].filter(Boolean).join(' · ') || null,
+ title: `${row.provider_name} ${row.name}${priceText ? `: ${priceText}` : ''}`,
+ summary: [...specs, checked ? `checked ${checked}${stale}` : null].filter(Boolean).join(' · ') || null,
body: [`${row.provider_name} ${row.name}`, KINDS[row.kind] || row.kind, specs.join(', '),
body: [`${row.provider_name} ${row.name}`, KINDS[row.kind] || row.kind, specs.join(', '),
- price ? `${price} a month (${row.currency})${checked ? `, checked ${checked}` : ''}` : null,
+ priceText ? `${priceText} (${row.currency})${checked ? `, checked ${checked}${stale}` : ''}` : null,
Array.isArray(row.locations) && row.locations.length ? `Locations: ${row.locations.join(', ')}` : null].filter(Boolean).join('\n'),
Array.isArray(row.locations) && row.locations.length ? `Locations: ${row.locations.join(', ')}` : null].filter(Boolean).join('\n'),
facets: { provider: row.provider_slug, kind: row.kind, currency: row.currency },
facets: { provider: row.provider_slug, kind: row.kind, currency: row.currency },
authorship: { mode: row.origin === 'sources' ? 'imported' : 'human' },
authorship: { mode: row.origin === 'sources' ? 'imported' : 'human' },
@@ -107,9 +112,9 @@ function createCatalogSearch({ config, store, outbox, log = console }) {
@@ -107,9 +112,9 @@ function createCatalogSearch({ config, store, outbox, log = console }) {
// The cheapest active plan is named in its own currency (the first currency by price, USD first when tied).
// The cheapest active plan is named in its own currency (the first currency by price, USD first when tied).
rows: (after, limit) => db.many(`SELECT v.*,
rows: (after, limit) => db.many(`SELECT v.*,
(SELECT COUNT(*) FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active') AS plan_count,
(SELECT COUNT(*) FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active') AS plan_count,
- (SELECT p.price_monthly FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active' ORDER BY (p.currency <> 'USD'), p.price_monthly, p.id LIMIT 1) AS from_price,
- (SELECT p.currency FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active' ORDER BY (p.currency <> 'USD'), p.price_monthly, p.id LIMIT 1) AS from_currency
- FROM catalog_providers v WHERE v.id > $1 ORDER BY v.id LIMIT $2`, [after, limit]),
+ (SELECT p.price_monthly FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active' AND p.price_monthly > 0 AND NOT (p.kind = 'storage' AND p.name ILIKE '%(per gb/month)%') AND p.checked_at >= $3 ORDER BY (p.currency <> 'USD'), p.price_monthly, p.id LIMIT 1) AS from_price,
+ (SELECT p.currency FROM catalog_plans p WHERE p.provider_id = v.id AND p.status = 'active' AND p.price_monthly > 0 AND NOT (p.kind = 'storage' AND p.name ILIKE '%(per gb/month)%') AND p.checked_at >= $3 ORDER BY (p.currency <> 'USD'), p.price_monthly, p.id LIMIT 1) AS from_currency
+ FROM catalog_providers v WHERE v.id > $1 ORDER BY v.id LIMIT $2`, [after, limit, freshCutoff(store.now())]),
exists: async (ids) => (await db.many('SELECT id FROM catalog_providers WHERE id = ANY($1)', [ids])).map((r) => r.id),
exists: async (ids) => (await db.many('SELECT id FROM catalog_providers WHERE id = ANY($1)', [ids])).map((r) => r.id),
},
},
tld: {
tld: {
@@ -7,6 +7,15 @@ const offerFeed = require('./offers');
@@ -7,6 +7,15 @@ const offerFeed = require('./offers');
const domainFeed = require('./domains');
const domainFeed = require('./domains');
const { createDomainStore } = require('./domain-store');
const { createDomainStore } = require('./domain-store');
const { planTrend } = require('./trend');
const { planTrend } = require('./trend');
+const { isStorageRate, isStale, freshCutoff } = require('./pricing');
+
+// A plan's stored price is a monthly price, a per-GB storage rate (Azure's per-GB tiers, server/catalog/mapping.js)
+// or nothing: only a monthly price is a server price. A price last checked over a year ago is stale and is never
+// counted as the cheapest. Both rules are in SQL here so the order and the "from" price are the same rule.
+const STORAGE_RATE_SQL = "(p.kind = 'storage' AND p.name ILIKE '%(per gb/month)%')";
+const UNPRICED_SQL = `(p.price_monthly IS NULL OR p.price_monthly = 0 OR ${STORAGE_RATE_SQL})`;
+const SERVER_PRICE_SQL = `(p.price_monthly > 0 AND NOT ${STORAGE_RATE_SQL})`;
+const STALE_SQL = '(p.checked_at < ?)';
const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };
const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };
const fields = {
const fields = {
@@ -197,15 +206,17 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -197,15 +206,17 @@ function createCatalog({ store, community = null, log = console, indexnow = null
const rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);
const rows = await db.prepare(`SELECT * FROM catalog_offers WHERE ${where} ORDER BY ${OFFER_SORTS[sort]} LIMIT 51 OFFSET ${offset}`).all(...args);
return { offers: rows.slice(0, 50).map(offerOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };
return { offers: rows.slice(0, 50).map(offerOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };
}
}
- const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString() });
+ const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString(), stale: isStale(p.checked_at, store.now()), price_unit: isStorageRate(p) ? 'per_gb_month' : 'month' });
async function plans(query = {}) {
async function plans(query = {}) {
const allowed = new Set(['kind', 'min_vcpu', 'min_ram_mb', 'min_disk_gb', 'max_price', 'currency', 'location', 'provider', 'sort', 'cursor']);
const allowed = new Set(['kind', 'min_vcpu', 'min_ram_mb', 'min_disk_gb', 'max_price', 'currency', 'location', 'provider', 'sort', 'cursor']);
for (const k of Object.keys(query)) if (!allowed.has(k) || Array.isArray(query[k])) invalid(`Invalid filter: ${k}`);
for (const k of Object.keys(query)) if (!allowed.has(k) || Array.isArray(query[k])) invalid(`Invalid filter: ${k}`);
const sort = query.sort || 'price';
const sort = query.sort || 'price';
- const sorts = { price: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN 1 ELSE 0 END, p.price_monthly',
- price_per_vcpu: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly / NULLIF(p.vcpu, 0) END',
- price_per_gb_ram: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly * 1024 / NULLIF(p.ram_mb, 0) END',
- price_per_gb_disk: 'CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly / NULLIF(p.disk_gb, 0) END',
+ // A plan with no monthly price (none stated, or a per-GB storage rate) and a stale price sort after every plan
+ // a comparison may count, so the head of a price order is always a fresh monthly price.
+ const sorts = { price: `${UNPRICED_SQL}, ${STALE_SQL}, p.price_monthly`,
+ price_per_vcpu: `${UNPRICED_SQL}, ${STALE_SQL}, CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly / NULLIF(p.vcpu, 0) END`,
+ price_per_gb_ram: `${UNPRICED_SQL}, ${STALE_SQL}, CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly * 1024 / NULLIF(p.ram_mb, 0) END`,
+ price_per_gb_disk: `${UNPRICED_SQL}, ${STALE_SQL}, CASE WHEN p.price_monthly IS NULL OR p.price_monthly = 0 THEN NULL ELSE p.price_monthly / NULLIF(p.disk_gb, 0) END`,
checked_at: 'p.checked_at' };
checked_at: 'p.checked_at' };
if (!sorts[sort]) invalid('Invalid sort');
if (!sorts[sort]) invalid('Invalid sort');
const args = [query.currency || 'USD'];
const args = [query.currency || 'USD'];
@@ -217,6 +228,8 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -217,6 +228,8 @@ function createCatalog({ store, community = null, log = console, indexnow = null
const n = Number(query[key]); if (!Number.isFinite(n) || n < 0 || query[key] === '') invalid(`Invalid ${key}`);
const n = Number(query[key]); if (!Number.isFinite(n) || n < 0 || query[key] === '') invalid(`Invalid ${key}`);
where += ` AND ${col} ${key === 'max_price' ? '<=' : '>='} ?`; args.push(n);
where += ` AND ${col} ${key === 'max_price' ? '<=' : '>='} ?`; args.push(n);
}
}
+ // The stale cutoff is the last of the ORDER BY placeholders, so it goes after the filters.
+ if (sorts[sort].includes('?')) args.push(freshCutoff(store.now()));
const offset = query.cursor == null ? 0 : Number(query.cursor);
const offset = query.cursor == null ? 0 : Number(query.cursor);
if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');
if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');
// Affiliation is deliberately absent from the ORDER BY expression.
// Affiliation is deliberately absent from the ORDER BY expression.
@@ -265,7 +278,10 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -265,7 +278,10 @@ function createCatalog({ store, community = null, log = console, indexnow = null
if (slug) {
if (slug) {
const p = await db.prepare("SELECT * FROM catalog_providers WHERE slug = ? AND status = 'active'").get(slug);
const p = await db.prepare("SELECT * FROM catalog_providers WHERE slug = ? AND status = 'active'").get(slug);
if (!p) missing();
if (!p) missing();
- return { ...providerOut(p), plans: (await db.prepare("SELECT * FROM catalog_plans WHERE provider_id = ? AND status = 'active' ORDER BY CASE WHEN price_monthly IS NULL OR price_monthly = 0 THEN 1 ELSE 0 END, price_monthly, id").all(p.id)).map((row) => planOut({ ...row, provider_name: p.name, provider_slug: p.slug, affiliate_url: p.affiliate_url, affiliate_note: p.affiliate_note })), promos: await promos(p.id), domains: await domains.countFor(p.id) };
+ // The provider's own list: fresh monthly prices first, then stale ones, then the plans with no monthly
+ // price (a per-GB storage rate), so a rate per GB never reads as the cheapest plan.
+ const rows = await db.prepare(`SELECT p.* FROM catalog_plans p WHERE p.provider_id = ? AND p.status = 'active' ORDER BY ${UNPRICED_SQL}, ${STALE_SQL}, p.price_monthly, p.id`).all(p.id, freshCutoff(store.now()));
+ return { ...providerOut(p), plans: rows.map((row) => planOut({ ...row, provider_name: p.name, provider_slug: p.slug, affiliate_url: p.affiliate_url, affiliate_note: p.affiliate_note })), promos: await promos(p.id), domains: await domains.countFor(p.id) };
}
}
return (await db.prepare("SELECT * FROM catalog_providers WHERE status = 'active' ORDER BY name, id").all()).map(providerOut);
return (await db.prepare("SELECT * FROM catalog_providers WHERE status = 'active' ORDER BY name, id").all()).map(providerOut);
}
}
@@ -286,7 +302,11 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -286,7 +302,11 @@ function createCatalog({ store, community = null, log = console, indexnow = null
}
}
/** Each provider with active plans: how many, the lowest monthly price and the newest check (the front page's cards). */
/** Each provider with active plans: how many, the lowest monthly price and the newest check (the front page's cards). */
async function providerSummary(currency = 'USD') {
async function providerSummary(currency = 'USD') {
- const rows = await db.prepare("SELECT v.slug, v.name, COUNT(*) AS plans, MIN(CASE WHEN p.price_monthly > 0 THEN p.price_monthly END) AS from_price, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' AND p.currency = ? GROUP BY v.slug, v.name ORDER BY MIN(CASE WHEN p.price_monthly > 0 THEN p.price_monthly END) ASC, v.name").all(currency);
+ // "From $X" is the cheapest fresh monthly price a reader can compare: a per-GB storage rate and a price last
+ // checked over a year ago are not one.
+ const cutoff = freshCutoff(store.now());
+ const priced = `CASE WHEN ${SERVER_PRICE_SQL} AND p.checked_at >= ? THEN p.price_monthly END`;
+ const rows = await db.prepare(`SELECT v.slug, v.name, COUNT(*) AS plans, MIN(${priced}) AS from_price, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' AND p.currency = ? GROUP BY v.slug, v.name ORDER BY MIN(${priced}) ASC NULLS LAST, v.name`).all(cutoff, currency, cutoff);
return rows.map((r) => ({ slug: r.slug, name: r.name, plans: Number(r.plans) || 0, from_price: r.from_price == null ? null : Number(r.from_price), currency, last_checked: r.last == null ? null : Number(r.last) }));
return rows.map((r) => ({ slug: r.slug, name: r.name, plans: Number(r.plans) || 0, from_price: r.from_price == null ? null : Number(r.from_price), currency, last_checked: r.last == null ? null : Number(r.last) }));
}
}
/** Every public plan for a form's choice list: provider by name, then cheapest first. */
/** Every public plan for a form's choice list: provider by name, then cheapest first. */
@@ -19,6 +19,7 @@ const kit = require('openvibe-shared/kit');
@@ -19,6 +19,7 @@ const kit = require('openvibe-shared/kit');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const { esc } = require('./layout');
const { esc } = require('./layout');
const { MAX_AGE_DAYS } = require('../catalog/offers');
const { MAX_AGE_DAYS } = require('../catalog/offers');
+const { isStorageRate, isStale } = require('../catalog/pricing');
const DISCLOSURE = 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.';
const DISCLOSURE = 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.';
const KINDS = [['', 'Any kind'], ['cloud', 'Cloud'], ['vps', 'VPS'], ['dedicated', 'Dedicated'], ['storage', 'Storage'], ['domain', 'Domains']];
const KINDS = [['', 'Any kind'], ['cloud', 'Cloud'], ['vps', 'VPS'], ['dedicated', 'Dedicated'], ['storage', 'Storage'], ['domain', 'Domains']];
@@ -47,6 +48,9 @@ function sizeGb(gb) {
@@ -47,6 +48,9 @@ function sizeGb(gb) {
}
}
const transfer = (p) => (p.unmetered ? 'Unmetered' : sizeGb(p.bandwidth_gb));
const transfer = (p) => (p.unmetered ? 'Unmetered' : sizeGb(p.bandwidth_gb));
const day = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null; };
const day = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null; };
+/** A price last checked more than a year ago is stale: it is shown, marked, and never counted as the cheapest. */
+const staleMark = (p, now) => (isStale(p.checked_at, now) ? ', <span class="hc-stale" title="This price was last checked more than a year ago">stale</span>' : '');
+const checkedText = (p, now) => `${esc(day(p.checked_at) || 'unknown')}${staleMark(p, now)}`;
const or = (v) => (v == null ? UNKNOWN : esc(v));
const or = (v) => (v == null ? UNKNOWN : esc(v));
const kindName = (k) => (KINDS.find(([v]) => v === k) || [k, k])[1];
const kindName = (k) => (KINDS.find(([v]) => v === k) || [k, k])[1];
const link = (href, label, cls = '') => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''}>${label}</a>`;
const link = (href, label, cls = '') => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''}>${label}</a>`;
@@ -62,6 +66,8 @@ function visit(p, label = 'Visit provider') {
@@ -62,6 +66,8 @@ function visit(p, label = 'Visit provider') {
function priceCell(p) {
function priceCell(p) {
const price = Number(p.price_monthly);
const price = Number(p.price_monthly);
+ // A per-GB rate is not a monthly price: it is shown as the rate per GB it is (server/catalog/pricing.js).
+ if (isStorageRate(p)) return `<span class="hc-price">${Number.isFinite(price) ? esc(money(price, p.currency, 4)) : 'price not stated'}</span><span class="hc-per">/GB/month</span>`;
const m = Number.isFinite(price) && price > 0 ? money(price, p.currency) : null;
const m = Number.isFinite(price) && price > 0 ? money(price, p.currency) : null;
return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>`;
return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>`;
}
}
@@ -134,7 +140,7 @@ function tldTrendFigure(trends, { title = 'Domain price over time' } = {}) {
@@ -134,7 +140,7 @@ function tldTrendFigure(trends, { title = 'Domain price over time' } = {}) {
return `<div class="hc-trend">${chart}${all.map(([name, t]) => trendSummary(t, name)).join('')}</div>`;
return `<div class="hc-trend">${chart}${all.map(([name, t]) => trendSummary(t, name)).join('')}</div>`;
}
}
-function planTable(plans, { caption = 'Hosting plans', showProvider = true } = {}) {
+function planTable(plans, { caption = 'Hosting plans', showProvider = true, now = Date.now() } = {}) {
if (!plans.length) return '';
if (!plans.length) return '';
const rows = plans.map((p) => {
const rows = plans.map((p) => {
const provider = showProvider ? `<td data-label="Provider">${link(`/catalog/providers/${encodeURIComponent(p.provider_slug)}`, esc(p.provider_name || p.provider_slug))}${p.affiliate_url ? ' <span class="hc-chip" title="This provider has an affiliate link">Affiliate</span>' : ''}</td>` : '';
const provider = showProvider ? `<td data-label="Provider">${link(`/catalog/providers/${encodeURIComponent(p.provider_slug)}`, esc(p.provider_name || p.provider_slug))}${p.affiliate_url ? ' <span class="hc-chip" title="This provider has an affiliate link">Affiliate</span>' : ''}</td>` : '';
@@ -147,7 +153,7 @@ ${provider}<th scope="row" data-label="Plan">${link(`/catalog/plans/${encodeURIC
@@ -147,7 +153,7 @@ ${provider}<th scope="row" data-label="Plan">${link(`/catalog/plans/${encodeURIC
<td data-label="Price" class="hc-n">${priceCell(p)}</td>
<td data-label="Price" class="hc-n">${priceCell(p)}</td>
<td data-label="Per vCPU" class="hc-n hc-dim">${or(money(p.price_per_vcpu, p.currency, 2))}</td>
<td data-label="Per vCPU" class="hc-n hc-dim">${or(money(p.price_per_vcpu, p.currency, 2))}</td>
<td data-label="Per GB RAM" class="hc-n hc-dim">${or(money(p.price_per_gb_ram, p.currency, 2))}</td>
<td data-label="Per GB RAM" class="hc-n hc-dim">${or(money(p.price_per_gb_ram, p.currency, 2))}</td>
-<td data-label="Checked" class="hc-checked">${or(day(p.checked_at))} ${out(p.source_url, 'source', { cls: 'hc-src' })}</td>
+<td data-label="Checked" class="hc-checked">${checkedText(p, now)} ${out(p.source_url, 'source', { cls: 'hc-src' })}</td>
</tr>`;
</tr>`;
}).join('');
}).join('');
return `<div class="hc-table-wrap"><table class="hc-table">
return `<div class="hc-table-wrap"><table class="hc-table">
@@ -264,15 +270,19 @@ function reviewsSection(p) {
@@ -264,15 +270,19 @@ function reviewsSection(p) {
<p class="hc-more">${out(write, `Review ${esc(p.name)}`, { cls: 'button' })} ${out(elsewhere, 'Other review sites →')}</p></section>`;
<p class="hc-more">${out(write, `Review ${esc(p.name)}`, { cls: 'button' })} ${out(elsewhere, 'Other review sites →')}</p></section>`;
}
}
-function provider(p, discussion = null, extra = '') {
- const cheapest = p.plans.length ? p.plans.reduce((m, x) => (Number(x.price_monthly) < Number(m.price_monthly) ? x : m), p.plans[0]) : null;
+function provider(p, discussion = null, extra = '', now = Date.now()) {
+ // "From $X" counts the plans a reader can compare today: a stated monthly price that is not a per-GB storage rate
+ // and was not last checked over a year ago.
+ const comparable = p.plans.filter((x) => Number(x.price_monthly) > 0 && !isStorageRate(x) && !isStale(x.checked_at, now));
+ const cheapest = comparable.length ? comparable.reduce((m, x) => (Number(x.price_monthly) < Number(m.price_monthly) ? x : m), comparable[0]) : null;
+ const skipped = p.plans.length - comparable.length;
return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link('/catalog/providers', 'Providers')}</nav>
return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link('/catalog/providers', 'Providers')}</nav>
<header class="hc-head hc-head-row">
<header class="hc-head hc-head-row">
<div><h1>${esc(p.name)}</h1>${p.description ? `<p class="hc-lede">${esc(p.description)}</p>` : ''}
<div><h1>${esc(p.name)}</h1>${p.description ? `<p class="hc-lede">${esc(p.description)}</p>` : ''}
<p class="hc-facts">${p.plans.length || !p.domains ? `${p.plans.length} plan${p.plans.length === 1 ? '' : 's'} listed` : ''}${cheapest ? ` · from <strong>${esc(money(cheapest.price_monthly, cheapest.currency))}</strong>/mo` : ''}${p.domains ? `${p.plans.length ? ' · ' : ''}${link(`/catalog/domains?registrar=${encodeURIComponent(p.slug)}&sort=tld`, `${p.domains.toLocaleString('en')} domain endings priced`)}` : ''}${p.homepage_url ? ` · ${out(p.homepage_url, esc(new URL(p.homepage_url).hostname))}` : ''}</p></div>
<p class="hc-facts">${p.plans.length || !p.domains ? `${p.plans.length} plan${p.plans.length === 1 ? '' : 's'} listed` : ''}${cheapest ? ` · from <strong>${esc(money(cheapest.price_monthly, cheapest.currency))}</strong>/mo` : ''}${p.domains ? `${p.plans.length ? ' · ' : ''}${link(`/catalog/domains?registrar=${encodeURIComponent(p.slug)}&sort=tld`, `${p.domains.toLocaleString('en')} domain endings priced`)}` : ''}${p.homepage_url ? ` · ${out(p.homepage_url, esc(new URL(p.homepage_url).hostname))}` : ''}</p></div>
${visit(p)}
${visit(p)}
</header>
</header>
-${p.plans.length ? planTable(p.plans, { caption: `${p.name}'s plans, lowest price first`, showProvider: false }) : p.domains ? `<p class="hc-more">${link(`/catalog/domains?registrar=${encodeURIComponent(p.slug)}&sort=tld`, `See ${esc(p.name)}'s domain prices →`, 'button')}</p>` : '<div class="hc-empty"><p>No plan of this provider is listed right now.</p></div>'}
+${p.plans.length ? planTable(p.plans, { caption: `${p.name}'s plans, lowest price first`, showProvider: false, now }) + (skipped ? `<p class="hc-dim">${skipped} of these plans carry no monthly price (a rate per GB) or one last checked over a year ago: they are listed, but not counted in the price above.</p>` : '') : p.domains ? `<p class="hc-more">${link(`/catalog/domains?registrar=${encodeURIComponent(p.slug)}&sort=tld`, `See ${esc(p.name)}'s domain prices →`, 'button')}</p>` : '<div class="hc-empty"><p>No plan of this provider is listed right now.</p></div>'}
${extra}
${extra}
${reviewsSection(p)}
${reviewsSection(p)}
${discussionSection(discussion)}
${discussionSection(discussion)}
@@ -281,7 +291,7 @@ ${promoCards(p.promos, { showProvider: false })}
@@ -281,7 +291,7 @@ ${promoCards(p.promos, { showProvider: false })}
${p.affiliate_url || p.promos.some((x) => x.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;
${p.affiliate_url || p.promos.some((x) => x.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;
}
}
-function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' } = {}) {
+function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '', now = Date.now() } = {}) {
const spec = [
const spec = [
['vCPU', p.vcpu == null ? null : num(p.vcpu)], ['RAM', bytesMb(p.ram_mb)],
['vCPU', p.vcpu == null ? null : num(p.vcpu)], ['RAM', bytesMb(p.ram_mb)],
['Disk', p.disk_gb == null ? null : `${sizeGb(p.disk_gb)}${p.disk_type ? ` ${p.disk_type.toUpperCase()}` : ''}`],
['Disk', p.disk_gb == null ? null : `${sizeGb(p.disk_gb)}${p.disk_type ? ` ${p.disk_type.toUpperCase()}` : ''}`],
@@ -298,10 +308,12 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' }
@@ -298,10 +308,12 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' }
<header class="hc-head"><p class="hc-eyebrow">${esc(p.provider_name)} · ${esc(kindName(p.kind))}</p><h1>${esc(p.name)}</h1></header>
<header class="hc-head"><p class="hc-eyebrow">${esc(p.provider_name)} · ${esc(kindName(p.kind))}</p><h1>${esc(p.name)}</h1></header>
<div class="hc-plan">
<div class="hc-plan">
<section class="hc-price-card" aria-label="Price">
<section class="hc-price-card" aria-label="Price">
-<p class="hc-big">${esc((() => { const price = Number(p.price_monthly); return (Number.isFinite(price) && price > 0) ? money(price, p.currency) : 'price not stated'; })())}<span>/month</span></p>
+<p class="hc-big">${esc((() => { const price = Number(p.price_monthly); if (isStorageRate(p)) return Number.isFinite(price) ? money(price, p.currency, 4) : 'price not stated'; return (Number.isFinite(price) && price > 0) ? money(price, p.currency) : 'price not stated'; })())}<span>${isStorageRate(p) ? '/GB/month' : '/month'}</span></p>
+${isStorageRate(p) ? '<p class="hc-dim">A storage rate: the price per GB a month, not a monthly server price.</p>' : ''}
${p.price_hourly != null ? `<p class="hc-dim">${esc(money(p.price_hourly, p.currency, 4))} an hour</p>` : ''}
${p.price_hourly != null ? `<p class="hc-dim">${esc(money(p.price_hourly, p.currency, 4))} an hour</p>` : ''}
${p.setup_fee != null && Number(p.setup_fee) > 0 ? `<p class="hc-dim">Setup fee ${esc(money(p.setup_fee, p.currency))}</p>` : ''}
${p.setup_fee != null && Number(p.setup_fee) > 0 ? `<p class="hc-dim">Setup fee ${esc(money(p.setup_fee, p.currency))}</p>` : ''}
-<p class="hc-checked-line">Checked <strong>${esc(day(p.checked_at) || 'unknown')}</strong> · ${out(p.source_url, 'source')}</p>
+<p class="hc-checked-line">Checked <strong>${esc(day(p.checked_at) || 'unknown')}</strong>${staleMark(p, now)} · ${out(p.source_url, 'source')}</p>
+${isStale(p.checked_at, now) ? '<p class="hc-dim">This price was last checked over a year ago, so it is not counted as a cheapest plan.</p>' : ''}
${visit(p)}
${visit(p)}
</section>
</section>
<section aria-labelledby="hc-spec-h"><h2 id="hc-spec-h">What you get</h2><dl class="hc-spec">${spec.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${or(v)}</dd></div>`).join('')}</dl></section>
<section aria-labelledby="hc-spec-h"><h2 id="hc-spec-h">What you get</h2><dl class="hc-spec">${spec.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${or(v)}</dd></div>`).join('')}</dl></section>
@@ -393,10 +405,12 @@ const jsonLd = {
@@ -393,10 +405,12 @@ const jsonLd = {
/** The front page of the hosting comparison. */
/** The front page of the hosting comparison. */
/** A plan as a small card: what it is, its size and its price, opening the plan's page. */
/** A plan as a small card: what it is, its size and its price, opening the plan's page. */
-const planCard = (label, p, note = '') => {
+const planCard = (label, p, note = '', now = Date.now()) => {
const price = Number(p.price_monthly);
const price = Number(p.price_monthly);
- const priceLabel = Number.isFinite(price) && price > 0 ? money(price, p.currency) : 'price not stated';
- return `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span class="hh-label">${esc(label)}</span><b>${esc(p.provider_name)} ${esc(p.name)}</b><span class="hh-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb), sizeGb(p.disk_gb) ? `${sizeGb(p.disk_gb)} disk` : null].filter(Boolean).map(esc).join(' · ')}</span><span class="hh-price"><strong>${esc(priceLabel)}</strong>${priceLabel === 'price not stated' ? '' : ' a month'}${note ? ` · ${esc(note)}` : ''}</span>`)}</li>`;
+ const storage = isStorageRate(p);
+ const priceLabel = storage ? (Number.isFinite(price) ? money(price, p.currency, 4) : 'price not stated') : (Number.isFinite(price) && price > 0 ? money(price, p.currency) : 'price not stated');
+ const unit = storage ? ' a GB a month' : ' a month';
+ return `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span class="hh-label">${esc(label)}</span><b>${esc(p.provider_name)} ${esc(p.name)}</b><span class="hh-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb), sizeGb(p.disk_gb) ? `${sizeGb(p.disk_gb)} disk` : null].filter(Boolean).map(esc).join(' · ')}</span><span class="hh-price"><strong>${esc(priceLabel)}</strong>${priceLabel === 'price not stated' ? '' : unit}${note ? ` · ${esc(note)}` : ''}</span>${day(p.checked_at) ? `<span class="hh-spec">checked ${esc(day(p.checked_at))}${isStale(p.checked_at, now) ? ', stale' : ''}</span>` : ''}`)}</li>`;
};
};
const perUnit = (p, by) => {
const perUnit = (p, by) => {
const price = Number(p.price_monthly);
const price = Number(p.price_monthly);
@@ -405,7 +419,7 @@ const perUnit = (p, by) => {
@@ -405,7 +419,7 @@ const perUnit = (p, by) => {
};
};
/** The home's extra blocks, each from real rows and left out when it has none. */
/** The home's extra blocks, each from real rows and left out when it has none. */
-function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], faq = [] }) {
+function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], faq = [] }, now = Date.now()) {
const stat = counts.filter((c) => c && c.value);
const stat = counts.filter((c) => c && c.value);
const best = [['vcpu', 'Lowest price per vCPU', 'per vCPU'], ['ram', 'Lowest price per GB of RAM', 'per GB of RAM'], ['disk', 'Lowest price per GB of disk', 'per GB of disk']]
const best = [['vcpu', 'Lowest price per vCPU', 'per vCPU'], ['ram', 'Lowest price per GB of RAM', 'per GB of RAM'], ['disk', 'Lowest price per GB of disk', 'per GB of disk']]
.filter(([k]) => value[k]).map(([k, label, unit]) => { const u = perUnit(value[k], k); return planCard(label, value[k], u ? `${u} ${unit}` : ''); });
.filter(([k]) => value[k]).map(([k, label, unit]) => { const u = perUnit(value[k], k); return planCard(label, value[k], u ? `${u} ${unit}` : ''); });
@@ -413,13 +427,13 @@ function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], fa
@@ -413,13 +427,13 @@ function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], fa
stats: stat.length ? `<div class="sc-sec hh-stats-wrap"><ul class="hh-stats" aria-label="OpenVibe.Host in numbers">${stat.map((c) => `<li>${link(c.href, `<b>${esc(c.value.toLocaleString('en'))}</b><span>${esc(c.label)}</span>`)}</li>`).join('')}</ul></div>` : '',
stats: stat.length ? `<div class="sc-sec hh-stats-wrap"><ul class="hh-stats" aria-label="OpenVibe.Host in numbers">${stat.map((c) => `<li>${link(c.href, `<b>${esc(c.value.toLocaleString('en'))}</b><span>${esc(c.label)}</span>`)}</li>`).join('')}</ul></div>` : '',
sizes: tiers.length ? `<section class="sc-sec" aria-labelledby="hh-sizes"><h2 id="hh-sizes">The lowest price for each size</h2><p class="sc-lede">The cheapest plan with at least this much memory, from every provider in the catalog.</p><ul class="hh-cards">${tiers.map((x) => planCard(`${x.gb} GB of RAM or more`, x.plan)).join('')}</ul></section>` : '',
sizes: tiers.length ? `<section class="sc-sec" aria-labelledby="hh-sizes"><h2 id="hh-sizes">The lowest price for each size</h2><p class="sc-lede">The cheapest plan with at least this much memory, from every provider in the catalog.</p><ul class="hh-cards">${tiers.map((x) => planCard(`${x.gb} GB of RAM or more`, x.plan)).join('')}</ul></section>` : '',
value: best.length ? `<section class="sc-sec" aria-labelledby="hh-value"><h2 id="hh-value">The most for your money</h2><p class="sc-lede">The same catalog sorted by what each dollar buys. ${link('/catalog?sort=price_per_gb_ram', 'Sort every plan this way')}.</p><ul class="hh-cards">${best.join('')}</ul></section>` : '',
value: best.length ? `<section class="sc-sec" aria-labelledby="hh-value"><h2 id="hh-value">The most for your money</h2><p class="sc-lede">The same catalog sorted by what each dollar buys. ${link('/catalog?sort=price_per_gb_ram', 'Sort every plan this way')}.</p><ul class="hh-cards">${best.join('')}</ul></section>` : '',
- providers: providerCards.length ? `<section class="sc-sec" aria-labelledby="hh-providers"><h2 id="hh-providers">Providers compared</h2><p class="sc-lede">Each provider's plans in the catalog, from its lowest monthly price, with the day it was last checked.</p><ul class="hh-cards">${providerCards.map((v) => `<li>${link(`/catalog/providers/${encodeURIComponent(v.slug)}`, `<b>${esc(v.name)}</b><span class="hh-spec">${esc(`${v.plans} plan${v.plans === 1 ? '' : 's'}`)}${v.domains ? esc(` · ${v.domains.toLocaleString('en')} domain endings`) : ''}</span><span class="hh-price">from <strong>${esc(money(v.from_price, v.currency))}</strong> a month</span>${v.last_checked ? `<span class="hh-spec">checked ${esc(day(v.last_checked))}</span>` : ''}`)}</li>`).join('')}</ul></section>` : '',
+ providers: providerCards.length ? `<section class="sc-sec" aria-labelledby="hh-providers"><h2 id="hh-providers">Providers compared</h2><p class="sc-lede">Each provider's plans in the catalog, from its lowest monthly price, with the day it was last checked.</p><ul class="hh-cards">${providerCards.map((v) => `<li>${link(`/catalog/providers/${encodeURIComponent(v.slug)}`, `<b>${esc(v.name)}</b><span class="hh-spec">${esc(`${v.plans} plan${v.plans === 1 ? '' : 's'}`)}${v.domains ? esc(` · ${v.domains.toLocaleString('en')} domain endings`) : ''}</span><span class="hh-price">${v.from_price != null ? `from <strong>${esc(money(v.from_price, v.currency))}</strong> a month` : 'no fresh monthly price yet'}</span>${v.last_checked ? `<span class="hh-spec">checked ${esc(day(v.last_checked))}${isStale(v.last_checked, now) ? ', stale' : ''}</span>` : ''}`)}</li>`).join('')}</ul></section>` : '',
faq: faq.length ? `<section class="sc-sec" aria-labelledby="hh-faq"><h2 id="hh-faq">Questions people ask</h2><div class="hh-faq">${faq.map((f) => `<details><summary>${esc(f.question)}</summary><p>${esc(f.answer)}</p></details>`).join('')}</div></section>` : '',
faq: faq.length ? `<section class="sc-sec" aria-labelledby="hh-faq"><h2 id="hh-faq">Questions people ask</h2><div class="hh-faq">${faq.map((f) => `<details><summary>${esc(f.question)}</summary><p>${esc(f.answer)}</p></details>`).join('')}</div></section>` : '',
};
};
}
}
function front({ plans, providers, promos: live, stats, offers: recent = [], domains: domainSection = '', changes = [], more = {}, now = Date.now() }) {
function front({ plans, providers, promos: live, stats, offers: recent = [], domains: domainSection = '', changes = [], more = {}, now = Date.now() }) {
- const extra = frontMore(more);
+ const extra = frontMore(more, now);
const changeRail = changes.length ? `<section class="sc-sec hh-changes" aria-labelledby="hh-changes-h"><h2 id="hh-changes-h">Price changes this month</h2>
const changeRail = changes.length ? `<section class="sc-sec hh-changes" aria-labelledby="hh-changes-h"><h2 id="hh-changes-h">Price changes this month</h2>
<p class="hc-dim">Plans and domain endings whose stated price moved this month, biggest move first. Nothing is converted between currencies.</p>
<p class="hc-dim">Plans and domain endings whose stated price moved this month, biggest move first. Nothing is converted between currencies.</p>
<ol class="hc-changes">${changes.map((c) => {
<ol class="hc-changes">${changes.map((c) => {
@@ -429,7 +443,7 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
@@ -429,7 +443,7 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
}).join('')}</ol></section>` : '';
}).join('')}</ol></section>` : '';
// The aside is servers: a plan with vCPU and a price above zero. Per-GB storage (archive tiers at $0.00 a GB once
// The aside is servers: a plan with vCPU and a price above zero. Per-GB storage (archive tiers at $0.00 a GB once
// rounded) read as four free "plans" at the top of the home (2026-11-01).
// rounded) read as four free "plans" at the top of the home (2026-11-01).
- const servers = plans.filter((p) => p.vcpu != null && Number(p.price_monthly) > 0);
+ const servers = plans.filter((p) => p.vcpu != null && Number(p.price_monthly) > 0 && !isStale(p.checked_at, now));
const aside = servers.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
const aside = servers.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
<p class="hc-aside-h">Cheapest servers right now</p>
<p class="hc-aside-h">Cheapest servers right now</p>
<ol>${servers.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>
<ol>${servers.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>
@@ -444,7 +458,7 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
@@ -444,7 +458,7 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
actions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Domain prices', href: '/catalog/domains' }],
actions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Domain prices', href: '/catalog/domains' }],
note: stats.plans ? `${stats.plans} plans from ${stats.providers} provider${stats.providers === 1 ? '' : 's'} so far. More providers join as their sources are checked.` : 'The catalog is filling up: providers join as their official sources are checked and enabled.',
note: stats.plans ? `${stats.plans} plans from ${stats.providers} provider${stats.providers === 1 ? '' : 's'} so far. More providers join as their sources are checked.` : 'The catalog is filling up: providers join as their official sources are checked and enabled.',
aside: aside ? { html: aside } : undefined,
aside: aside ? { html: aside } : undefined,
- }) + extra.stats + changeRail + `<section class="sc-sec hh-short" aria-labelledby="hh-cheapest"><h2 id="hh-cheapest">The lowest monthly prices</h2>${planTable(plans.slice(0, 8), { caption: 'Lowest price first' })}
+ }) + extra.stats + changeRail + `<section class="sc-sec hh-short" aria-labelledby="hh-cheapest"><h2 id="hh-cheapest">The lowest monthly prices</h2>${planTable(plans.slice(0, 8), { caption: 'Lowest price first', now })}
<p class="hc-more">${link('/catalog', 'Filter and sort every plan →', 'button')}</p></section>`
<p class="hc-more">${link('/catalog', 'Filter and sort every plan →', 'button')}</p></section>`
+ extra.sizes + extra.value + extra.providers
+ extra.sizes + extra.value + extra.providers
+ domainSection
+ domainSection
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+/**
+ * What a stored price means in the catalog (server/catalog/pricing.js): a per-GB storage rate is not a monthly
+ * server price, and a price last checked over a year ago is stale. Neither is the cheapest plan, neither is a
+ * provider's "from" price, and both are shown as what they are (a rate per GB, a stale price).
+ */
+const assert = require('assert');
+const { testDb } = require('./helpers/db');
+const { createStore } = require('../server/db');
+const { createCatalog } = require('../server/catalog/store');
+const render = require('../server/render/catalog');
+const { check, done } = require('./helpers/boot');
+
+const NOW = Date.parse('2026-11-01T00:00:00Z');
+const YEAR = 365 * 86400000;
+
+(async () => {
+ const db = await testDb();
+ const store = createStore(db.db, { now: () => NOW });
+ const catalog = createCatalog({ store });
+ try {
+ const provider = await catalog.save('providers', { slug: 'azure', name: 'Microsoft Azure', homepage_url: 'https://azure.microsoft.com/' }, null, 'tester');
+ const plan = async (over) => await catalog.save('plans', {
+ provider_id: provider.id, external_id: over.external_id, name: over.name, kind: over.kind || 'cloud',
+ currency: 'USD', price_monthly: over.price_monthly, source_url: 'https://azure.microsoft.com/en-us/pricing/',
+ vcpu: over.vcpu == null ? null : over.vcpu, ram_mb: over.ram_mb == null ? null : over.ram_mb,
+ disk_gb: over.disk_gb == null ? null : over.disk_gb, checked_at: over.checked_at == null ? NOW : over.checked_at,
+ }, null, 'tester');
+
+ const fresh = await plan({ external_id: 'fresh', name: 'Standard_D2s_v5', price_monthly: 5, vcpu: 2, ram_mb: 8192, disk_gb: 64 });
+ const stale = await plan({ external_id: 'stale', name: 'Standard_D1s_v5', price_monthly: 1, vcpu: 1, ram_mb: 2048, disk_gb: 32, checked_at: NOW - (YEAR + 86400000) });
+ const rate = await plan({ external_id: 'rate', name: 'Archive LRS (per GB/month)', kind: 'storage', price_monthly: 0.001 });
+ const unpriced = await plan({ external_id: 'unpriced', name: 'Free tier', price_monthly: 0 });
+
+ await check('a price list puts the fresh monthly prices first and the rates and the stale price last', async () => {
+ const rows = (await catalog.plans({ provider: 'azure' })).plans;
+ assert.deepStrictEqual(rows.map((x) => x.external_id), ['fresh', 'stale', 'unpriced', 'rate']);
+ const listed = (await catalog.providers('azure')).plans;
+ assert.deepStrictEqual(listed.map((x) => x.external_id), ['fresh', 'stale', 'unpriced', 'rate']);
+ assert.strictEqual(rows[0].stale, false);
+ assert.strictEqual(rows[1].stale, true, 'a price checked over a year ago is stale');
+ assert.strictEqual(rows[3].price_unit, 'per_gb_month', 'a per-GB rate is not a monthly price');
+ });
+
+ await check("a provider's from price is the cheapest fresh monthly price, not a rate per GB and not a stale price", async () => {
+ const summary = await catalog.providerSummary();
+ assert.strictEqual(summary.find((x) => x.slug === 'azure').from_price, 5);
+ });
+
+ await check('the provider page shows the from price, the rate per GB and the stale price', async () => {
+ const html = render.provider(await catalog.providers('azure'), null, '', NOW);
+ assert.ok(html.includes('from <strong>$5.00</strong>/mo'), 'the from price is the fresh monthly price');
+ assert.ok(!html.includes('$0.00</span><span class="hc-per">/mo</span>'), 'no rate per GB reads as a monthly price');
+ assert.ok(html.includes('$0.0010'), 'the rate is shown as the rate it is');
+ assert.ok(html.includes('/GB/month'), 'the rate is shown per GB');
+ assert.ok(html.includes('2025-10-31, <span class="hc-stale"'), 'a stale price is marked as stale');
+ assert.ok(html.includes('not counted in the price above'), 'the page says what is not counted');
+ });
+
+ await check('a plan page shows a rate per GB as a rate and marks a stale price', async () => {
+ const ratePage = render.plan(await catalog.plan(rate.id), null, { now: NOW });
+ assert.ok(ratePage.includes('$0.0010'), 'the rate is the stored rate');
+ assert.ok(ratePage.includes('/GB/month'), 'the rate is a rate per GB');
+ assert.ok(!ratePage.includes('$0.00</span>'), 'a rate never reads as a monthly price');
+ const stalePage = render.plan(await catalog.plan(stale.id), null, { now: NOW });
+ assert.ok(stalePage.includes('Checked <strong>2025-10-31</strong>, <span class="hc-stale"'), 'the price is marked as stale');
+ assert.ok(stalePage.includes('not counted as a cheapest plan'));
+ });
+ } finally {
+ await db.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });