openvibers/openvibe.host Commits

Host: a rate per GB and a stale price are not a server price (#241)

OP OpenVibers committed 12 days ago · fc9446e92f · 5 files, +174 −30

Azure's per-GB tiers keep their rate in price_monthly, so the provider page read
$0.0010 a GB as $0.00 a month and listed 24 rates first; a price last checked over a
year ago was counted as current. A price order now puts fresh monthly prices first,
then stale ones, then plans with no monthly price, and a provider's from price is the
cheapest fresh monthly price. A rate is shown per GB, a stale price is marked stale.

Changes

ddiff --git a/server/catalog/pricing.js b/server/catalog/pricing.js
nnew file mode 100644
iindex 0000000..bb2f920
---- /dev/null
++++ b/server/catalog/pricing.js
@@@ -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 };
ddiff --git a/server/catalog/search-index.js b/server/catalog/search-index.js
iindex a66ade3..4935c69 100644
---- a/server/catalog/search-index.js
++++ b/server/catalog/search-index.js
@@@ -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: {
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex 6e4b3b8..b679b4b 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -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 = null
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 };
}
-- 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 = null
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);
}
++ // 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 = null
if (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. */
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex 6f08fdf..c5e56dd 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -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 = [], 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>` : '',
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 = [], dom
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.',
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
+ domainSection
ddiff --git a/test/catalog-pricing.test.js b/test/catalog-pricing.test.js
nnew file mode 100644
iindex 0000000..a290ab8
---- /dev/null
++++ b/test/catalog-pricing.test.js
@@@ -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); });