openvibers/openvibe.host Commits
Host: price trends for plans and TLDs, the home rail and the trend API (#210)
Changes
@@@ -26,10 +26,11 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m- Nothing is estimated. A value the source does not state is shown as Unknown.- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.- **Domains** (`/catalog/domains`, `/catalog/domains/:tld`, `/catalog/domains/check?name=`, a block on the front page; `GET /api/v1/catalog/domains`, `/domains/:tld`, `/domains/check?name=`). What registrars charge for each ending (the owner's "like tld-list.com"): the first year, a renewal, a transfer and five years added up (the first year and four renewals at one registrar), with a mark where the renewal is more than a fifth above the first year. Prices come from registrars' public price lists through Sources (`porkbun-tld-pricing`, Porkbun's documented keyless list, `server/catalog/domains.js`; Handshake names are left out) into `catalog_tld_prices` (migration 0011) with an append-only change history; the registrar is a catalog provider. Endings sort popular first, by first year, renewal, five years or A to Z, and filter by ending and kind (generic, country code, second level, internationalized). A name check (`server/catalog/domain-check.js`) reads what was typed (a URL, a bare label checked as .com, the longest priced suffix as the ending), asks the ending's registry over RDAP (`server/catalog/rdap.js`: IANA's bootstrap file, https servers only, no redirects, 6 s timeout, answers cached 10 minutes, 30 checks a minute per address) and prices the same name at the popular endings; every registrar's search is a plain deep link. Before migration 0011 applies the price sections are left out and the check still works. `/domains` (static hosting's old page) redirects here.++- **Price history.** A plan's page and a TLD's page draw the price over time from the append-only change rows (`catalog_price_changes`, `catalog_tld_price_changes`) with the shared `kit.chart` step chart: from the first observation to today, the current price as the last point, with "Was $X on <date>, now $Y (−Z%)", the low and the high; the full change list folds under "Every change". A TLD draws the cheapest registration and renewal at any registrar. One observation only shows the price with "No changes recorded since <date>" and no chart. The catalog home has a "Price changes this month" rail (up to 8, biggest move first, rose/dropped in words). `GET /api/v1/catalog/plans/:id/price-trend` and `/api/v1/catalog/tlds/:tld/price-trend?kind=register|renew` return `{ points: [{ at, price }], currency, now, low, high, change: { amount, percent } | null }`.- **Benchmarks** (`/catalog/benchmarks`, `/catalog/benchmarks/:id`, `/catalog/benchmarks/new`; `GET /api/v1/catalog/benchmarks`, `/benchmarks/:id`, `POST /benchmarks/links`, `DELETE /benchmarks/:id`). The owner's "benchmarks … user submittable" (Q21): a signed-in person names the provider (a catalog provider or plan, or another provider by name) and gets a one-time link (24 hours, 10 a day); `curl -sL https://yabs.sh | bash -s -- -s "<link>"` runs YABS on their server, which posts its JSON to `POST /api/v1/catalog/benchmarks/yabs/:token` and prints Host's one-line answer (the result's address). `server/catalog/benchmarks.js` reads the JSON field by field (CPU, memory, disk, the newest Geekbench with only a browser.geekbench.com link, fio in MB/s and IOPS, iperf3, the city and network YABS looked up; never the server's address) into `catalog_benchmarks` (migration 0012). Results list newest first or by Geekbench or 4k IOPS, filter by provider or plan; plan and provider pages show the medians. The submitter is never shown; they, or staff, can remove a result; account export carries results and links, and erasure deletes the links and leaves the results anonymous.- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.--- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history), `/providers`, `/providers/:slug` and `/promos`.++- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history and its trend), `/plans/:id/price-trend`, `/tlds/:tld/price-trend`, `/providers`, `/providers/:slug` and `/promos`.- **Search.** Every public plan and provider page is an OpenVibe.Search document (`host.index_document.upserted|deleted`, `server/catalog/search-index.js` through openvibe-publishing/search-feed): sent a minute after boot, hourly, after each catalog pull and shortly after a staff change; only changed pages are sent, and a hidden or gone entry becomes a tombstone.- **IndexNow.** With `INDEXNOW_KEY`, a catalog pull that adds a plan or a provider, or changes a plan's price, sends those pages and `/sitemap.xml` to search engines in one batch (a re-check at the same price sends nothing); a new offer post sends `/catalog/offers`. The key is served at `/<key>.txt`.- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`, `/catalog/promos`, `/catalog/offers`, `/catalog/domains` (with each ending's page and the name check) and `/catalog/benchmarks`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.@@@ -265,3 +265,29 @@.hc-more > a:only-child { position: relative; }.hc-more > a:only-child::after { content: ''; position: absolute; left: 0; right: 0; top: -13px; bottom: -13px; }}++++/* Price over time (server/render/catalog.js trendFigure, render/domains.js tldTrendFigure): the shared kit draws the++ chart; these rules only place it and its one-line summary. Theme tokens only. */++.hc-trend { margin: 0 0 1.25rem; }++.hc-trend .ovk-chart { margin: 0 0 .5rem; }++.hc-trend-summary { margin: .25rem 0 1rem; color: var(--hs-muted); font-size: 14px; }++.hc-trend-summary strong { color: var(--hs-fg); }++.hc-history-fold { margin: 1rem 0; border: 1px solid var(--hs-line); border-radius: 10px; background: var(--hs-card); }++.hc-history-fold > summary { display: flex; align-items: center; min-height: 44px; padding: 0 .9rem; cursor: pointer; font-weight: 600; }++.hc-history-fold > summary:focus-visible { outline: 2px solid var(--hs-accent); outline-offset: -2px; border-radius: 10px; }++.hc-history-fold .hc-history { margin: 0; padding: .25rem 1.2rem 1rem 2.2rem; }++++/* The catalog home's "Price changes this month" rail: words say rose or dropped; colour only repeats them. */++.hh-changes .hc-changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); }++.hc-changes li { border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); }++.hc-changes a { display: flex; flex-wrap: wrap; gap: 8px 14px; justify-content: space-between; align-items: baseline; min-height: 44px; box-sizing: border-box; padding: 12px 14px; color: inherit; text-decoration: none; }++.hc-changes a:hover, .hc-changes a:focus-visible { outline: 2px solid var(--hs-accent); outline-offset: -2px; border-radius: 12px; }++.hc-change-what { display: flex; flex-direction: column; gap: 2px; min-width: 0; }++.hc-change-what b { color: var(--hs-fg); overflow-wrap: anywhere; }++.hc-change-what .hc-dim { font-size: .82rem; }++.hc-change-move { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; white-space: nowrap; font-size: .85rem; margin-left: auto; }++.hc-change-word { font-weight: 700; }++.hc-change-move.is-up .hc-change-word { color: var(--hs-warn); }++.hc-change-move.is-down .hc-change-word { color: var(--hs-ok); }++.hc-change-prices { color: var(--hs-muted); font-variant-numeric: tabular-nums; }++@media (max-width: 400px) { .hc-change-move { align-items: flex-start; margin-left: 0; } }@@@ -42,3 +42,7 @@ ul.domains { padding-left: 1.2rem; } ul.domains li { margin-bottom: 1rem; }.hh-stats::-webkit-scrollbar { display: none; }.hh-stats li { flex: 0 0 auto; min-width: 128px; }}++++/* The shared footer's patch-notes link is nowrap; a long title pushes a 320px page sideways (found on the catalog++ home, 2026-10-30). It wraps like the rest of the footer. */++a.ov-shipped-notes { white-space: normal; overflow-wrap: anywhere; max-width: 100%; }@@@ -214,7 +214,7 @@ async function createApp(opts = {}) {// The front page is built by the shared view builder (server/http/catalog-views.js), the same one// the agent-layer twin uses, so the two cannot drift.const view = await views.front();-- const o = { title: view.title, description: view.description, body: view.body, viewer: req.viewer, config, path: '/', indexable: true, styles: view.styles, css: view.css, jsonLd: view.jsonLd, agent: true };++ const o = { title: view.title, description: view.description, body: view.body, viewer: req.viewer, config, path: '/', indexable: true, styles: view.styles, scripts: view.scripts, css: view.css, jsonLd: view.jsonLd, agent: true };const link = twinHeader(o);if (link) res.set('Link', link);res.type('html').send(renderPage(o));@@@ -6,6 +6,7 @@const { newId } = require('../ids');const { ApiError } = require('../http/errors');const domains = require('./domains');++const { tldTrend } = require('./trend');const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };const PAGE = 100;@@@ -112,7 +113,7 @@ function createDomainStore({ store, announce = () => {} }) {const rows = await db.prepare(`SELECT p.*, ${FIVE_YEAR} AS five_year, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_noteFROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.tld = ? ORDER BY p.register ASC NULLS LAST, v.name, p.id`).all(t);const history = rows.length ? await db.prepare(`SELECT h.*, v.name AS provider_name FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id-- WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC LIMIT 20`).all(t) : [];++ WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC`).all(t) : [];const data = { rows, history };if (!data.rows.length) throw new ApiError(404, 'host.catalog.not_found', 'No registrar price is listed for this ending');const prices = data.rows.map((r) => ({@@@ -124,13 +125,24 @@ function createDomainStore({ store, announce = () => {} }) {return {tld: t, display: domains.displayTld(t), kind: domains.tldKind(t), currency: 'USD', prices,cheapest: { register: min('register'), renew: min('renew'), transfer: min('transfer'), five_year: min('five_year') },-- history: data.history.map((h) => ({++ price_trend: tldTrend(rows, data.history, store.now()), history_count: data.history.length,++ history: data.history.slice(0, 20).map((h) => ({registrar: h.provider_name, observed_at: iso(h.observed_at), currency: h.currency,register: [n(h.register_before), n(h.register_after)], renew: [n(h.renew_before), n(h.renew_after)], transfer: [n(h.transfer_before), n(h.transfer_after)],})),};}++ /** One field of a TLD's price over time: register (default) or renew, the cheapest at any registrar. */++ async function trend(input, kind = 'register') {++ if (!['register', 'renew'].includes(kind)) throw new ApiError(422, 'host.catalog.invalid', 'kind must be register or renew');++ const data = await tld(input);++ const field = kind === 'renew' ? 'renew' : 'register';++ const series = data.price_trend && data.price_trend[field];++ if (!series) throw new ApiError(404, 'host.catalog.not_found', `No ${field === 'renew' ? 'renewal' : 'registration'} price history for this ending`);++ return { ...series, kind: field };++ }++/** The cheapest prices for a few endings (a name check's alternatives), in the order asked. */async function cheapest(tlds) {const list = [...new Set(tlds.map((x) => domains.normalizeTld(x)).filter(Boolean))].slice(0, 30);@@@ -164,7 +176,7 @@ function createDomainStore({ store, announce = () => {} }) {return Number((await db.prepare("SELECT COUNT(*) AS n FROM catalog_tld_prices WHERE provider_id = ? AND status = 'active'").get(providerId)).n) || 0;}-- return { apply, list, tld, cheapest, knownTlds, stats, sitemap, countFor, SORTS: Object.keys(SORTS), KINDS: Object.keys(KINDS) };++ return { apply, list, tld, trend, cheapest, knownTlds, stats, sitemap, countFor, SORTS: Object.keys(SORTS), KINDS: Object.keys(KINDS) };}module.exports = { createDomainStore };@@@ -6,6 +6,7 @@ const { mapItem } = require('./mapping');const offerFeed = require('./offers');const domainFeed = require('./domains');const { createDomainStore } = require('./domain-store');++const { planTrend } = require('./trend');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 = {@@@ -221,7 +222,40 @@ function createCatalog({ store, community = null, log = console, indexnow = nullasync function plan(id) {const p = await db.prepare("SELECT p.*, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.id = ? AND p.status = 'active' AND v.status = 'active'").get(id);if (!p) missing();-- return { ...planOut(p), price_history: (await db.prepare('SELECT * FROM catalog_price_changes WHERE plan_id = ? ORDER BY observed_at DESC').all(id)).map((h) => ({ ...h, checked_at: new Date(Number(h.observed_at)).toISOString(), source_url: p.source_url })) };++ const changes = await db.prepare('SELECT * FROM catalog_price_changes WHERE plan_id = ? ORDER BY observed_at DESC').all(id);++ return { ...planOut(p), price_history: changes.map((h) => ({ ...h, checked_at: new Date(Number(h.observed_at)).toISOString(), source_url: p.source_url })), price_trend: planTrend(p, changes, store.now()) };++ }++ /** The plan's price over time (the same shape as the page's chart and GET /plans/:id/price-trend). */++ async function planPriceTrend(id) { return (await plan(id)).price_trend; }++ /**++ * The biggest price changes of the current UTC month, from plans and TLD prices, for the catalog home's rail:++ * newest first after the size of the move, in the currency each price was stated in (nothing converted).++ */++ async function recentChanges(limit = 8) {++ const now = store.now();++ const d = new Date(now);++ const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);++ const planRows = await db.prepare(`SELECT h.*, p.id AS plan_id, p.name AS plan_name, p.currency AS plan_currency, v.name AS provider_name++ FROM catalog_price_changes h JOIN catalog_plans p ON p.id = h.plan_id JOIN catalog_providers v ON v.id = p.provider_id++ WHERE h.observed_at >= ? AND p.status = 'active' AND v.status = 'active'`).all(start);++ const tldRows = await db.prepare(`SELECT h.*, p.tld, v.name AS provider_name++ FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id++ WHERE h.observed_at >= ? AND p.status = 'active' AND v.status = 'active' AND h.currency = 'USD'`).all(start);++ const pct = (before, after) => (Number(before) > 0 ? Math.round(((Number(after) - Number(before)) / Number(before)) * 1000) / 10 : null);++ const items = [];++ for (const h of planRows) if ((h.currency || null) === (h.plan_currency || null)) items.push({++ type: 'plan', kind: 'monthly price', name: `${h.provider_name} ${h.plan_name}`, href: `/catalog/plans/${encodeURIComponent(h.plan_id)}`,++ before: Number(h.price_monthly_before), after: Number(h.price_monthly_after), currency: h.currency, at: new Date(Number(h.observed_at)).toISOString(), percent: pct(h.price_monthly_before, h.price_monthly_after),++ });++ for (const h of tldRows) for (const [field, kind] of [['register', 'registration'], ['renew', 'renewal'], ['transfer', 'transfer']]) {++ const before = h[`${field}_before`], after = h[`${field}_after`];++ if (before == null || after == null || Number(before) === Number(after)) continue;++ items.push({++ type: 'tld', kind, name: domainFeed.displayTld(h.tld), href: `/catalog/domains/${encodeURIComponent(h.tld)}`,++ before: Number(before), after: Number(after), currency: h.currency, at: new Date(Number(h.observed_at)).toISOString(), percent: pct(before, after),++ });++ }++ return items.sort((a, b) => (Math.abs(b.percent ?? 0) - Math.abs(a.percent ?? 0)) || (Date.parse(b.at) - Date.parse(a.at)) || a.name.localeCompare(b.name, 'en')).slice(0, limit);}async function providers(slug) {if (slug) {@@@ -255,6 +289,6 @@ function createCatalog({ store, community = null, log = console, indexnow = nullasync function planChoices() { return await db.prepare("SELECT p.id, p.name, v.name AS provider_name, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY v.name, p.price_monthly, p.id LIMIT 1000").all(); }// Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }-- return { onChange: (fn) => listeners.push(fn), save, get, applyItem, domains, plans, plan, providers, promos, offers, currencies: currenciesInUse, stats, providerSummary, sitemapPlans, planChoices, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };++ return { onChange: (fn) => listeners.push(fn), save, get, applyItem, domains, plans, plan, planPriceTrend, recentChanges, providers, promos, offers, currencies: currenciesInUse, stats, providerSummary, sitemapPlans, planChoices, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };}module.exports = { createCatalog };nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++/**++ * Price over time from the catalog's append-only observations (migrations/0005 and 0011), shared by a plan's page,++ * a TLD's page and the two price-trend API routes.++ *++ * A trend is the step a price held: every point starts when the price was first seen or changed, and the current++ * price is always the last point at `now`. A price is never interpolated and two currencies are never compared: only++ * the observation rows in the price's own currency count.++ *++ * trend(points, currency) → { points: [{ at, price }], currency, now, low, high, change }++ * points oldest first, `at` an ISO date-time, `price` a number++ * now the current price (the last point's price)++ * low/high the lowest and highest price in the points (a tie goes to the earliest)++ * change { amount, percent } | null (null with one observation); percent is a whole number, null at a++ * first price of 0++ */++++const round4 = (n) => Math.round(n * 10000) / 10000;++const finite = (n) => Number.isFinite(Number(n)) ? Number(n) : null;++const at = (ms) => new Date(Number(ms)).toISOString();++++function trend(points, currency) {++ const clean = (points || [])++ .map((p) => ({ at: Number(p.at), price: finite(p.price) }))++ .filter((p) => Number.isFinite(p.at) && p.price != null)++ .sort((a, b) => a.at - b.at);++ // One price per moment, the last written wins (a first_seen_at that equals a change's time is the change).++ const byTime = [];++ for (const p of clean) {++ if (byTime.length && byTime[byTime.length - 1].at === p.at) byTime[byTime.length - 1] = p;++ else byTime.push(p);++ }++ if (!byTime.length) return null;++ const first = byTime[0];++ const last = byTime[byTime.length - 1];++ // Ties go to the earliest moment: "lowest since" is when it was first that low.++ const low = byTime.reduce((a, p) => (p.price < a.price ? p : a));++ const high = byTime.reduce((a, p) => (p.price > a.price ? p : a));++ const amount = round4(last.price - first.price);++ return {++ points: byTime.map((p) => ({ at: at(p.at), price: round4(p.price) })),++ currency: currency || null,++ now: round4(last.price),++ low: round4(low.price),++ high: round4(high.price),++ change: byTime.length > 1 ? {++ amount,++ percent: first.price > 0 ? Math.round((amount / first.price) * 100) : null,++ } : null,++ };++}++++/**++ * A plan's price over time: the price first seen at first_seen_at, every catalog_price_changes row in the plan's++ * current currency, and the current price at `now`. Changes rows may arrive newest first (the store's query) or in++ * any order. With no change the plan has one observation.++ */++function planTrend(plan, changes, now) {++ if (!plan) return null;++ const currency = plan.currency || null;++ const current = finite(plan.price_monthly);++ if (current == null) return null;++ const rows = (changes || [])++ .filter((c) => (c.currency || null) === (currency || null) && finite(c.observed_at) != null)++ .sort((a, b) => Number(a.observed_at) - Number(b.observed_at));++ const firstAt = finite(plan.first_seen_at) ?? finite(plan.checked_at) ?? now;++ const points = [];++ if (rows.length) {++ const before = finite(rows[0].price_monthly_before);++ if (before != null) points.push({ at: firstAt, price: before });++ for (const c of rows) {++ const after = finite(c.price_monthly_after);++ if (after != null) points.push({ at: Number(c.observed_at), price: after });++ }++ } else {++ points.push({ at: firstAt, price: current });++ }++ // The current price is always the last point once there is a change, even when the last change was weeks++ // ago; with no change at all the plan has exactly one observation and no chart.++ if (rows.length) {++ const end = Math.max(Number(now) || 0, ...points.map((p) => p.at));++ const last = points[points.length - 1];++ if (!last || last.at !== end || last.price !== current) points.push({ at: end, price: current });++ }++ return trend(points, currency);++}++++// ── TLDs ──────────────────────────────────────────────────────────────────────++// A TLD's page shows the cheapest registration and renewal at any registrar. Each registrar's price is a step++// function rebuilt from its own first sighting and change rows; the market's cheapest price at a moment is the++// lowest of those steps. A price that appears after the TLD was first seen joins at the moment it appears.++++/** One registrar's step function for `field`, from its first sighting, changes and current value. */++function registrarSteps(row, changes, field) {++ const rows = (changes || [])++ .filter((c) => c.price_id === row.id && (c.currency || null) === (row.currency || null))++ .sort((a, b) => Number(a.observed_at) - Number(b.observed_at));++ const steps = [];++ if (rows.length) {++ const before = finite(rows[0][`${field}_before`]);++ if (before != null) steps.push({ at: Number(row.first_seen_at), value: before });++ for (const c of rows) steps.push({ at: Number(c.observed_at), value: finite(c[`${field}_after`]) });++ } else {++ const value = finite(row[field]);++ if (value != null) steps.push({ at: Number(row.first_seen_at), value });++ }++ // The current value still holds at `now`.++ if (steps.length) steps.push({ at: Number(row.checked_at), value: finite(row[field]) });++ return steps;++}++++const stepAt = (steps, time) => {++ let value = null;++ for (const s of steps) {++ if (s.at > time) break;++ value = s.value;++ }++ return value;++};++++/** Turn a market value function into step points: the value before each change and the new value at it. */++function marketSteps(events, valueAt, now) {++ const points = [];++ for (const t of events) {++ if (t === events[0]) {++ const v = valueAt(t);++ if (v != null) points.push({ at: t, value: v });++ continue;++ }++ const before = valueAt(t - 1);++ const after = valueAt(t);++ if (before === after) continue;++ if (before != null) points.push({ at: t - 1, value: before });++ if (after != null) points.push({ at: t, value: after });++ }++ const last = points[points.length - 1];++ const current = valueAt(now);++ if (current != null && (!last || last.at < now)) points.push({ at: now, value: current });++ return points;++}++++/**++ * The cheapest registration and renewal over time for one TLD.++ * → { currency, register: trend, renew: trend } (a field nobody ever priced is null).++ */++function tldTrend(rows, changes, now) {++ const prices = (rows || []).filter((r) => (r.currency || null) === 'USD');++ if (!prices.length) return null;++ const timelines = prices.map((row) => ({++ register: registrarSteps(row, changes, 'register'),++ renew: registrarSteps(row, changes, 'renew'),++ }));++ const times = [...new Set([++ ...prices.map((r) => Number(r.first_seen_at)),++ ...(changes || []).map((c) => Number(c.observed_at)),++ Number(now),++ ].filter(Number.isFinite))].sort((a, b) => a - b);++ const market = (field) => {++ const valueAt = (t) => {++ const values = prices.map((_row, i) => stepAt(timelines[i][field], t)).filter((v) => v != null);++ return values.length ? Math.min(...values) : null;++ };++ return marketSteps(times, valueAt, Number(now));++ };++ const register = market('register');++ const renew = market('renew');++ return {++ currency: 'USD',++ register: register.length ? trend(register.map((p) => ({ at: p.at, price: p.value })), 'USD') : null,++ renew: renew.length ? trend(renew.map((p) => ({ at: p.at, price: p.value })), 'USD') : null,++ };++}++++module.exports = { trend, planTrend, tldTrend };@@@ -42,9 +42,9 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons/** The front page: the same hero, tables, offers, promos, domain section and FAQ the route renders. */async function front() {-- const [result, providers, promos, stats, recent, popular, domainStats, cheapDomains, providerCards] = await Promise.all([++ const [result, providers, promos, stats, recent, popular, domainStats, cheapDomains, providerCards, changes] = await Promise.all([catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({}),-- catalog.domains.list({}), catalog.domains.stats(), catalog.domains.list({ sort: 'register' }), catalog.providerSummary(),++ catalog.domains.list({}), catalog.domains.stats(), catalog.domains.list({ sort: 'register' }), catalog.providerSummary(), catalog.recentChanges(),]);// The lowest price for each size, and the most each dollar buys: the same plan query the catalog page runs.const first = async (q) => (await catalog.plans(q)).plans[0] || null;@@@ -64,7 +64,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = constitle: 'Hosting plans and domain prices, compared',description: HOME_DESCRIPTION,body: render.front({-- plans: result.plans, providers, promos, stats, offers: recent.offers, more,++ plans: result.plans, providers, promos, stats, offers: recent.offers, changes, more,domains: renderDomains.frontSection({ popular: popular.tlds, cheap: cheapDomains.tlds, stats: domainStats }),}),indexable: true,@@@ -114,6 +114,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = consreturn {title: p.provider_name + ' ' + p.name,body: render.plan(p, discussion, { baseUrl, watchUrl, extra: benchHtml }),++ styles: ['kit.css', 'kit-data.css'], scripts: ['kit-data-web.js'],indexable: true,description: p.provider_name + ' ' + p.name + ': ' + p.currency + ' ' + p.price_monthly + ' a month, with its specifications, price per resource and price history.',jsonLd: render.jsonLd.plan(baseUrl, p),@@@ -148,6 +149,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = consconst t = await catalog.domains.tld(tld);return {title: t.display + ' domain prices', body: renderDomains.tld(t, { baseUrl, watchUrl }), indexable: true,++ styles: ['kit.css', 'kit-data.css'], scripts: ['kit-data-web.js'],description: t.display + ' domains from ' + (render.money(t.cheapest.register, 'USD') || 'an unknown price') + ' the first year, renewing from ' + (render.money(t.cheapest.renew, 'USD') || 'an unknown price') + ": every registrar's price, dated and sourced.",jsonLd: renderDomains.jsonLd.tld(baseUrl, t),};@@@ -25,6 +25,8 @@ function createCatalogApi({ catalog, domainCheck, benchmarks, viewers, actorLimiconst staff = (req, _res, next) => req.viewer.kind === 'user' && req.viewer.staff ? next() : next(new ApiError(403, 'host.catalog.forbidden', 'Staff access required'));router.get('/plans', run(async (req) => await catalog.plans(req.query)));router.get('/plans/:id', run(async (req) => ({ plan: await catalog.plan(req.params.id) })));++ router.get('/plans/:id/price-trend', run(async (req) => await catalog.planPriceTrend(req.params.id)));++ router.get('/tlds/:tld/price-trend', run(async (req) => await catalog.domains.trend(req.params.tld, req.query.kind || 'register')));router.get('/providers', run(async () => ({ providers: await catalog.providers() })));router.get('/providers/:slug', run(async (req) => ({ provider: await catalog.providers(req.params.slug) })));router.get('/promos', run(async () => ({ promos: await catalog.promos() })));@@@ -78,7 +80,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, conficonst page = (req, res, view) => {const indexable = view.indexable !== false;cachePage(req, res, indexable);-- const o = { title: view.title, body: view.body, viewer: req.viewer, config, path: req.originalUrl, indexable, description: view.description, jsonLd: view.jsonLd, css: view.css || ['css/catalog.css'], styles: view.styles, agent: indexable };++ const o = { title: view.title, body: view.body, viewer: req.viewer, config, path: req.originalUrl, indexable, description: view.description, jsonLd: view.jsonLd, css: view.css || ['css/catalog.css'], styles: view.styles, scripts: view.scripts, agent: indexable };const link = twinHeader(o);if (link) res.set('Link', link);return res.status(200).type('html').send(renderPage(o));@@@ -15,6 +15,7 @@* order never depends on affiliation (the store sorts; nothing here reorders).*/const showcase = require('openvibe-shared/showcase');++const kit = require('openvibe-shared/kit');const seo = require('openvibe-shared/seo');const { esc } = require('./layout');const { MAX_AGE_DAYS } = require('../catalog/offers');@@@ -64,6 +65,74 @@ function priceCell(p) {return `<span class="hc-price">${m ? esc(m) : UNKNOWN}</span><span class="hc-per">/mo</span>`;}++const isoAt = (v) => new Date(v).toISOString();++const MINUS = '−';++++/** A trend's step: each price holds until the next point, so a change is a vertical step, never a diagonal. */++function stepPoints(t) {++ const points = [];++ (t.points || []).forEach((p, i) => {++ if (i) points.push([new Date(Date.parse(p.at) - 1).toISOString(), Number(t.points[i - 1].price)]);++ points.push([p.at, Number(p.price)]);++ });++ return points;++}++++/** The value of a step series at a moment (the last point at or before it). */++function valueAt(points, time) {++ let value = null;++ for (const [at, price] of points) { if (Date.parse(at) > time) break; value = price; }++ return value;++}++++/** "Was $9 on 2 Sep, now $7 (−22%) · low $7 · high $12"; one observation says when it was first seen instead. */++function trendSummary(t, label = '') {++ if (!t || !t.points || !t.points.length) return '';++ const cur = t.currency;++ const first = t.points[0];++ const now = money(t.now, cur) || String(t.now);++ const was = money(first.price, cur) || String(first.price);++ let line;++ if (t.points.length < 2) line = `No changes recorded since ${day(first.at) || first.at} (${now})`;++ else if (t.change && t.change.amount === 0) line = `Unchanged since ${day(first.at)}: ${now}`;++ else if (t.change && t.change.percent != null) line = `Was ${was} on ${day(first.at)}, now ${now} (${t.change.amount > 0 ? '+' : MINUS}${Math.abs(t.change.percent)}%)`;++ else if (t.change) line = `Was ${was} on ${day(first.at)}, now ${now}`;++ const low = money(t.low, cur) || String(t.low);++ const high = money(t.high, cur) || String(t.high);++ const lowAt = (t.points.find((p) => Number(p.price) === Number(t.low)) || first).at;++ const highAt = (t.points.find((p) => Number(p.price) === Number(t.high)) || first).at;++ return `<p class="hc-trend-summary">${label ? `<strong>${esc(label)}</strong> ` : ''}${esc(line)} · low ${esc(low)} (${esc(day(lowAt) || '')}), high ${esc(high)} (${esc(day(highAt) || '')}).</p>`;++}++++/** One series' step chart, with its summary line under it. Nothing with one observation. */++function trendFigure(t, { title = 'Price over time', label = '', seriesName = 'Price', note = 'A price holds until the next change.' } = {}) {++ if (!t || !t.points || t.points.length < 2) return '';++ const chart = kit.chart({++ type: 'line', title, note, height: 220, label: `${title} over time`,++ x: { time: true, label: 'Date' }, y: { fmt: 'money', zero: false, label: t.currency || 'Price' },++ series: [{ name: seriesName, points: stepPoints(t) }],++ });++ return `<div class="hc-trend">${chart}${trendSummary(t, label)}</div>`;++}++++/** The registration and renewal step series on a shared time axis (kit.chart reads one x list for every series). */++function tldTrendFigure(trends, { title = 'Domain price over time' } = {}) {++ const all = [['Registration', trends && trends.register], ['Renewal', trends && trends.renew]].filter(([, t]) => t && t.points && t.points.length);++ if (!all.length) return '';++ const steps = all.map(([name, t]) => ({ name, points: stepPoints(t) }));++ const times = [...new Set(steps.flatMap((s) => s.points.map(([at]) => Date.parse(at))))].sort((a, b) => a - b);++ const series = steps.map((s) => ({ name: s.name, points: times.map((t) => [isoAt(t), valueAt(s.points, t)]) }));++ const chart = series.some((s) => s.points.length > 1)++ ? kit.chart({++ type: 'line', title, note: 'The cheapest price at any registrar; a price holds until the next change.', height: 240,++ label: `${title}: registration and renewal`,++ x: { time: true, label: 'Date' }, y: { fmt: 'money', zero: false, label: 'USD' },++ series,++ })++ : '';++ return `<div class="hc-trend">${chart}${all.map(([name, t]) => trendSummary(t, name)).join('')}</div>`;++}++function planTable(plans, { caption = 'Hosting plans', showProvider = true } = {}) {if (!plans.length) return '';const rows = plans.map((p) => {@@@ -219,9 +288,11 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' }['Locations', p.locations && p.locations.length ? p.locations.join(', ') : null], ['Kind', kindName(p.kind)],];const value = [['Per vCPU', money(p.price_per_vcpu, p.currency)], ['Per GB of RAM', money(p.price_per_gb_ram, p.currency)], ['Per GB of disk', money(p.price_per_gb_disk, p.currency, 3)]];++ const trend = p.price_trend;++ const chart = trendFigure(trend, { title: 'Monthly price over time', seriesName: 'Monthly price' }) || trendSummary(trend);const history = p.price_history.length-- ? `<ol class="hc-history">${p.price_history.map((h) => `<li><time datetime="${esc(day(Number(h.observed_at)))}">${esc(day(Number(h.observed_at)))}</time> ${esc(money(h.price_monthly_before, h.currency) || h.price_monthly_before)} → <strong>${esc(money(h.price_monthly_after, h.currency) || h.price_monthly_after)}</strong></li>`).join('')}</ol>`-- : '<p class="hc-dim">No price change recorded since the catalog first saw this plan.</p>';++ ? `<details class="hc-history-fold"><summary>Every change (${p.price_history.length})</summary><ol class="hc-history">${p.price_history.map((h) => `<li><time datetime="${esc(day(Number(h.observed_at)))}">${esc(day(Number(h.observed_at)))}</time> ${esc(money(h.price_monthly_before, h.currency) || h.price_monthly_before)} → <strong>${esc(money(h.price_monthly_after, h.currency) || h.price_monthly_after)}</strong></li>`).join('')}</ol></details>`++ : '';return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link(`/catalog/providers/${encodeURIComponent(p.provider_slug)}`, esc(p.provider_name))}</nav><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">@@@ -236,6 +307,7 @@ ${visit(p)}<section aria-labelledby="hc-value-h"><h2 id="hc-value-h">Value</h2><dl class="hc-spec">${value.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${or(v)}</dd></div>`).join('')}</dl></section></div><h2>Price history</h2>++${chart}${history}${baseUrl && watchUrl ? `<p class="hc-watch">${out(`${watchUrl}/watches/new?${new URLSearchParams({ template: 'json', url: `${baseUrl}/api/v1/catalog/plans/${p.id}`, value_path: 'plan.price_monthly', op: 'changed', name: `${p.provider_name} ${p.name} price` })}`, 'Tell me when this price changes', { cls: 'button hc-button-quiet' })} <span class="hc-dim">with OpenVibe.Watch, free</span></p>` : ''}${extra}@@@ -340,8 +412,15 @@ function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], fa};}--function front({ plans, providers, promos: live, stats, offers: recent = [], domains: domainSection = '', 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 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) => {++ const up = c.after > c.before;++ const move = c.percent == null ? (up ? 'Rose' : 'Dropped') : `${up ? 'Rose' : 'Dropped'} ${Math.abs(c.percent)}%`;++ return `<li><a href="${esc(c.href)}"><span class="hc-change-what"><b>${esc(c.name)}</b><span class="hc-dim">${esc(c.kind)}</span></span><span class="hc-change-move ${up ? 'is-up' : 'is-down'}"><span class="hc-change-word">${move}</span><span class="hc-change-prices">${esc(money(c.before, c.currency) || String(c.before))} → ${esc(money(c.after, c.currency) || String(c.after))}</span></span></a></li>`;++ }).join('')}</ol></section>` : '';const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now"><p class="hc-aside-h">Lowest monthly prices right now</p><ol>${plans.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>@@@ -356,7 +435,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 + `<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' })}<p class="hc-more">${link('/catalog', 'Filter and sort every plan →', 'button')}</p></section>`+ extra.sizes + extra.value + extra.providers+ domainSection@@@ -374,4 +453,4 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom+ `<p class="hc-own">Want to host a site with OpenVibe? ${link('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;}--module.exports = { front, catalog, providerList, provider, plan, promos, offers, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb };++module.exports = { front, catalog, providerList, provider, plan, promos, offers, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb, trendFigure, tldTrendFigure, trendSummary, day };@@@ -13,7 +13,7 @@*/const seo = require('openvibe-shared/seo');const { esc } = require('./layout');--const { money } = require('./catalog');++const { money, tldTrendFigure } = require('./catalog');const domains = require('../catalog/domains');const UNKNOWN = '<span class="hc-unknown" title="The registrar does not state it">Unknown</span>';@@@ -100,11 +100,12 @@ function tld(t, { baseUrl = '', watchUrl = '' } = {}) {const c = t.cheapest;const jump = c.register != null && c.renew != null && c.renew > c.register * 1.2;const kind = t.kind === 'second-level' ? `Second level under ${domains.displayTld(t.tld.slice(t.tld.indexOf('.') + 1))}` : domains.KIND_LABEL[t.kind];-- const history = t.history.length ? `<ol class="hc-history">${t.history.map((h) => {++ const trend = tldTrendFigure(t.price_trend, { title: 'Registration and renewal over time' });++ const history = t.history.length ? `<details class="hc-history-fold"><summary>Every change (${t.history_count || t.history.length})</summary><ol class="hc-history">${t.history.map((h) => {const moved = [['first year', h.register], ['renewal', h.renew], ['transfer', h.transfer]].filter(([, [a, b]]) => a !== b).map(([k, [a, b]]) => `${k} ${a == null ? 'none' : esc(money(a, h.currency))} → <strong>${b == null ? 'none' : esc(money(b, h.currency))}</strong>`).join(', ');return `<li><time datetime="${esc(day(h.observed_at))}">${esc(day(h.observed_at))}</time> ${esc(h.registrar)}: ${moved}</li>`;-- }).join('')}</ol>` : '<p class="hc-dim">No price change recorded since the catalog first saw these prices.</p>';++ }).join('')}</ol></details>` : '';const rows = t.prices.map((p) => `<tr><th scope="row" data-label="Registrar">${link(`/catalog/providers/${encodeURIComponent(p.registrar_slug)}`, esc(p.registrar))}</th><td data-label="First year" class="hc-n hd-first"><span class="hc-price">${usd(p.register)}</span></td>@@@ -122,6 +123,7 @@ ${checkForm({ tld: t.tld, label: `Check a ${t.display} name` })}<thead><tr><th scope="col">Registrar</th><th scope="col" class="hc-n">First year</th><th scope="col" class="hc-n">Renewal</th><th scope="col" class="hc-n">Transfer</th><th scope="col" class="hc-n">5 years</th><th scope="col">Checked</th><th scope="col"><span class="hd-sr">Register</span></th></tr></thead><tbody>${rows}</tbody></table></div><h2>Price history</h2>++${trend}${history}${baseUrl && watchUrl ? `<p class="hc-watch">${out(`${watchUrl}/watches/new?${new URLSearchParams({ template: 'json', url: `${baseUrl}/api/v1/catalog/domains/${t.tld}`, value_path: 'tld.cheapest.renew', op: 'changed', name: `${t.display} renewal price` })}`, `Tell me when ${esc(t.display)} prices change`, 'button hc-button-quiet')} <span class="hc-dim">with OpenVibe.Watch, free</span></p>` : ''}<section aria-labelledby="hd-reg-h"><h2 id="hd-reg-h">Search a ${esc(t.display)} name at every registrar</h2>${registrarLinks(`yourname.${t.tld}`)}</section>@@@ -117,8 +117,10 @@ function renderPage(o) {`<link rel="stylesheet" href="${asset('css/host.css')}">`,// Host's own stylesheets a page asks for (the catalog's css/catalog.css)...(o.css || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`),-- // openvibe-shared stylesheets a page asks for by name (the front page's showcase.css)++ // openvibe-shared stylesheets a page asks for by name (the front page's showcase.css, the chart's kit.css)...(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`),++ // openvibe-shared scripts a page asks for by name (kit-data-web.js for a chart's readout)++ ...(o.scripts || []).map((name) => `<script src="${esc(ovServe.url(name))}" defer></script>`),`<meta name="ov-boost" content="host@${esc(RELEASE)}">`,`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,].join('\n'),nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++/**++ * Price over time for plans and domain endings (server/catalog/trend.js): a step from the first observation to++ * today, one point with no change, a rise, a fall, the currency kept, the change percent, the low and the high;++ * the page's chart and summary line; the two API routes; the catalog home's "Price changes this month" rail.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { planTrend, trend } = require('../server/catalog/trend');++++const DAY = 86400000;++const at = '2026-09-22T12:00:00Z';++let seq = 0;++const item = (tld, fields) => ({++ id: `itm_${String(++seq).padStart(26, '0')}`, source_key: 'porkbun-tld-pricing', category: 'hosting', kind: 'tld_price', identity: tld, canonical_url: null, title: tld,++ summary: null, authors: [], published_at: null, source_updated_at: null, fields: { special_type: null, ...fields }, revision: 1, removed: null,++ provenance: { retrieved_at: at, first_seen_at: at, content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'api@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null },++});++++(async () => {++ await check('the trend maths: one observation, a rise, a fall, currency kept, the change percent', async () => {++ const t0 = Date.parse('2026-09-01T00:00:00Z');++ const one = planTrend({ price_monthly: '10', currency: 'USD', first_seen_at: t0 }, [], t0 + DAY);++ assert.deepStrictEqual([one.points.length, one.now, one.low, one.high, one.change], [1, 10, 10, 10, null]);++ const rise = planTrend({ price_monthly: '12', currency: 'USD', first_seen_at: t0 }, [++ { observed_at: t0 + 2 * DAY, currency: 'USD', price_monthly_before: '10', price_monthly_after: '12' },++ ], t0 + 3 * DAY);++ assert.deepStrictEqual([rise.points.map((p) => p.price), rise.change], [[10, 12, 12], { amount: 2, percent: 20 }]);++ assert.strictEqual(rise.points[rise.points.length - 1].at, new Date(t0 + 3 * DAY).toISOString(), 'the current price is the last point');++ const fall = planTrend({ price_monthly: '8', currency: 'EUR', first_seen_at: t0 }, [++ { observed_at: t0 + 2 * DAY, currency: 'EUR', price_monthly_before: '10', price_monthly_after: '8' },++ ], t0 + 3 * DAY);++ assert.deepStrictEqual([fall.currency, fall.change, fall.low, fall.high], ['EUR', { amount: -2, percent: -20 }, 8, 10]);++ assert.strictEqual(trend([{ at: t0, price: 5 }], 'USD').change, null, 'one price has no change');++ // A change in another currency is never compared with the plan's currency.++ assert.deepStrictEqual(planTrend({ price_monthly: '12', currency: 'USD', first_seen_at: t0 }, [++ { observed_at: t0 + 2 * DAY, currency: 'EUR', price_monthly_before: '9', price_monthly_after: '12' },++ ], t0 + 3 * DAY).points.map((p) => p.price), [12]);++ });++++ const t = await boot();++ t.clock.set(Date.parse('2026-10-01T12:00:00Z'));++ const staff = t.user('trend-editor', { role: 'admin' });++ try {++ const post = async (type, body) => { const r = await t.api('POST', `/api/v1/catalog/${type}`, { as: staff, json: body }); assert.strictEqual(r.status, 201, r.text); return r.json()[type.slice(0, -1)]; };++ const patch = async (id, body) => { const r = await t.api('PATCH', `/api/v1/catalog/plans/${id}`, { as: staff, json: body }); assert.strictEqual(r.status, 200, r.text); return r.json().plan; };++ const provider = await post('providers', { slug: 'trendco', name: 'Trend Co', homepage_url: 'https://trend.test' });++ const plan = async (id, price, currency = 'USD') => await post('plans', { provider_id: provider.id, external_id: id, name: id, kind: 'vps', currency, price_monthly: price, source_url: 'https://trend.test/pricing' });++ const moved = await plan('moved', 10);++ const still = await plan('still', 5);++ const euro = await plan('euro', 20, 'EUR');++++ t.clock.advance(5 * DAY);++ await patch(moved.id, { price_monthly: 12 });++ t.clock.advance(5 * DAY);++ await patch(moved.id, { price_monthly: 9 });++ await patch(euro.id, { price_monthly: 25 });++++ await check("the plan's price-trend API: points, currency, now, low, high, change", async () => {++ const r = await t.api('GET', `/api/v1/catalog/plans/${moved.id}/price-trend`);++ assert.strictEqual(r.status, 200, r.text);++ const trend = r.json();++ assert.deepStrictEqual(Object.keys(trend).sort(), ['change', 'currency', 'high', 'low', 'now', 'points']);++ assert.strictEqual(trend.currency, 'USD');++ assert.deepStrictEqual([trend.now, trend.low, trend.high, trend.change], [9, 9, 12, { amount: -1, percent: -10 }]);++ assert.deepStrictEqual(trend.points.map((p) => p.price), [10, 12, 9]);++ assert.ok(trend.points.every((p) => /^\d{4}-\d\d-\d\dT/.test(p.at) && Number.isFinite(p.price)));++ const e = (await t.api('GET', `/api/v1/catalog/plans/${euro.id}/price-trend`)).json();++ assert.deepStrictEqual([e.currency, e.points.map((p) => p.price)], ['EUR', [20, 25]]);++ const none = (await t.api('GET', `/api/v1/catalog/plans/${still.id}/price-trend`)).json();++ assert.deepStrictEqual([none.points.length, none.change, none.low, none.high], [1, null, 5, 5]);++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/plans/nope/price-trend')).status, 404);++ });++++ await check("the plan page draws the chart, the summary line, and folds every change", async () => {++ const r = await t.api('GET', `/catalog/plans/${moved.id}`);++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /class="ovk-chart ovk-chart--line"/, 'the shared kit draws it');++ assert.match(r.text, /Monthly price over time/);++ assert.match(r.text, /Was \$10\.00 on 2026-10-\d\d, now \$9\.00 \(−10%\)/);++ assert.match(r.text, /low \$9\.00 \([^)]*\), high \$12\.00/);++ assert.match(r.text, /<details class="hc-history-fold"><summary>Every change \(2\)<\/summary>/);++ assert.match(r.text, /\/shared\/kit-data\.css\?v=/);++ assert.match(r.text, /\/shared\/kit-data-web\.js\?v=/);++ const quiet = await t.api('GET', `/catalog/plans/${still.id}`);++ assert.doesNotMatch(quiet.text, /class="ovk-chart/, 'one observation has no chart');++ assert.match(quiet.text, /No changes recorded since 2026-10-01 \(\$5\.00\)/);++ });++++ await check("the catalog home's rail: this month's moves, biggest first, rose and dropped in words", async () => {++ const r = await t.api('GET', '/');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Price changes this month/);++ assert.match(r.text, /class="hc-change-word">Dropped 25%<\/span>/, 'a fall is a word, not only a colour');++ assert.match(r.text, /class="hc-change-word">Rose 25%<\/span>/, 'the EUR plan rose 25%');++ const order = [...r.text.matchAll(/class="hc-change-word">([^<]+)</g)].map((m) => m[1]);++ assert.deepStrictEqual(order.slice(0, 2).sort(), ['Dropped 25%', 'Rose 25%'], 'the two 25% moves lead');++ assert.strictEqual(order[2], 'Rose 20%');++ });++++ await check('a TLD\'s registration and renewal over time, on its page and in the API', async () => {++ assert.strictEqual(await t.ctx.catalog.applyItem(item('dev', { registration: '9.00', renewal: '12.00', transfer: '9.00' })), 'applied');++ t.clock.advance(7 * DAY);++ assert.strictEqual(await t.ctx.catalog.applyItem({ ...item('dev', { registration: '8.00', renewal: '13.00', transfer: '9.00' }), provenance: { ...item('dev', {}).provenance, retrieved_at: new Date(t.clock.now()).toISOString() } }), 'applied');++ const r = await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=register');++ assert.strictEqual(r.status, 200, r.text);++ const register = r.json();++ assert.deepStrictEqual([register.kind, register.currency, register.now, register.low, register.high, register.change], ['register', 'USD', 8, 8, 9, { amount: -1, percent: -11 }]);++ const renew = (await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=renew')).json();++ assert.deepStrictEqual([renew.kind, renew.now, renew.high, renew.change], ['renew', 13, 13, { amount: 1, percent: 8 }]);++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=transfer')).status, 422);++ const page = await t.api('GET', '/catalog/domains/dev');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /Registration and renewal over time/);++ assert.match(page.text, /class="ovk-chart ovk-chart--line"/);++ assert.match(page.text, /Registration<\/strong> Was \$9\.00 on 2026-09-\d\d, now \$8\.00 \(−11%\)/);++ assert.match(page.text, /Renewal<\/strong> Was \$12\.00 on 2026-09-\d\d, now \$13\.00 \(\+8%\)/);++ assert.match(page.text, /Every change \(1\)/);++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -18,8 +18,8 @@ const BUDGETS = {jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 239.1 (openvibe-shared 2.6.0 served scripts; was 212.2 on 2.2.0)jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 56.3 (was 49.9)cssFiles: 3, // 3 (host.css, openvibe-shared showcase.css, catalog.css: the front page is the hosting catalog since Host#69)-- cssRawKB: 54, // 53.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 51.3 the home's phone rows and fold (2026-10-29); 46.2 openvibe-shared 4.26.1 (hero visual); 40.7 with Shared 4.13.0 (2026-10-26: account links); 34.8 on 2026-10-22 (the front page's cards and questions); 31.9 (2026-10-16 the domain comparison's and benchmarks' styles; 27.2 with showcase.css 4.1.5, 14.8 before catalog.css)-- cssBrotliKB: 11.1, // 10.9 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 10.1 with the phone rules that show four rows of each front-page list (2026-10-29); 8.3 with Shared 4.13.0 (2026-10-26: account links); 6.9 (5.7 before the domain and benchmark styles, 3.6 before catalog.css)++ cssRawKB: 57, // 55.8 with the price-trend chart's placement, the home's change rail and the Every change fold (2026-10-30); 53.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 51.3 the home's phone rows and fold (2026-10-29); 46.2 openvibe-shared 4.26.1 (hero visual); 40.7 with Shared 4.13.0 (2026-10-26: account links); 34.8 on 2026-10-22 (the front page's cards and questions); 31.9 (2026-10-16 the domain comparison's and benchmarks' styles; 27.2 with showcase.css 4.1.5, 14.8 before catalog.css)++ cssBrotliKB: 11.5, // 11.2 with the price-trend chart's placement, the home's change rail and the Every change fold (2026-10-30); 10.9 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 10.1 with the phone rules that show four rows of each front-page list (2026-10-29); 8.3 with Shared 4.13.0 (2026-10-26: account links); 6.9 (5.7 before the domain and benchmark styles, 3.6 before catalog.css)externalFiles: 1, // 0};
@@ -26,10 +26,11 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
@@ -26,10 +26,11 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
- Nothing is estimated. A value the source does not state is shown as Unknown.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.
- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.
- **Domains** (`/catalog/domains`, `/catalog/domains/:tld`, `/catalog/domains/check?name=`, a block on the front page; `GET /api/v1/catalog/domains`, `/domains/:tld`, `/domains/check?name=`). What registrars charge for each ending (the owner's "like tld-list.com"): the first year, a renewal, a transfer and five years added up (the first year and four renewals at one registrar), with a mark where the renewal is more than a fifth above the first year. Prices come from registrars' public price lists through Sources (`porkbun-tld-pricing`, Porkbun's documented keyless list, `server/catalog/domains.js`; Handshake names are left out) into `catalog_tld_prices` (migration 0011) with an append-only change history; the registrar is a catalog provider. Endings sort popular first, by first year, renewal, five years or A to Z, and filter by ending and kind (generic, country code, second level, internationalized). A name check (`server/catalog/domain-check.js`) reads what was typed (a URL, a bare label checked as .com, the longest priced suffix as the ending), asks the ending's registry over RDAP (`server/catalog/rdap.js`: IANA's bootstrap file, https servers only, no redirects, 6 s timeout, answers cached 10 minutes, 30 checks a minute per address) and prices the same name at the popular endings; every registrar's search is a plain deep link. Before migration 0011 applies the price sections are left out and the check still works. `/domains` (static hosting's old page) redirects here.
- **Domains** (`/catalog/domains`, `/catalog/domains/:tld`, `/catalog/domains/check?name=`, a block on the front page; `GET /api/v1/catalog/domains`, `/domains/:tld`, `/domains/check?name=`). What registrars charge for each ending (the owner's "like tld-list.com"): the first year, a renewal, a transfer and five years added up (the first year and four renewals at one registrar), with a mark where the renewal is more than a fifth above the first year. Prices come from registrars' public price lists through Sources (`porkbun-tld-pricing`, Porkbun's documented keyless list, `server/catalog/domains.js`; Handshake names are left out) into `catalog_tld_prices` (migration 0011) with an append-only change history; the registrar is a catalog provider. Endings sort popular first, by first year, renewal, five years or A to Z, and filter by ending and kind (generic, country code, second level, internationalized). A name check (`server/catalog/domain-check.js`) reads what was typed (a URL, a bare label checked as .com, the longest priced suffix as the ending), asks the ending's registry over RDAP (`server/catalog/rdap.js`: IANA's bootstrap file, https servers only, no redirects, 6 s timeout, answers cached 10 minutes, 30 checks a minute per address) and prices the same name at the popular endings; every registrar's search is a plain deep link. Before migration 0011 applies the price sections are left out and the check still works. `/domains` (static hosting's old page) redirects here.
+- **Price history.** A plan's page and a TLD's page draw the price over time from the append-only change rows (`catalog_price_changes`, `catalog_tld_price_changes`) with the shared `kit.chart` step chart: from the first observation to today, the current price as the last point, with "Was $X on <date>, now $Y (−Z%)", the low and the high; the full change list folds under "Every change". A TLD draws the cheapest registration and renewal at any registrar. One observation only shows the price with "No changes recorded since <date>" and no chart. The catalog home has a "Price changes this month" rail (up to 8, biggest move first, rose/dropped in words). `GET /api/v1/catalog/plans/:id/price-trend` and `/api/v1/catalog/tlds/:tld/price-trend?kind=register|renew` return `{ points: [{ at, price }], currency, now, low, high, change: { amount, percent } | null }`.
- **Benchmarks** (`/catalog/benchmarks`, `/catalog/benchmarks/:id`, `/catalog/benchmarks/new`; `GET /api/v1/catalog/benchmarks`, `/benchmarks/:id`, `POST /benchmarks/links`, `DELETE /benchmarks/:id`). The owner's "benchmarks … user submittable" (Q21): a signed-in person names the provider (a catalog provider or plan, or another provider by name) and gets a one-time link (24 hours, 10 a day); `curl -sL https://yabs.sh | bash -s -- -s "<link>"` runs YABS on their server, which posts its JSON to `POST /api/v1/catalog/benchmarks/yabs/:token` and prints Host's one-line answer (the result's address). `server/catalog/benchmarks.js` reads the JSON field by field (CPU, memory, disk, the newest Geekbench with only a browser.geekbench.com link, fio in MB/s and IOPS, iperf3, the city and network YABS looked up; never the server's address) into `catalog_benchmarks` (migration 0012). Results list newest first or by Geekbench or 4k IOPS, filter by provider or plan; plan and provider pages show the medians. The submitter is never shown; they, or staff, can remove a result; account export carries results and links, and erasure deletes the links and leaves the results anonymous.
- **Benchmarks** (`/catalog/benchmarks`, `/catalog/benchmarks/:id`, `/catalog/benchmarks/new`; `GET /api/v1/catalog/benchmarks`, `/benchmarks/:id`, `POST /benchmarks/links`, `DELETE /benchmarks/:id`). The owner's "benchmarks … user submittable" (Q21): a signed-in person names the provider (a catalog provider or plan, or another provider by name) and gets a one-time link (24 hours, 10 a day); `curl -sL https://yabs.sh | bash -s -- -s "<link>"` runs YABS on their server, which posts its JSON to `POST /api/v1/catalog/benchmarks/yabs/:token` and prints Host's one-line answer (the result's address). `server/catalog/benchmarks.js` reads the JSON field by field (CPU, memory, disk, the newest Geekbench with only a browser.geekbench.com link, fio in MB/s and IOPS, iperf3, the city and network YABS looked up; never the server's address) into `catalog_benchmarks` (migration 0012). Results list newest first or by Geekbench or 4k IOPS, filter by provider or plan; plan and provider pages show the medians. The submitter is never shown; they, or staff, can remove a result; account export carries results and links, and erasure deletes the links and leaves the results anonymous.
- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).
- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).
- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.
- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.
-- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history), `/providers`, `/providers/:slug` and `/promos`.
+- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history and its trend), `/plans/:id/price-trend`, `/tlds/:tld/price-trend`, `/providers`, `/providers/:slug` and `/promos`.
- **Search.** Every public plan and provider page is an OpenVibe.Search document (`host.index_document.upserted|deleted`, `server/catalog/search-index.js` through openvibe-publishing/search-feed): sent a minute after boot, hourly, after each catalog pull and shortly after a staff change; only changed pages are sent, and a hidden or gone entry becomes a tombstone.
- **Search.** Every public plan and provider page is an OpenVibe.Search document (`host.index_document.upserted|deleted`, `server/catalog/search-index.js` through openvibe-publishing/search-feed): sent a minute after boot, hourly, after each catalog pull and shortly after a staff change; only changed pages are sent, and a hidden or gone entry becomes a tombstone.
- **IndexNow.** With `INDEXNOW_KEY`, a catalog pull that adds a plan or a provider, or changes a plan's price, sends those pages and `/sitemap.xml` to search engines in one batch (a re-check at the same price sends nothing); a new offer post sends `/catalog/offers`. The key is served at `/<key>.txt`.
- **IndexNow.** With `INDEXNOW_KEY`, a catalog pull that adds a plan or a provider, or changes a plan's price, sends those pages and `/sitemap.xml` to search engines in one batch (a re-check at the same price sends nothing); a new offer post sends `/catalog/offers`. The key is served at `/<key>.txt`.
- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`, `/catalog/promos`, `/catalog/offers`, `/catalog/domains` (with each ending's page and the name check) and `/catalog/benchmarks`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`, `/catalog/promos`, `/catalog/offers`, `/catalog/domains` (with each ending's page and the name check) and `/catalog/benchmarks`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
@@ -265,3 +265,29 @@
@@ -265,3 +265,29 @@
.hc-more > a:only-child { position: relative; }
.hc-more > a:only-child { position: relative; }
.hc-more > a:only-child::after { content: ''; position: absolute; left: 0; right: 0; top: -13px; bottom: -13px; }
.hc-more > a:only-child::after { content: ''; position: absolute; left: 0; right: 0; top: -13px; bottom: -13px; }
}
}
+
+/* Price over time (server/render/catalog.js trendFigure, render/domains.js tldTrendFigure): the shared kit draws the
+ chart; these rules only place it and its one-line summary. Theme tokens only. */
+.hc-trend { margin: 0 0 1.25rem; }
+.hc-trend .ovk-chart { margin: 0 0 .5rem; }
+.hc-trend-summary { margin: .25rem 0 1rem; color: var(--hs-muted); font-size: 14px; }
+.hc-trend-summary strong { color: var(--hs-fg); }
+.hc-history-fold { margin: 1rem 0; border: 1px solid var(--hs-line); border-radius: 10px; background: var(--hs-card); }
+.hc-history-fold > summary { display: flex; align-items: center; min-height: 44px; padding: 0 .9rem; cursor: pointer; font-weight: 600; }
+.hc-history-fold > summary:focus-visible { outline: 2px solid var(--hs-accent); outline-offset: -2px; border-radius: 10px; }
+.hc-history-fold .hc-history { margin: 0; padding: .25rem 1.2rem 1rem 2.2rem; }
+
+/* The catalog home's "Price changes this month" rail: words say rose or dropped; colour only repeats them. */
+.hh-changes .hc-changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); }
+.hc-changes li { border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); }
+.hc-changes a { display: flex; flex-wrap: wrap; gap: 8px 14px; justify-content: space-between; align-items: baseline; min-height: 44px; box-sizing: border-box; padding: 12px 14px; color: inherit; text-decoration: none; }
+.hc-changes a:hover, .hc-changes a:focus-visible { outline: 2px solid var(--hs-accent); outline-offset: -2px; border-radius: 12px; }
+.hc-change-what { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.hc-change-what b { color: var(--hs-fg); overflow-wrap: anywhere; }
+.hc-change-what .hc-dim { font-size: .82rem; }
+.hc-change-move { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; white-space: nowrap; font-size: .85rem; margin-left: auto; }
+.hc-change-word { font-weight: 700; }
+.hc-change-move.is-up .hc-change-word { color: var(--hs-warn); }
+.hc-change-move.is-down .hc-change-word { color: var(--hs-ok); }
+.hc-change-prices { color: var(--hs-muted); font-variant-numeric: tabular-nums; }
+@media (max-width: 400px) { .hc-change-move { align-items: flex-start; margin-left: 0; } }
@@ -42,3 +42,7 @@ ul.domains { padding-left: 1.2rem; } ul.domains li { margin-bottom: 1rem; }
@@ -42,3 +42,7 @@ ul.domains { padding-left: 1.2rem; } ul.domains li { margin-bottom: 1rem; }
.hh-stats::-webkit-scrollbar { display: none; }
.hh-stats::-webkit-scrollbar { display: none; }
.hh-stats li { flex: 0 0 auto; min-width: 128px; }
.hh-stats li { flex: 0 0 auto; min-width: 128px; }
}
}
+
+/* The shared footer's patch-notes link is nowrap; a long title pushes a 320px page sideways (found on the catalog
+ home, 2026-10-30). It wraps like the rest of the footer. */
+a.ov-shipped-notes { white-space: normal; overflow-wrap: anywhere; max-width: 100%; }
@@ -214,7 +214,7 @@ async function createApp(opts = {}) {
@@ -214,7 +214,7 @@ async function createApp(opts = {}) {
// The front page is built by the shared view builder (server/http/catalog-views.js), the same one
// The front page is built by the shared view builder (server/http/catalog-views.js), the same one
// the agent-layer twin uses, so the two cannot drift.
// the agent-layer twin uses, so the two cannot drift.
const view = await views.front();
const view = await views.front();
- const o = { title: view.title, description: view.description, body: view.body, viewer: req.viewer, config, path: '/', indexable: true, styles: view.styles, css: view.css, jsonLd: view.jsonLd, agent: true };
+ const o = { title: view.title, description: view.description, body: view.body, viewer: req.viewer, config, path: '/', indexable: true, styles: view.styles, scripts: view.scripts, css: view.css, jsonLd: view.jsonLd, agent: true };
const link = twinHeader(o);
const link = twinHeader(o);
if (link) res.set('Link', link);
if (link) res.set('Link', link);
res.type('html').send(renderPage(o));
res.type('html').send(renderPage(o));
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
const { newId } = require('../ids');
const { newId } = require('../ids');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
const domains = require('./domains');
const domains = require('./domains');
+const { tldTrend } = require('./trend');
const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
const PAGE = 100;
const PAGE = 100;
@@ -112,7 +113,7 @@ function createDomainStore({ store, announce = () => {} }) {
@@ -112,7 +113,7 @@ function createDomainStore({ store, announce = () => {} }) {
const rows = await db.prepare(`SELECT p.*, ${FIVE_YEAR} AS five_year, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note
const rows = await db.prepare(`SELECT p.*, ${FIVE_YEAR} AS five_year, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note
FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.tld = ? ORDER BY p.register ASC NULLS LAST, v.name, p.id`).all(t);
FROM catalog_tld_prices p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${ACTIVE} AND p.tld = ? ORDER BY p.register ASC NULLS LAST, v.name, p.id`).all(t);
const history = rows.length ? await db.prepare(`SELECT h.*, v.name AS provider_name FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id
const history = rows.length ? await db.prepare(`SELECT h.*, v.name AS provider_name FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id
- WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC LIMIT 20`).all(t) : [];
+ WHERE p.tld = ? AND ${ACTIVE} ORDER BY h.observed_at DESC`).all(t) : [];
const data = { rows, history };
const data = { rows, history };
if (!data.rows.length) throw new ApiError(404, 'host.catalog.not_found', 'No registrar price is listed for this ending');
if (!data.rows.length) throw new ApiError(404, 'host.catalog.not_found', 'No registrar price is listed for this ending');
const prices = data.rows.map((r) => ({
const prices = data.rows.map((r) => ({
@@ -124,13 +125,24 @@ function createDomainStore({ store, announce = () => {} }) {
@@ -124,13 +125,24 @@ function createDomainStore({ store, announce = () => {} }) {
return {
return {
tld: t, display: domains.displayTld(t), kind: domains.tldKind(t), currency: 'USD', prices,
tld: t, display: domains.displayTld(t), kind: domains.tldKind(t), currency: 'USD', prices,
cheapest: { register: min('register'), renew: min('renew'), transfer: min('transfer'), five_year: min('five_year') },
cheapest: { register: min('register'), renew: min('renew'), transfer: min('transfer'), five_year: min('five_year') },
- history: data.history.map((h) => ({
+ price_trend: tldTrend(rows, data.history, store.now()), history_count: data.history.length,
+ history: data.history.slice(0, 20).map((h) => ({
registrar: h.provider_name, observed_at: iso(h.observed_at), currency: h.currency,
registrar: h.provider_name, observed_at: iso(h.observed_at), currency: h.currency,
register: [n(h.register_before), n(h.register_after)], renew: [n(h.renew_before), n(h.renew_after)], transfer: [n(h.transfer_before), n(h.transfer_after)],
register: [n(h.register_before), n(h.register_after)], renew: [n(h.renew_before), n(h.renew_after)], transfer: [n(h.transfer_before), n(h.transfer_after)],
})),
})),
};
};
}
}
+ /** One field of a TLD's price over time: register (default) or renew, the cheapest at any registrar. */
+ async function trend(input, kind = 'register') {
+ if (!['register', 'renew'].includes(kind)) throw new ApiError(422, 'host.catalog.invalid', 'kind must be register or renew');
+ const data = await tld(input);
+ const field = kind === 'renew' ? 'renew' : 'register';
+ const series = data.price_trend && data.price_trend[field];
+ if (!series) throw new ApiError(404, 'host.catalog.not_found', `No ${field === 'renew' ? 'renewal' : 'registration'} price history for this ending`);
+ return { ...series, kind: field };
+ }
+
/** The cheapest prices for a few endings (a name check's alternatives), in the order asked. */
/** The cheapest prices for a few endings (a name check's alternatives), in the order asked. */
async function cheapest(tlds) {
async function cheapest(tlds) {
const list = [...new Set(tlds.map((x) => domains.normalizeTld(x)).filter(Boolean))].slice(0, 30);
const list = [...new Set(tlds.map((x) => domains.normalizeTld(x)).filter(Boolean))].slice(0, 30);
@@ -164,7 +176,7 @@ function createDomainStore({ store, announce = () => {} }) {
@@ -164,7 +176,7 @@ function createDomainStore({ store, announce = () => {} }) {
return Number((await db.prepare("SELECT COUNT(*) AS n FROM catalog_tld_prices WHERE provider_id = ? AND status = 'active'").get(providerId)).n) || 0;
return Number((await db.prepare("SELECT COUNT(*) AS n FROM catalog_tld_prices WHERE provider_id = ? AND status = 'active'").get(providerId)).n) || 0;
}
}
- return { apply, list, tld, cheapest, knownTlds, stats, sitemap, countFor, SORTS: Object.keys(SORTS), KINDS: Object.keys(KINDS) };
+ return { apply, list, tld, trend, cheapest, knownTlds, stats, sitemap, countFor, SORTS: Object.keys(SORTS), KINDS: Object.keys(KINDS) };
}
}
module.exports = { createDomainStore };
module.exports = { createDomainStore };
@@ -6,6 +6,7 @@ const { mapItem } = require('./mapping');
@@ -6,6 +6,7 @@ const { mapItem } = require('./mapping');
const offerFeed = require('./offers');
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 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 = {
@@ -221,7 +222,40 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -221,7 +222,40 @@ function createCatalog({ store, community = null, log = console, indexnow = null
async function plan(id) {
async function plan(id) {
const p = await db.prepare("SELECT p.*, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.id = ? AND p.status = 'active' AND v.status = 'active'").get(id);
const p = await db.prepare("SELECT p.*, v.slug AS provider_slug, v.name AS provider_name, v.homepage_url, v.affiliate_url, v.affiliate_note FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.id = ? AND p.status = 'active' AND v.status = 'active'").get(id);
if (!p) missing();
if (!p) missing();
- return { ...planOut(p), price_history: (await db.prepare('SELECT * FROM catalog_price_changes WHERE plan_id = ? ORDER BY observed_at DESC').all(id)).map((h) => ({ ...h, checked_at: new Date(Number(h.observed_at)).toISOString(), source_url: p.source_url })) };
+ const changes = await db.prepare('SELECT * FROM catalog_price_changes WHERE plan_id = ? ORDER BY observed_at DESC').all(id);
+ return { ...planOut(p), price_history: changes.map((h) => ({ ...h, checked_at: new Date(Number(h.observed_at)).toISOString(), source_url: p.source_url })), price_trend: planTrend(p, changes, store.now()) };
+ }
+ /** The plan's price over time (the same shape as the page's chart and GET /plans/:id/price-trend). */
+ async function planPriceTrend(id) { return (await plan(id)).price_trend; }
+ /**
+ * The biggest price changes of the current UTC month, from plans and TLD prices, for the catalog home's rail:
+ * newest first after the size of the move, in the currency each price was stated in (nothing converted).
+ */
+ async function recentChanges(limit = 8) {
+ const now = store.now();
+ const d = new Date(now);
+ const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
+ const planRows = await db.prepare(`SELECT h.*, p.id AS plan_id, p.name AS plan_name, p.currency AS plan_currency, v.name AS provider_name
+ FROM catalog_price_changes h JOIN catalog_plans p ON p.id = h.plan_id JOIN catalog_providers v ON v.id = p.provider_id
+ WHERE h.observed_at >= ? AND p.status = 'active' AND v.status = 'active'`).all(start);
+ const tldRows = await db.prepare(`SELECT h.*, p.tld, v.name AS provider_name
+ FROM catalog_tld_price_changes h JOIN catalog_tld_prices p ON p.id = h.price_id JOIN catalog_providers v ON v.id = p.provider_id
+ WHERE h.observed_at >= ? AND p.status = 'active' AND v.status = 'active' AND h.currency = 'USD'`).all(start);
+ const pct = (before, after) => (Number(before) > 0 ? Math.round(((Number(after) - Number(before)) / Number(before)) * 1000) / 10 : null);
+ const items = [];
+ for (const h of planRows) if ((h.currency || null) === (h.plan_currency || null)) items.push({
+ type: 'plan', kind: 'monthly price', name: `${h.provider_name} ${h.plan_name}`, href: `/catalog/plans/${encodeURIComponent(h.plan_id)}`,
+ before: Number(h.price_monthly_before), after: Number(h.price_monthly_after), currency: h.currency, at: new Date(Number(h.observed_at)).toISOString(), percent: pct(h.price_monthly_before, h.price_monthly_after),
+ });
+ for (const h of tldRows) for (const [field, kind] of [['register', 'registration'], ['renew', 'renewal'], ['transfer', 'transfer']]) {
+ const before = h[`${field}_before`], after = h[`${field}_after`];
+ if (before == null || after == null || Number(before) === Number(after)) continue;
+ items.push({
+ type: 'tld', kind, name: domainFeed.displayTld(h.tld), href: `/catalog/domains/${encodeURIComponent(h.tld)}`,
+ before: Number(before), after: Number(after), currency: h.currency, at: new Date(Number(h.observed_at)).toISOString(), percent: pct(before, after),
+ });
+ }
+ return items.sort((a, b) => (Math.abs(b.percent ?? 0) - Math.abs(a.percent ?? 0)) || (Date.parse(b.at) - Date.parse(a.at)) || a.name.localeCompare(b.name, 'en')).slice(0, limit);
}
}
async function providers(slug) {
async function providers(slug) {
if (slug) {
if (slug) {
@@ -255,6 +289,6 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -255,6 +289,6 @@ function createCatalog({ store, community = null, log = console, indexnow = null
async function planChoices() { return await db.prepare("SELECT p.id, p.name, v.name AS provider_name, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY v.name, p.price_monthly, p.id LIMIT 1000").all(); }
async function planChoices() { return await db.prepare("SELECT p.id, p.name, v.name AS provider_name, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY v.name, p.price_monthly, p.id LIMIT 1000").all(); }
// Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.
// Each public plan with the day its price was last checked, and its provider's slug, so the sitemap can date pages.
async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
async function sitemapPlans() { return await db.prepare("SELECT p.id, p.checked_at, v.slug AS provider_slug FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
- return { onChange: (fn) => listeners.push(fn), save, get, applyItem, domains, plans, plan, providers, promos, offers, currencies: currenciesInUse, stats, providerSummary, sitemapPlans, planChoices, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
+ return { onChange: (fn) => listeners.push(fn), save, get, applyItem, domains, plans, plan, planPriceTrend, recentChanges, providers, promos, offers, currencies: currenciesInUse, stats, providerSummary, sitemapPlans, planChoices, discussionIdle: idle, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
}
}
module.exports = { createCatalog };
module.exports = { createCatalog };
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+/**
+ * Price over time from the catalog's append-only observations (migrations/0005 and 0011), shared by a plan's page,
+ * a TLD's page and the two price-trend API routes.
+ *
+ * A trend is the step a price held: every point starts when the price was first seen or changed, and the current
+ * price is always the last point at `now`. A price is never interpolated and two currencies are never compared: only
+ * the observation rows in the price's own currency count.
+ *
+ * trend(points, currency) → { points: [{ at, price }], currency, now, low, high, change }
+ * points oldest first, `at` an ISO date-time, `price` a number
+ * now the current price (the last point's price)
+ * low/high the lowest and highest price in the points (a tie goes to the earliest)
+ * change { amount, percent } | null (null with one observation); percent is a whole number, null at a
+ * first price of 0
+ */
+
+const round4 = (n) => Math.round(n * 10000) / 10000;
+const finite = (n) => Number.isFinite(Number(n)) ? Number(n) : null;
+const at = (ms) => new Date(Number(ms)).toISOString();
+
+function trend(points, currency) {
+ const clean = (points || [])
+ .map((p) => ({ at: Number(p.at), price: finite(p.price) }))
+ .filter((p) => Number.isFinite(p.at) && p.price != null)
+ .sort((a, b) => a.at - b.at);
+ // One price per moment, the last written wins (a first_seen_at that equals a change's time is the change).
+ const byTime = [];
+ for (const p of clean) {
+ if (byTime.length && byTime[byTime.length - 1].at === p.at) byTime[byTime.length - 1] = p;
+ else byTime.push(p);
+ }
+ if (!byTime.length) return null;
+ const first = byTime[0];
+ const last = byTime[byTime.length - 1];
+ // Ties go to the earliest moment: "lowest since" is when it was first that low.
+ const low = byTime.reduce((a, p) => (p.price < a.price ? p : a));
+ const high = byTime.reduce((a, p) => (p.price > a.price ? p : a));
+ const amount = round4(last.price - first.price);
+ return {
+ points: byTime.map((p) => ({ at: at(p.at), price: round4(p.price) })),
+ currency: currency || null,
+ now: round4(last.price),
+ low: round4(low.price),
+ high: round4(high.price),
+ change: byTime.length > 1 ? {
+ amount,
+ percent: first.price > 0 ? Math.round((amount / first.price) * 100) : null,
+ } : null,
+ };
+}
+
+/**
+ * A plan's price over time: the price first seen at first_seen_at, every catalog_price_changes row in the plan's
+ * current currency, and the current price at `now`. Changes rows may arrive newest first (the store's query) or in
+ * any order. With no change the plan has one observation.
+ */
+function planTrend(plan, changes, now) {
+ if (!plan) return null;
+ const currency = plan.currency || null;
+ const current = finite(plan.price_monthly);
+ if (current == null) return null;
+ const rows = (changes || [])
+ .filter((c) => (c.currency || null) === (currency || null) && finite(c.observed_at) != null)
+ .sort((a, b) => Number(a.observed_at) - Number(b.observed_at));
+ const firstAt = finite(plan.first_seen_at) ?? finite(plan.checked_at) ?? now;
+ const points = [];
+ if (rows.length) {
+ const before = finite(rows[0].price_monthly_before);
+ if (before != null) points.push({ at: firstAt, price: before });
+ for (const c of rows) {
+ const after = finite(c.price_monthly_after);
+ if (after != null) points.push({ at: Number(c.observed_at), price: after });
+ }
+ } else {
+ points.push({ at: firstAt, price: current });
+ }
+ // The current price is always the last point once there is a change, even when the last change was weeks
+ // ago; with no change at all the plan has exactly one observation and no chart.
+ if (rows.length) {
+ const end = Math.max(Number(now) || 0, ...points.map((p) => p.at));
+ const last = points[points.length - 1];
+ if (!last || last.at !== end || last.price !== current) points.push({ at: end, price: current });
+ }
+ return trend(points, currency);
+}
+
+// ── TLDs ──────────────────────────────────────────────────────────────────────
+// A TLD's page shows the cheapest registration and renewal at any registrar. Each registrar's price is a step
+// function rebuilt from its own first sighting and change rows; the market's cheapest price at a moment is the
+// lowest of those steps. A price that appears after the TLD was first seen joins at the moment it appears.
+
+/** One registrar's step function for `field`, from its first sighting, changes and current value. */
+function registrarSteps(row, changes, field) {
+ const rows = (changes || [])
+ .filter((c) => c.price_id === row.id && (c.currency || null) === (row.currency || null))
+ .sort((a, b) => Number(a.observed_at) - Number(b.observed_at));
+ const steps = [];
+ if (rows.length) {
+ const before = finite(rows[0][`${field}_before`]);
+ if (before != null) steps.push({ at: Number(row.first_seen_at), value: before });
+ for (const c of rows) steps.push({ at: Number(c.observed_at), value: finite(c[`${field}_after`]) });
+ } else {
+ const value = finite(row[field]);
+ if (value != null) steps.push({ at: Number(row.first_seen_at), value });
+ }
+ // The current value still holds at `now`.
+ if (steps.length) steps.push({ at: Number(row.checked_at), value: finite(row[field]) });
+ return steps;
+}
+
+const stepAt = (steps, time) => {
+ let value = null;
+ for (const s of steps) {
+ if (s.at > time) break;
+ value = s.value;
+ }
+ return value;
+};
+
+/** Turn a market value function into step points: the value before each change and the new value at it. */
+function marketSteps(events, valueAt, now) {
+ const points = [];
+ for (const t of events) {
+ if (t === events[0]) {
+ const v = valueAt(t);
+ if (v != null) points.push({ at: t, value: v });
+ continue;
+ }
+ const before = valueAt(t - 1);
+ const after = valueAt(t);
+ if (before === after) continue;
+ if (before != null) points.push({ at: t - 1, value: before });
+ if (after != null) points.push({ at: t, value: after });
+ }
+ const last = points[points.length - 1];
+ const current = valueAt(now);
+ if (current != null && (!last || last.at < now)) points.push({ at: now, value: current });
+ return points;
+}
+
+/**
+ * The cheapest registration and renewal over time for one TLD.
+ * → { currency, register: trend, renew: trend } (a field nobody ever priced is null).
+ */
+function tldTrend(rows, changes, now) {
+ const prices = (rows || []).filter((r) => (r.currency || null) === 'USD');
+ if (!prices.length) return null;
+ const timelines = prices.map((row) => ({
+ register: registrarSteps(row, changes, 'register'),
+ renew: registrarSteps(row, changes, 'renew'),
+ }));
+ const times = [...new Set([
+ ...prices.map((r) => Number(r.first_seen_at)),
+ ...(changes || []).map((c) => Number(c.observed_at)),
+ Number(now),
+ ].filter(Number.isFinite))].sort((a, b) => a - b);
+ const market = (field) => {
+ const valueAt = (t) => {
+ const values = prices.map((_row, i) => stepAt(timelines[i][field], t)).filter((v) => v != null);
+ return values.length ? Math.min(...values) : null;
+ };
+ return marketSteps(times, valueAt, Number(now));
+ };
+ const register = market('register');
+ const renew = market('renew');
+ return {
+ currency: 'USD',
+ register: register.length ? trend(register.map((p) => ({ at: p.at, price: p.value })), 'USD') : null,
+ renew: renew.length ? trend(renew.map((p) => ({ at: p.at, price: p.value })), 'USD') : null,
+ };
+}
+
+module.exports = { trend, planTrend, tldTrend };
@@ -42,9 +42,9 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
@@ -42,9 +42,9 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
/** The front page: the same hero, tables, offers, promos, domain section and FAQ the route renders. */
/** The front page: the same hero, tables, offers, promos, domain section and FAQ the route renders. */
async function front() {
async function front() {
- const [result, providers, promos, stats, recent, popular, domainStats, cheapDomains, providerCards] = await Promise.all([
+ const [result, providers, promos, stats, recent, popular, domainStats, cheapDomains, providerCards, changes] = await Promise.all([
catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({}),
catalog.plans({}), catalog.providers(), catalog.promos(), catalog.stats(), catalog.offers({}),
- catalog.domains.list({}), catalog.domains.stats(), catalog.domains.list({ sort: 'register' }), catalog.providerSummary(),
+ catalog.domains.list({}), catalog.domains.stats(), catalog.domains.list({ sort: 'register' }), catalog.providerSummary(), catalog.recentChanges(),
]);
]);
// The lowest price for each size, and the most each dollar buys: the same plan query the catalog page runs.
// The lowest price for each size, and the most each dollar buys: the same plan query the catalog page runs.
const first = async (q) => (await catalog.plans(q)).plans[0] || null;
const first = async (q) => (await catalog.plans(q)).plans[0] || null;
@@ -64,7 +64,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
@@ -64,7 +64,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
title: 'Hosting plans and domain prices, compared',
title: 'Hosting plans and domain prices, compared',
description: HOME_DESCRIPTION,
description: HOME_DESCRIPTION,
body: render.front({
body: render.front({
- plans: result.plans, providers, promos, stats, offers: recent.offers, more,
+ plans: result.plans, providers, promos, stats, offers: recent.offers, changes, more,
domains: renderDomains.frontSection({ popular: popular.tlds, cheap: cheapDomains.tlds, stats: domainStats }),
domains: renderDomains.frontSection({ popular: popular.tlds, cheap: cheapDomains.tlds, stats: domainStats }),
}),
}),
indexable: true,
indexable: true,
@@ -114,6 +114,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
@@ -114,6 +114,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
return {
return {
title: p.provider_name + ' ' + p.name,
title: p.provider_name + ' ' + p.name,
body: render.plan(p, discussion, { baseUrl, watchUrl, extra: benchHtml }),
body: render.plan(p, discussion, { baseUrl, watchUrl, extra: benchHtml }),
+ styles: ['kit.css', 'kit-data.css'], scripts: ['kit-data-web.js'],
indexable: true,
indexable: true,
description: p.provider_name + ' ' + p.name + ': ' + p.currency + ' ' + p.price_monthly + ' a month, with its specifications, price per resource and price history.',
description: p.provider_name + ' ' + p.name + ': ' + p.currency + ' ' + p.price_monthly + ' a month, with its specifications, price per resource and price history.',
jsonLd: render.jsonLd.plan(baseUrl, p),
jsonLd: render.jsonLd.plan(baseUrl, p),
@@ -148,6 +149,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
@@ -148,6 +149,7 @@ function createCatalogViews({ catalog, benchmarks, community, config, log = cons
const t = await catalog.domains.tld(tld);
const t = await catalog.domains.tld(tld);
return {
return {
title: t.display + ' domain prices', body: renderDomains.tld(t, { baseUrl, watchUrl }), indexable: true,
title: t.display + ' domain prices', body: renderDomains.tld(t, { baseUrl, watchUrl }), indexable: true,
+ styles: ['kit.css', 'kit-data.css'], scripts: ['kit-data-web.js'],
description: t.display + ' domains from ' + (render.money(t.cheapest.register, 'USD') || 'an unknown price') + ' the first year, renewing from ' + (render.money(t.cheapest.renew, 'USD') || 'an unknown price') + ": every registrar's price, dated and sourced.",
description: t.display + ' domains from ' + (render.money(t.cheapest.register, 'USD') || 'an unknown price') + ' the first year, renewing from ' + (render.money(t.cheapest.renew, 'USD') || 'an unknown price') + ": every registrar's price, dated and sourced.",
jsonLd: renderDomains.jsonLd.tld(baseUrl, t),
jsonLd: renderDomains.jsonLd.tld(baseUrl, t),
};
};
@@ -25,6 +25,8 @@ function createCatalogApi({ catalog, domainCheck, benchmarks, viewers, actorLimi
@@ -25,6 +25,8 @@ function createCatalogApi({ catalog, domainCheck, benchmarks, viewers, actorLimi
const staff = (req, _res, next) => req.viewer.kind === 'user' && req.viewer.staff ? next() : next(new ApiError(403, 'host.catalog.forbidden', 'Staff access required'));
const staff = (req, _res, next) => req.viewer.kind === 'user' && req.viewer.staff ? next() : next(new ApiError(403, 'host.catalog.forbidden', 'Staff access required'));
router.get('/plans', run(async (req) => await catalog.plans(req.query)));
router.get('/plans', run(async (req) => await catalog.plans(req.query)));
router.get('/plans/:id', run(async (req) => ({ plan: await catalog.plan(req.params.id) })));
router.get('/plans/:id', run(async (req) => ({ plan: await catalog.plan(req.params.id) })));
+ router.get('/plans/:id/price-trend', run(async (req) => await catalog.planPriceTrend(req.params.id)));
+ router.get('/tlds/:tld/price-trend', run(async (req) => await catalog.domains.trend(req.params.tld, req.query.kind || 'register')));
router.get('/providers', run(async () => ({ providers: await catalog.providers() })));
router.get('/providers', run(async () => ({ providers: await catalog.providers() })));
router.get('/providers/:slug', run(async (req) => ({ provider: await catalog.providers(req.params.slug) })));
router.get('/providers/:slug', run(async (req) => ({ provider: await catalog.providers(req.params.slug) })));
router.get('/promos', run(async () => ({ promos: await catalog.promos() })));
router.get('/promos', run(async () => ({ promos: await catalog.promos() })));
@@ -78,7 +80,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
@@ -78,7 +80,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
const page = (req, res, view) => {
const page = (req, res, view) => {
const indexable = view.indexable !== false;
const indexable = view.indexable !== false;
cachePage(req, res, indexable);
cachePage(req, res, indexable);
- const o = { title: view.title, body: view.body, viewer: req.viewer, config, path: req.originalUrl, indexable, description: view.description, jsonLd: view.jsonLd, css: view.css || ['css/catalog.css'], styles: view.styles, agent: indexable };
+ const o = { title: view.title, body: view.body, viewer: req.viewer, config, path: req.originalUrl, indexable, description: view.description, jsonLd: view.jsonLd, css: view.css || ['css/catalog.css'], styles: view.styles, scripts: view.scripts, agent: indexable };
const link = twinHeader(o);
const link = twinHeader(o);
if (link) res.set('Link', link);
if (link) res.set('Link', link);
return res.status(200).type('html').send(renderPage(o));
return res.status(200).type('html').send(renderPage(o));
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
* order never depends on affiliation (the store sorts; nothing here reorders).
* order never depends on affiliation (the store sorts; nothing here reorders).
*/
*/
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+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');
@@ -64,6 +65,74 @@ function priceCell(p) {
@@ -64,6 +65,74 @@ function priceCell(p) {
return `<span class="hc-price">${m ? esc(m) : UNKNOWN}</span><span class="hc-per">/mo</span>`;
return `<span class="hc-price">${m ? esc(m) : UNKNOWN}</span><span class="hc-per">/mo</span>`;
}
}
+const isoAt = (v) => new Date(v).toISOString();
+const MINUS = '−';
+
+/** A trend's step: each price holds until the next point, so a change is a vertical step, never a diagonal. */
+function stepPoints(t) {
+ const points = [];
+ (t.points || []).forEach((p, i) => {
+ if (i) points.push([new Date(Date.parse(p.at) - 1).toISOString(), Number(t.points[i - 1].price)]);
+ points.push([p.at, Number(p.price)]);
+ });
+ return points;
+}
+
+/** The value of a step series at a moment (the last point at or before it). */
+function valueAt(points, time) {
+ let value = null;
+ for (const [at, price] of points) { if (Date.parse(at) > time) break; value = price; }
+ return value;
+}
+
+/** "Was $9 on 2 Sep, now $7 (−22%) · low $7 · high $12"; one observation says when it was first seen instead. */
+function trendSummary(t, label = '') {
+ if (!t || !t.points || !t.points.length) return '';
+ const cur = t.currency;
+ const first = t.points[0];
+ const now = money(t.now, cur) || String(t.now);
+ const was = money(first.price, cur) || String(first.price);
+ let line;
+ if (t.points.length < 2) line = `No changes recorded since ${day(first.at) || first.at} (${now})`;
+ else if (t.change && t.change.amount === 0) line = `Unchanged since ${day(first.at)}: ${now}`;
+ else if (t.change && t.change.percent != null) line = `Was ${was} on ${day(first.at)}, now ${now} (${t.change.amount > 0 ? '+' : MINUS}${Math.abs(t.change.percent)}%)`;
+ else if (t.change) line = `Was ${was} on ${day(first.at)}, now ${now}`;
+ const low = money(t.low, cur) || String(t.low);
+ const high = money(t.high, cur) || String(t.high);
+ const lowAt = (t.points.find((p) => Number(p.price) === Number(t.low)) || first).at;
+ const highAt = (t.points.find((p) => Number(p.price) === Number(t.high)) || first).at;
+ return `<p class="hc-trend-summary">${label ? `<strong>${esc(label)}</strong> ` : ''}${esc(line)} · low ${esc(low)} (${esc(day(lowAt) || '')}), high ${esc(high)} (${esc(day(highAt) || '')}).</p>`;
+}
+
+/** One series' step chart, with its summary line under it. Nothing with one observation. */
+function trendFigure(t, { title = 'Price over time', label = '', seriesName = 'Price', note = 'A price holds until the next change.' } = {}) {
+ if (!t || !t.points || t.points.length < 2) return '';
+ const chart = kit.chart({
+ type: 'line', title, note, height: 220, label: `${title} over time`,
+ x: { time: true, label: 'Date' }, y: { fmt: 'money', zero: false, label: t.currency || 'Price' },
+ series: [{ name: seriesName, points: stepPoints(t) }],
+ });
+ return `<div class="hc-trend">${chart}${trendSummary(t, label)}</div>`;
+}
+
+/** The registration and renewal step series on a shared time axis (kit.chart reads one x list for every series). */
+function tldTrendFigure(trends, { title = 'Domain price over time' } = {}) {
+ const all = [['Registration', trends && trends.register], ['Renewal', trends && trends.renew]].filter(([, t]) => t && t.points && t.points.length);
+ if (!all.length) return '';
+ const steps = all.map(([name, t]) => ({ name, points: stepPoints(t) }));
+ const times = [...new Set(steps.flatMap((s) => s.points.map(([at]) => Date.parse(at))))].sort((a, b) => a - b);
+ const series = steps.map((s) => ({ name: s.name, points: times.map((t) => [isoAt(t), valueAt(s.points, t)]) }));
+ const chart = series.some((s) => s.points.length > 1)
+ ? kit.chart({
+ type: 'line', title, note: 'The cheapest price at any registrar; a price holds until the next change.', height: 240,
+ label: `${title}: registration and renewal`,
+ x: { time: true, label: 'Date' }, y: { fmt: 'money', zero: false, label: 'USD' },
+ series,
+ })
+ : '';
+ 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 } = {}) {
if (!plans.length) return '';
if (!plans.length) return '';
const rows = plans.map((p) => {
const rows = plans.map((p) => {
@@ -219,9 +288,11 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' }
@@ -219,9 +288,11 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '' }
['Locations', p.locations && p.locations.length ? p.locations.join(', ') : null], ['Kind', kindName(p.kind)],
['Locations', p.locations && p.locations.length ? p.locations.join(', ') : null], ['Kind', kindName(p.kind)],
];
];
const value = [['Per vCPU', money(p.price_per_vcpu, p.currency)], ['Per GB of RAM', money(p.price_per_gb_ram, p.currency)], ['Per GB of disk', money(p.price_per_gb_disk, p.currency, 3)]];
const value = [['Per vCPU', money(p.price_per_vcpu, p.currency)], ['Per GB of RAM', money(p.price_per_gb_ram, p.currency)], ['Per GB of disk', money(p.price_per_gb_disk, p.currency, 3)]];
+ const trend = p.price_trend;
+ const chart = trendFigure(trend, { title: 'Monthly price over time', seriesName: 'Monthly price' }) || trendSummary(trend);
const history = p.price_history.length
const history = p.price_history.length
- ? `<ol class="hc-history">${p.price_history.map((h) => `<li><time datetime="${esc(day(Number(h.observed_at)))}">${esc(day(Number(h.observed_at)))}</time> ${esc(money(h.price_monthly_before, h.currency) || h.price_monthly_before)} → <strong>${esc(money(h.price_monthly_after, h.currency) || h.price_monthly_after)}</strong></li>`).join('')}</ol>`
- : '<p class="hc-dim">No price change recorded since the catalog first saw this plan.</p>';
+ ? `<details class="hc-history-fold"><summary>Every change (${p.price_history.length})</summary><ol class="hc-history">${p.price_history.map((h) => `<li><time datetime="${esc(day(Number(h.observed_at)))}">${esc(day(Number(h.observed_at)))}</time> ${esc(money(h.price_monthly_before, h.currency) || h.price_monthly_before)} → <strong>${esc(money(h.price_monthly_after, h.currency) || h.price_monthly_after)}</strong></li>`).join('')}</ol></details>`
+ : '';
return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link(`/catalog/providers/${encodeURIComponent(p.provider_slug)}`, esc(p.provider_name))}</nav>
return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link(`/catalog/providers/${encodeURIComponent(p.provider_slug)}`, esc(p.provider_name))}</nav>
<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">
@@ -236,6 +307,7 @@ ${visit(p)}
@@ -236,6 +307,7 @@ ${visit(p)}
<section aria-labelledby="hc-value-h"><h2 id="hc-value-h">Value</h2><dl class="hc-spec">${value.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${or(v)}</dd></div>`).join('')}</dl></section>
<section aria-labelledby="hc-value-h"><h2 id="hc-value-h">Value</h2><dl class="hc-spec">${value.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${or(v)}</dd></div>`).join('')}</dl></section>
</div>
</div>
<h2>Price history</h2>
<h2>Price history</h2>
+${chart}
${history}
${history}
${baseUrl && watchUrl ? `<p class="hc-watch">${out(`${watchUrl}/watches/new?${new URLSearchParams({ template: 'json', url: `${baseUrl}/api/v1/catalog/plans/${p.id}`, value_path: 'plan.price_monthly', op: 'changed', name: `${p.provider_name} ${p.name} price` })}`, 'Tell me when this price changes', { cls: 'button hc-button-quiet' })} <span class="hc-dim">with OpenVibe.Watch, free</span></p>` : ''}
${baseUrl && watchUrl ? `<p class="hc-watch">${out(`${watchUrl}/watches/new?${new URLSearchParams({ template: 'json', url: `${baseUrl}/api/v1/catalog/plans/${p.id}`, value_path: 'plan.price_monthly', op: 'changed', name: `${p.provider_name} ${p.name} price` })}`, 'Tell me when this price changes', { cls: 'button hc-button-quiet' })} <span class="hc-dim">with OpenVibe.Watch, free</span></p>` : ''}
${extra}
${extra}
@@ -340,8 +412,15 @@ function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], fa
@@ -340,8 +412,15 @@ function frontMore({ counts = [], tiers = [], value = {}, providerCards = [], fa
};
};
}
}
-function front({ plans, providers, promos: live, stats, offers: recent = [], domains: domainSection = '', 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);
+ 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) => {
+ const up = c.after > c.before;
+ const move = c.percent == null ? (up ? 'Rose' : 'Dropped') : `${up ? 'Rose' : 'Dropped'} ${Math.abs(c.percent)}%`;
+ return `<li><a href="${esc(c.href)}"><span class="hc-change-what"><b>${esc(c.name)}</b><span class="hc-dim">${esc(c.kind)}</span></span><span class="hc-change-move ${up ? 'is-up' : 'is-down'}"><span class="hc-change-word">${move}</span><span class="hc-change-prices">${esc(money(c.before, c.currency) || String(c.before))} → ${esc(money(c.after, c.currency) || String(c.after))}</span></span></a></li>`;
+ }).join('')}</ol></section>` : '';
const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
<p class="hc-aside-h">Lowest monthly prices right now</p>
<p class="hc-aside-h">Lowest monthly prices right now</p>
<ol>${plans.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>${plans.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>
@@ -356,7 +435,7 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
@@ -356,7 +435,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 + `<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' })}
<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
@@ -374,4 +453,4 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
@@ -374,4 +453,4 @@ function front({ plans, providers, promos: live, stats, offers: recent = [], dom
+ `<p class="hc-own">Want to host a site with OpenVibe? ${link('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;
+ `<p class="hc-own">Want to host a site with OpenVibe? ${link('https://openvibe.space', 'Visit OpenVibe.Space')}.</p>`;
}
}
-module.exports = { front, catalog, providerList, provider, plan, promos, offers, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb };
+module.exports = { front, catalog, providerList, provider, plan, promos, offers, planTable, jsonLd, DISCLOSURE, money, bytesMb, sizeGb, trendFigure, tldTrendFigure, trendSummary, day };
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
*/
*/
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const { esc } = require('./layout');
const { esc } = require('./layout');
-const { money } = require('./catalog');
+const { money, tldTrendFigure } = require('./catalog');
const domains = require('../catalog/domains');
const domains = require('../catalog/domains');
const UNKNOWN = '<span class="hc-unknown" title="The registrar does not state it">Unknown</span>';
const UNKNOWN = '<span class="hc-unknown" title="The registrar does not state it">Unknown</span>';
@@ -100,11 +100,12 @@ function tld(t, { baseUrl = '', watchUrl = '' } = {}) {
@@ -100,11 +100,12 @@ function tld(t, { baseUrl = '', watchUrl = '' } = {}) {
const c = t.cheapest;
const c = t.cheapest;
const jump = c.register != null && c.renew != null && c.renew > c.register * 1.2;
const jump = c.register != null && c.renew != null && c.renew > c.register * 1.2;
const kind = t.kind === 'second-level' ? `Second level under ${domains.displayTld(t.tld.slice(t.tld.indexOf('.') + 1))}` : domains.KIND_LABEL[t.kind];
const kind = t.kind === 'second-level' ? `Second level under ${domains.displayTld(t.tld.slice(t.tld.indexOf('.') + 1))}` : domains.KIND_LABEL[t.kind];
- const history = t.history.length ? `<ol class="hc-history">${t.history.map((h) => {
+ const trend = tldTrendFigure(t.price_trend, { title: 'Registration and renewal over time' });
+ const history = t.history.length ? `<details class="hc-history-fold"><summary>Every change (${t.history_count || t.history.length})</summary><ol class="hc-history">${t.history.map((h) => {
const moved = [['first year', h.register], ['renewal', h.renew], ['transfer', h.transfer]].filter(([, [a, b]]) => a !== b)
const moved = [['first year', h.register], ['renewal', h.renew], ['transfer', h.transfer]].filter(([, [a, b]]) => a !== b)
.map(([k, [a, b]]) => `${k} ${a == null ? 'none' : esc(money(a, h.currency))} → <strong>${b == null ? 'none' : esc(money(b, h.currency))}</strong>`).join(', ');
.map(([k, [a, b]]) => `${k} ${a == null ? 'none' : esc(money(a, h.currency))} → <strong>${b == null ? 'none' : esc(money(b, h.currency))}</strong>`).join(', ');
return `<li><time datetime="${esc(day(h.observed_at))}">${esc(day(h.observed_at))}</time> ${esc(h.registrar)}: ${moved}</li>`;
return `<li><time datetime="${esc(day(h.observed_at))}">${esc(day(h.observed_at))}</time> ${esc(h.registrar)}: ${moved}</li>`;
- }).join('')}</ol>` : '<p class="hc-dim">No price change recorded since the catalog first saw these prices.</p>';
+ }).join('')}</ol></details>` : '';
const rows = t.prices.map((p) => `<tr>
const rows = t.prices.map((p) => `<tr>
<th scope="row" data-label="Registrar">${link(`/catalog/providers/${encodeURIComponent(p.registrar_slug)}`, esc(p.registrar))}</th>
<th scope="row" data-label="Registrar">${link(`/catalog/providers/${encodeURIComponent(p.registrar_slug)}`, esc(p.registrar))}</th>
<td data-label="First year" class="hc-n hd-first"><span class="hc-price">${usd(p.register)}</span></td>
<td data-label="First year" class="hc-n hd-first"><span class="hc-price">${usd(p.register)}</span></td>
@@ -122,6 +123,7 @@ ${checkForm({ tld: t.tld, label: `Check a ${t.display} name` })}
@@ -122,6 +123,7 @@ ${checkForm({ tld: t.tld, label: `Check a ${t.display} name` })}
<thead><tr><th scope="col">Registrar</th><th scope="col" class="hc-n">First year</th><th scope="col" class="hc-n">Renewal</th><th scope="col" class="hc-n">Transfer</th><th scope="col" class="hc-n">5 years</th><th scope="col">Checked</th><th scope="col"><span class="hd-sr">Register</span></th></tr></thead>
<thead><tr><th scope="col">Registrar</th><th scope="col" class="hc-n">First year</th><th scope="col" class="hc-n">Renewal</th><th scope="col" class="hc-n">Transfer</th><th scope="col" class="hc-n">5 years</th><th scope="col">Checked</th><th scope="col"><span class="hd-sr">Register</span></th></tr></thead>
<tbody>${rows}</tbody></table></div>
<tbody>${rows}</tbody></table></div>
<h2>Price history</h2>
<h2>Price history</h2>
+${trend}
${history}
${history}
${baseUrl && watchUrl ? `<p class="hc-watch">${out(`${watchUrl}/watches/new?${new URLSearchParams({ template: 'json', url: `${baseUrl}/api/v1/catalog/domains/${t.tld}`, value_path: 'tld.cheapest.renew', op: 'changed', name: `${t.display} renewal price` })}`, `Tell me when ${esc(t.display)} prices change`, 'button hc-button-quiet')} <span class="hc-dim">with OpenVibe.Watch, free</span></p>` : ''}
${baseUrl && watchUrl ? `<p class="hc-watch">${out(`${watchUrl}/watches/new?${new URLSearchParams({ template: 'json', url: `${baseUrl}/api/v1/catalog/domains/${t.tld}`, value_path: 'tld.cheapest.renew', op: 'changed', name: `${t.display} renewal price` })}`, `Tell me when ${esc(t.display)} prices change`, 'button hc-button-quiet')} <span class="hc-dim">with OpenVibe.Watch, free</span></p>` : ''}
<section aria-labelledby="hd-reg-h"><h2 id="hd-reg-h">Search a ${esc(t.display)} name at every registrar</h2>${registrarLinks(`yourname.${t.tld}`)}</section>
<section aria-labelledby="hd-reg-h"><h2 id="hd-reg-h">Search a ${esc(t.display)} name at every registrar</h2>${registrarLinks(`yourname.${t.tld}`)}</section>
@@ -117,8 +117,10 @@ function renderPage(o) {
@@ -117,8 +117,10 @@ function renderPage(o) {
`<link rel="stylesheet" href="${asset('css/host.css')}">`,
`<link rel="stylesheet" href="${asset('css/host.css')}">`,
// Host's own stylesheets a page asks for (the catalog's css/catalog.css)
// Host's own stylesheets a page asks for (the catalog's css/catalog.css)
...(o.css || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`),
...(o.css || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`),
- // openvibe-shared stylesheets a page asks for by name (the front page's showcase.css)
+ // openvibe-shared stylesheets a page asks for by name (the front page's showcase.css, the chart's kit.css)
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`),
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`),
+ // openvibe-shared scripts a page asks for by name (kit-data-web.js for a chart's readout)
+ ...(o.scripts || []).map((name) => `<script src="${esc(ovServe.url(name))}" defer></script>`),
`<meta name="ov-boost" content="host@${esc(RELEASE)}">`,
`<meta name="ov-boost" content="host@${esc(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
].join('\n'),
].join('\n'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+/**
+ * Price over time for plans and domain endings (server/catalog/trend.js): a step from the first observation to
+ * today, one point with no change, a rise, a fall, the currency kept, the change percent, the low and the high;
+ * the page's chart and summary line; the two API routes; the catalog home's "Price changes this month" rail.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { planTrend, trend } = require('../server/catalog/trend');
+
+const DAY = 86400000;
+const at = '2026-09-22T12:00:00Z';
+let seq = 0;
+const item = (tld, fields) => ({
+ id: `itm_${String(++seq).padStart(26, '0')}`, source_key: 'porkbun-tld-pricing', category: 'hosting', kind: 'tld_price', identity: tld, canonical_url: null, title: tld,
+ summary: null, authors: [], published_at: null, source_updated_at: null, fields: { special_type: null, ...fields }, revision: 1, removed: null,
+ provenance: { retrieved_at: at, first_seen_at: at, content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'api@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null },
+});
+
+(async () => {
+ await check('the trend maths: one observation, a rise, a fall, currency kept, the change percent', async () => {
+ const t0 = Date.parse('2026-09-01T00:00:00Z');
+ const one = planTrend({ price_monthly: '10', currency: 'USD', first_seen_at: t0 }, [], t0 + DAY);
+ assert.deepStrictEqual([one.points.length, one.now, one.low, one.high, one.change], [1, 10, 10, 10, null]);
+ const rise = planTrend({ price_monthly: '12', currency: 'USD', first_seen_at: t0 }, [
+ { observed_at: t0 + 2 * DAY, currency: 'USD', price_monthly_before: '10', price_monthly_after: '12' },
+ ], t0 + 3 * DAY);
+ assert.deepStrictEqual([rise.points.map((p) => p.price), rise.change], [[10, 12, 12], { amount: 2, percent: 20 }]);
+ assert.strictEqual(rise.points[rise.points.length - 1].at, new Date(t0 + 3 * DAY).toISOString(), 'the current price is the last point');
+ const fall = planTrend({ price_monthly: '8', currency: 'EUR', first_seen_at: t0 }, [
+ { observed_at: t0 + 2 * DAY, currency: 'EUR', price_monthly_before: '10', price_monthly_after: '8' },
+ ], t0 + 3 * DAY);
+ assert.deepStrictEqual([fall.currency, fall.change, fall.low, fall.high], ['EUR', { amount: -2, percent: -20 }, 8, 10]);
+ assert.strictEqual(trend([{ at: t0, price: 5 }], 'USD').change, null, 'one price has no change');
+ // A change in another currency is never compared with the plan's currency.
+ assert.deepStrictEqual(planTrend({ price_monthly: '12', currency: 'USD', first_seen_at: t0 }, [
+ { observed_at: t0 + 2 * DAY, currency: 'EUR', price_monthly_before: '9', price_monthly_after: '12' },
+ ], t0 + 3 * DAY).points.map((p) => p.price), [12]);
+ });
+
+ const t = await boot();
+ t.clock.set(Date.parse('2026-10-01T12:00:00Z'));
+ const staff = t.user('trend-editor', { role: 'admin' });
+ try {
+ const post = async (type, body) => { const r = await t.api('POST', `/api/v1/catalog/${type}`, { as: staff, json: body }); assert.strictEqual(r.status, 201, r.text); return r.json()[type.slice(0, -1)]; };
+ const patch = async (id, body) => { const r = await t.api('PATCH', `/api/v1/catalog/plans/${id}`, { as: staff, json: body }); assert.strictEqual(r.status, 200, r.text); return r.json().plan; };
+ const provider = await post('providers', { slug: 'trendco', name: 'Trend Co', homepage_url: 'https://trend.test' });
+ const plan = async (id, price, currency = 'USD') => await post('plans', { provider_id: provider.id, external_id: id, name: id, kind: 'vps', currency, price_monthly: price, source_url: 'https://trend.test/pricing' });
+ const moved = await plan('moved', 10);
+ const still = await plan('still', 5);
+ const euro = await plan('euro', 20, 'EUR');
+
+ t.clock.advance(5 * DAY);
+ await patch(moved.id, { price_monthly: 12 });
+ t.clock.advance(5 * DAY);
+ await patch(moved.id, { price_monthly: 9 });
+ await patch(euro.id, { price_monthly: 25 });
+
+ await check("the plan's price-trend API: points, currency, now, low, high, change", async () => {
+ const r = await t.api('GET', `/api/v1/catalog/plans/${moved.id}/price-trend`);
+ assert.strictEqual(r.status, 200, r.text);
+ const trend = r.json();
+ assert.deepStrictEqual(Object.keys(trend).sort(), ['change', 'currency', 'high', 'low', 'now', 'points']);
+ assert.strictEqual(trend.currency, 'USD');
+ assert.deepStrictEqual([trend.now, trend.low, trend.high, trend.change], [9, 9, 12, { amount: -1, percent: -10 }]);
+ assert.deepStrictEqual(trend.points.map((p) => p.price), [10, 12, 9]);
+ assert.ok(trend.points.every((p) => /^\d{4}-\d\d-\d\dT/.test(p.at) && Number.isFinite(p.price)));
+ const e = (await t.api('GET', `/api/v1/catalog/plans/${euro.id}/price-trend`)).json();
+ assert.deepStrictEqual([e.currency, e.points.map((p) => p.price)], ['EUR', [20, 25]]);
+ const none = (await t.api('GET', `/api/v1/catalog/plans/${still.id}/price-trend`)).json();
+ assert.deepStrictEqual([none.points.length, none.change, none.low, none.high], [1, null, 5, 5]);
+ assert.strictEqual((await t.api('GET', '/api/v1/catalog/plans/nope/price-trend')).status, 404);
+ });
+
+ await check("the plan page draws the chart, the summary line, and folds every change", async () => {
+ const r = await t.api('GET', `/catalog/plans/${moved.id}`);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /class="ovk-chart ovk-chart--line"/, 'the shared kit draws it');
+ assert.match(r.text, /Monthly price over time/);
+ assert.match(r.text, /Was \$10\.00 on 2026-10-\d\d, now \$9\.00 \(−10%\)/);
+ assert.match(r.text, /low \$9\.00 \([^)]*\), high \$12\.00/);
+ assert.match(r.text, /<details class="hc-history-fold"><summary>Every change \(2\)<\/summary>/);
+ assert.match(r.text, /\/shared\/kit-data\.css\?v=/);
+ assert.match(r.text, /\/shared\/kit-data-web\.js\?v=/);
+ const quiet = await t.api('GET', `/catalog/plans/${still.id}`);
+ assert.doesNotMatch(quiet.text, /class="ovk-chart/, 'one observation has no chart');
+ assert.match(quiet.text, /No changes recorded since 2026-10-01 \(\$5\.00\)/);
+ });
+
+ await check("the catalog home's rail: this month's moves, biggest first, rose and dropped in words", async () => {
+ const r = await t.api('GET', '/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Price changes this month/);
+ assert.match(r.text, /class="hc-change-word">Dropped 25%<\/span>/, 'a fall is a word, not only a colour');
+ assert.match(r.text, /class="hc-change-word">Rose 25%<\/span>/, 'the EUR plan rose 25%');
+ const order = [...r.text.matchAll(/class="hc-change-word">([^<]+)</g)].map((m) => m[1]);
+ assert.deepStrictEqual(order.slice(0, 2).sort(), ['Dropped 25%', 'Rose 25%'], 'the two 25% moves lead');
+ assert.strictEqual(order[2], 'Rose 20%');
+ });
+
+ await check('a TLD\'s registration and renewal over time, on its page and in the API', async () => {
+ assert.strictEqual(await t.ctx.catalog.applyItem(item('dev', { registration: '9.00', renewal: '12.00', transfer: '9.00' })), 'applied');
+ t.clock.advance(7 * DAY);
+ assert.strictEqual(await t.ctx.catalog.applyItem({ ...item('dev', { registration: '8.00', renewal: '13.00', transfer: '9.00' }), provenance: { ...item('dev', {}).provenance, retrieved_at: new Date(t.clock.now()).toISOString() } }), 'applied');
+ const r = await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=register');
+ assert.strictEqual(r.status, 200, r.text);
+ const register = r.json();
+ assert.deepStrictEqual([register.kind, register.currency, register.now, register.low, register.high, register.change], ['register', 'USD', 8, 8, 9, { amount: -1, percent: -11 }]);
+ const renew = (await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=renew')).json();
+ assert.deepStrictEqual([renew.kind, renew.now, renew.high, renew.change], ['renew', 13, 13, { amount: 1, percent: 8 }]);
+ assert.strictEqual((await t.api('GET', '/api/v1/catalog/tlds/dev/price-trend?kind=transfer')).status, 422);
+ const page = await t.api('GET', '/catalog/domains/dev');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /Registration and renewal over time/);
+ assert.match(page.text, /class="ovk-chart ovk-chart--line"/);
+ assert.match(page.text, /Registration<\/strong> Was \$9\.00 on 2026-09-\d\d, now \$8\.00 \(−11%\)/);
+ assert.match(page.text, /Renewal<\/strong> Was \$12\.00 on 2026-09-\d\d, now \$13\.00 \(\+8%\)/);
+ assert.match(page.text, /Every change \(1\)/);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -18,8 +18,8 @@ const BUDGETS = {
@@ -18,8 +18,8 @@ const BUDGETS = {
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 239.1 (openvibe-shared 2.6.0 served scripts; was 212.2 on 2.2.0)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 239.1 (openvibe-shared 2.6.0 served scripts; was 212.2 on 2.2.0)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 56.3 (was 49.9)
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 56.3 (was 49.9)
cssFiles: 3, // 3 (host.css, openvibe-shared showcase.css, catalog.css: the front page is the hosting catalog since Host#69)
cssFiles: 3, // 3 (host.css, openvibe-shared showcase.css, catalog.css: the front page is the hosting catalog since Host#69)
- cssRawKB: 54, // 53.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 51.3 the home's phone rows and fold (2026-10-29); 46.2 openvibe-shared 4.26.1 (hero visual); 40.7 with Shared 4.13.0 (2026-10-26: account links); 34.8 on 2026-10-22 (the front page's cards and questions); 31.9 (2026-10-16 the domain comparison's and benchmarks' styles; 27.2 with showcase.css 4.1.5, 14.8 before catalog.css)
- cssBrotliKB: 11.1, // 10.9 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 10.1 with the phone rules that show four rows of each front-page list (2026-10-29); 8.3 with Shared 4.13.0 (2026-10-26: account links); 6.9 (5.7 before the domain and benchmark styles, 3.6 before catalog.css)
+ cssRawKB: 57, // 55.8 with the price-trend chart's placement, the home's change rail and the Every change fold (2026-10-30); 53.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 51.3 the home's phone rows and fold (2026-10-29); 46.2 openvibe-shared 4.26.1 (hero visual); 40.7 with Shared 4.13.0 (2026-10-26: account links); 34.8 on 2026-10-22 (the front page's cards and questions); 31.9 (2026-10-16 the domain comparison's and benchmarks' styles; 27.2 with showcase.css 4.1.5, 14.8 before catalog.css)
+ cssBrotliKB: 11.5, // 11.2 with the price-trend chart's placement, the home's change rail and the Every change fold (2026-10-30); 10.9 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 10.1 with the phone rules that show four rows of each front-page list (2026-10-29); 8.3 with Shared 4.13.0 (2026-10-26: account links); 6.9 (5.7 before the domain and benchmark styles, 3.6 before catalog.css)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};