openvibers/openvibe.deals Commits
Deals: the price over time on offer pages (latest, lowest, highest, change since first seen, a step line drawn on the server) and price_trend in the offer JSON and API (#117)
Changes
@@@ -76,6 +76,32 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }table.obs, table.compare { width: 100%; border-collapse: collapse; font-size: .92rem; display: block; overflow-x: auto; }table.obs th, table.obs td, table.compare th, table.compare td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); vertical-align: top; }table.obs caption, table.compare caption { text-align: left; color: var(--text-muted, #7386a3); font-size: .85rem; padding: 4px 0; }++/* The price over time (views.js priceChart): tiles, then a step line with the high and low on the left. */++.pc { margin: 8px 0 18px; }++.pc-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; margin: 0 0 12px; }++.pc-tile { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); min-width: 0; }++.pc-tile dt { color: var(--text-muted, #7386a3); font-size: .8rem; }++.pc-tile dd { margin: 2px 0 0; display: flex; flex-direction: column; gap: 1px; }++.pc-tile strong { font-size: 1.05rem; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }++.pc-tile .meta { font-size: .8rem; }++.pc-best { border-color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--border, #1f2d47)); }++.pc-best .meta::before { content: "\2713\00a0"; }++.pc-plot { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px 10px; }++.pc-y { position: relative; min-width: 3ch; font-size: .78rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }++.pc-y span { position: absolute; right: 0; transform: translateY(-50%); white-space: nowrap; }++.pc-area { position: relative; height: 128px; margin: 0 6px; }++.pc-area svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }++.pc-grid { stroke: var(--border, #1f2d47); stroke-width: 1; vector-effect: non-scaling-stroke; }++.pc-line { fill: none; stroke: var(--accent, #3b82f6); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }++.pc-dot, .pc-hit { position: absolute; transform: translate(-50%, -50%); border-radius: 50%; }++.pc-dot { width: 10px; height: 10px; box-shadow: 0 0 0 2px var(--bg-primary, #0b1220); pointer-events: none; }++.pc-end { background: var(--accent, #3b82f6); }++.pc-low { background: var(--bg-primary, #0b1220); border: 2px solid var(--accent, #3b82f6); }++.pc-hit { width: 18px; height: 18px; }++@media (hover: hover) { .pc-hit:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 25%, transparent); } }++.pc-x { grid-column: 2; display: flex; justify-content: space-between; gap: 8px; margin: 0 6px; font-size: .78rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }++.pc figcaption { margin-top: 6px; font-size: .82rem; }++@media (forced-colors: active) { .pc-line { stroke: CanvasText; } .pc-end { background: CanvasText; } .pc-low { border-color: CanvasText; } }tr.removed { opacity: .6; }.vote button[aria-pressed="true"] { outline: 2px solid var(--on-accent, #fff); }.mod form { margin: 10px 0; }nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++/**++ * A deal's price over time, from its observations (reads.observations: newest first).++ *++ * Only stated prices count, only in the currency of the latest stated price (a listing seen in two++ * currencies is never compared across them: nothing is converted), and never from a removed source.++ * Prices stay the text the source stated; the numbers only order and measure them.++ *++ * priceTrend(observations) → null (no stated price) or++ * { currency, count, points: [{ at, num, price }] oldest first,++ * first, latest, lowest, highest: { at, num, price },++ * change: { amount, percent } | null (one price only),++ * atLowest: the latest price is the lowest seen (with two or more prices that differ) }++ *++ * The offer page draws it (render/views.js priceChart) and the JSON twin and API carry it as++ * price_trend (publication.js trendDto).++ */++++const decimals = (s) => { const m = /\.(\d+)$/.exec(String(s)); return m ? m[1].length : 0; };++++function priceTrend(observations) {++ const stated = (observations || []).filter((o) => o.price != null && o.price_num != null && Number.isFinite(Number(o.price_num)) && !o.source_removed_at);++ const ref = stated.find((o) => o.currency) || stated[0];++ if (!ref) return null;++ const points = stated.filter((o) => (o.currency || null) === (ref.currency || null))++ .map((o) => ({ at: Number(o.observed_at), num: Number(o.price_num), price: o.price }))++ .reverse();++ const first = points[0];++ const latest = points[points.length - 1];++ // Ties go to the earliest time: "lowest since" is when it was first that low.++ const lowest = points.reduce((a, p) => (p.num < a.num ? p : a));++ const highest = points.reduce((a, p) => (p.num > a.num ? p : a));++ const places = Math.max(decimals(first.price), decimals(latest.price));++ const change = points.length > 1 ? {++ amount: Number((latest.num - first.num).toFixed(places)),++ percent: first.num > 0 ? Math.round(((latest.num - first.num) / first.num) * 100) : null,++ } : null;++ return {++ currency: ref.currency || null,++ count: points.length,++ points,++ first, latest, lowest, highest,++ change,++ places,++ atLowest: points.length > 1 && lowest.num < highest.num && latest.num <= lowest.num,++ };++}++++module.exports = { priceTrend };@@@ -21,6 +21,7 @@const seo = require('openvibe-publishing/seo');const { iso } = require('./values');const { attributionOf, hidesFromExtension } = require('./source-policy');++const { priceTrend } = require('./price-trend');function createPublication({ config, store, reads, catalog }) {const abs = (p) => seo.canonicalUrl(config.baseUrl, p);@@@ -69,12 +70,14 @@ function createPublication({ config, store, reads, catalog }) {const tally = await reads.tally(ids);const snap = await reads.lastSnapshot(root.id);const sources = await reads.sources(ids);++ const observations = await reads.observations(ids);return {root,ids,store: await catalog.store(root.store_id),product: await catalog.product(root.product_id),-- observations: await reads.observations(ids),++ observations,++ trend: priceTrend(observations),latest,freshness: freshness(latest, now),sources,@@@ -111,6 +114,13 @@ function createPublication({ config, store, reads, catalog }) {};}++ /** The price over time as JSON: the stated prices, their times, and the change since the first. */++ function trendDto(t) {++ if (!t) return null;++ const at = (p) => ({ price: p.price, observed_at: iso(p.at) });++ return { currency: t.currency, observations: t.count, first: at(t.first), latest: at(t.latest), lowest: at(t.lowest), highest: at(t.highest), change: t.change, at_lowest: t.atLowest };++ }++/** The JSON representation (API and /d/:slug.json): the same facts as the page. */function offerDto(v) {const r = v.root;@@@ -130,6 +140,8 @@ function createPublication({ config, store, reads, catalog }) {freshness: v.freshness,attribution: v.attribution || null,observations: v.observations.map(observationDto),++ // Lowest, highest and the change since first seen, in the latest price's currency only (never converted).++ price_trend: trendDto(v.trend),sources: v.sources.map((s) => ({id: s.id, kind: s.kind, listing: s.offer_id,ref: s.ref_service === 'sources' ? { service: 'sources', type: 'item', id: s.ref_id, revision: s.ref_revision } : null,@@@ -115,6 +115,54 @@ function observationTable(v, urls) {<tbody>${rows}</tbody></table>`;}++const day = (ms) => when(ms).slice(0, 10);++const pct = (n, max) => Math.round((n / max) * 1000) / 10;++++/**++ * The price over time (domain/price-trend.js) above the history table: four tiles (latest, lowest,++ * highest, change since first seen) and a step line, since a price holds until the next observation.++ * Drawn on the server, readable without JavaScript; each point names its price and time on hover,++ * and the table under it lists every observation.++ */++function priceChart(t) {++ if (!t || t.count < 2) return '';++ const cur = t.currency ? ` ${t.currency}` : '';++ const span = t.latest.at - t.first.at;++ const flat = t.highest.num === t.lowest.num;++ const x = (p, i) => (span > 0 ? (p.at - t.first.at) / span : i / (t.count - 1)) * 1000;++ const y = (n) => (flat ? 50 : 10 + ((t.highest.num - n) / (t.highest.num - t.lowest.num)) * 80);++ const pts = t.points.map((p, i) => ({ p, x: Math.round(x(p, i) * 10) / 10, y: Math.round(y(p.num) * 10) / 10 }));++ const d = pts.map((q, i) => (i === 0 ? `M${q.x} ${q.y}` : `H${q.x}V${q.y}`)).join('');++ const dot = (q, cls, label) => html`<span class="${cls}" style="left:${pct(q.x, 1000)}%;top:${q.y}%"${label ? html` title="${label}"` : ''}></span>`;++ const lowQ = pts.find((q) => q.p === t.lowest);++ const endQ = pts[pts.length - 1];++ // Hover targets name each point; past 60 points only the latest and the lowest do.++ const hits = pts.length <= 60 ? pts.map((q) => dot(q, 'pc-hit', `${q.p.price}${cur} · ${when(q.p.at)}`)) : [];++ const ch = t.change;++ const sign = ch.amount > 0 ? '+' : ch.amount < 0 ? '−' : '';++ const changeText = ch.amount === 0 ? 'No change' : `${sign}${Math.abs(ch.amount).toFixed(t.places)}${cur}`;++ const changeNote = `${ch.amount !== 0 && ch.percent != null ? `${sign}${Math.abs(ch.percent)}% ` : ''}from ${t.first.price}${cur} on ${day(t.first.at)}`;++ const summary = `Price over time${cur ? ` in${cur}` : ''}: ${t.first.price} on ${day(t.first.at)}, ${t.latest.price} on ${day(t.latest.at)}; lowest ${t.lowest.price} (${day(t.lowest.at)}), highest ${t.highest.price} (${day(t.highest.at)}), ${t.count} prices.`;++ const tile = (label, value, note, cls = '') => html`<div class="pc-tile${cls}"><dt>${label}</dt><dd><strong>${value}</strong><span class="meta">${note}</span></dd></div>`;++ return html`<figure class="pc" aria-label="Price over time">++<dl class="pc-tiles">++${tile('Latest', `${t.latest.price}${cur}`, day(t.latest.at))}++${tile('Lowest', `${t.lowest.price}${cur}`, t.atLowest ? 'the lowest we have seen' : day(t.lowest.at), t.atLowest ? ' pc-best' : '')}++${tile('Highest', `${t.highest.price}${cur}`, day(t.highest.at))}++${tile('Since first seen', changeText, changeNote)}++</dl>++<div class="pc-plot">++<div class="pc-y" aria-hidden="true">${flat ? html`<span style="top:50%">${t.latest.price}</span>` : html`<span style="top:10%">${t.highest.price}</span><span style="top:90%">${t.lowest.price}</span>`}</div>++<div class="pc-area" role="img" aria-label="${summary}">++<svg viewBox="0 0 1000 100" preserveAspectRatio="none" aria-hidden="true" focusable="false">${flat ? html`<line class="pc-grid" x1="0" x2="1000" y1="50" y2="50"/>` : html`<line class="pc-grid" x1="0" x2="1000" y1="10" y2="10"/><line class="pc-grid" x1="0" x2="1000" y1="90" y2="90"/>`}<path class="pc-line" d="${d}"/></svg>++${hits}${lowQ !== endQ && !flat ? dot(lowQ, 'pc-dot pc-low', '') : ''}${dot(endQ, 'pc-dot pc-end', '')}++</div>++<div class="pc-x" aria-hidden="true"><span>${day(t.first.at)}</span><span>${day(t.latest.at)}</span></div>++</div>++<figcaption class="meta">A price holds until the next observation, and only as of its time. ${t.currency ? `Prices in other currencies are not compared.` : ''}</figcaption>++</figure>`;++}++function sourceLabel(o) {if (o.source_kind === 'sources_item') return html`${o.source_key || 'source'}${safeHref(o.source_url) ? html` · <a href="${safeHref(o.source_url)}" rel="nofollow noopener">item</a>` : ''}`;if (o.origin === 'community' && o.source_kind === 'submission') return 'the submission';@@@ -163,6 +211,7 @@ ${r.seller ? html`<dt>Sold by</dt><dd>${urls.sellerPath ? html`<a href="${urls.s<dt>Posted</dt><dd>${timeEl(r.created_at)} ${r.origin === 'import' ? '(imported)' : '(by a member)'}</dd></dl><h2 id="history">Price history</h2>++${priceChart(v.trend)}${observationTable(v, urls)}<h2 id="sources">Sources</h2><ul class="sources">${v.sources.map((s) => html`<li>${s.kind === 'sources_item' ? html`OpenVibe.Sources item <code>${s.ref_id}</code> from <code>${s.source_key || 'a source'}</code>${safeHref(s.url) ? html` — <a href="${safeHref(s.url)}" rel="nofollow noopener">original</a>` : ''}${s.retrieved_at ? html`, retrieved ${timeEl(s.retrieved_at)}` : ''}${s.license_note ? html` · ${s.license_note}` : ''}` : s.kind === 'submission' ? html`Submitted by a member ${timeEl(s.created_at)}` : html`Observations reported by a member`}${s.removed_at ? html` <span class="badge expired">removed: ${s.removed_reason}</span>` : ''}${s.offer_id !== r.id ? html` <span class="meta">(merged listing)</span>` : ''}</li>`)}</ul>nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * The price over time (domain/price-trend.js): lowest, highest and the change since first seen, in the++ * latest price's currency only; the offer page's tiles and step line; price_trend in the JSON twin.++ */++const assert = require('assert');++const { priceTrend } = require('../server/domain/price-trend');++const { boot, check, done, HOUR } = require('./helpers/boot');++++const obs = (at, price, currency = 'USD', extra = {}) => ({ observed_at: at, price, price_num: price == null ? null : Number(price), currency, ...extra });++++(async () => {++ await check('priceTrend: newest-first observations → oldest-first points, lowest, highest, change', async () => {++ const t = priceTrend([obs(4, '64.00'), obs(3, '59.00'), obs(2, '89.00'), obs(1, '79.00')]);++ assert.deepStrictEqual(t.points.map((p) => p.price), ['79.00', '89.00', '59.00', '64.00']);++ assert.strictEqual(t.lowest.price, '59.00');++ assert.strictEqual(t.highest.price, '89.00');++ assert.deepStrictEqual(t.change, { amount: -15, percent: -19 });++ assert.strictEqual(t.atLowest, false);++ assert.strictEqual(t.currency, 'USD');++ });++++ await check('priceTrend: only the latest currency, only stated prices, never a removed source', async () => {++ const t = priceTrend([obs(5, '50.00'), obs(4, null), obs(3, '40.00', 'EUR'), obs(2, '30.00', 'USD', { source_removed_at: 9 }), obs(1, '60.00')]);++ assert.deepStrictEqual(t.points.map((p) => p.price), ['60.00', '50.00']);++ assert.strictEqual(t.atLowest, true, 'the latest is the lowest seen');++ assert.strictEqual(priceTrend([obs(1, null)]), null);++ assert.strictEqual(priceTrend([]), null);++ const one = priceTrend([obs(1, '9.99')]);++ assert.strictEqual(one.change, null);++ assert.strictEqual(one.atLowest, false, 'one price is not "the lowest we have seen"');++ });++++ await check('priceTrend: the same price twice is no change and not a low; ties keep the earliest time', async () => {++ const t = priceTrend([obs(2, '20'), obs(1, '20')]);++ assert.deepStrictEqual(t.change, { amount: 0, percent: 0 });++ assert.strictEqual(t.atLowest, false);++ assert.strictEqual(t.lowest.at, 1);++ });++++ const t = await boot();++ const [alice, bob] = ['alice', 'bob'].map((n) => t.network.addUser(n));++++ await check('an offer page with several prices: tiles, a step line naming each point, the table still under it', async () => {++ const deal = await t.submit(alice, { url: 'https://gadgets.example/kettle', title: 'Glass kettle', price: '79.00', currency: 'USD', condition: 'new' });++ const one = await t.get(`/d/${deal.slug}`);++ assert.doesNotMatch(one.text, /class="pc"/, 'one price draws no chart');++ for (const price of ['89.00', '59.00', '64.00']) {++ t.clock.advance(2 * HOUR);++ const r = await t.get(`/d/${deal.slug}/observe`, { as: bob, form: { price, currency: 'USD' } });++ assert.strictEqual(r.status, 303, r.text.slice(0, 200));++ }++ const page = await t.get(`/d/${deal.slug}`);++ assert.match(page.text, /<figure class="pc"/);++ assert.match(page.text, /<dt>Lowest<\/dt><dd><strong>59\.00 USD<\/strong>/);++ assert.match(page.text, /<dt>Highest<\/dt><dd><strong>89\.00 USD<\/strong>/);++ assert.match(page.text, /<dt>Since first seen<\/dt><dd><strong>−15\.00 USD<\/strong><span class="meta">−19% from 79\.00 USD on /);++ assert.match(page.text, /<path class="pc-line" d="M0 [\d.]+H/);++ assert.strictEqual((page.text.match(/class="pc-hit"/g) || []).length, 4, 'every point names its price and time');++ assert.match(page.text, /role="img" aria-label="Price over time in USD: 79\.00 on /);++ assert.ok(page.text.indexOf('class="pc"') < page.text.indexOf('<table class="obs">'), 'the chart sits above the table');++++ const json = await t.offerJson(deal.slug);++ assert.strictEqual(json.price_trend.currency, 'USD');++ assert.strictEqual(json.price_trend.observations, 4);++ assert.strictEqual(json.price_trend.lowest.price, '59.00');++ assert.match(json.price_trend.lowest.observed_at, /^\d{4}-\d\d-\d\dT/);++ assert.deepStrictEqual(json.price_trend.change, { amount: -15, percent: -19 });++ assert.strictEqual(json.price_trend.at_lowest, false);++++ t.clock.advance(2 * HOUR);++ await t.get(`/d/${deal.slug}/observe`, { as: bob, form: { price: '55.00', currency: 'USD' } });++ const low = await t.get(`/d/${deal.slug}`);++ assert.match(low.text, /class="pc-tile pc-best"><dt>Lowest<\/dt><dd><strong>55\.00 USD<\/strong><span class="meta">the lowest we have seen/);++ assert.strictEqual((await t.offerJson(deal.slug)).price_trend.at_lowest, true);++ });++++ await t.close();++ done();++})();
@@ -76,6 +76,32 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
@@ -76,6 +76,32 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
table.obs, table.compare { width: 100%; border-collapse: collapse; font-size: .92rem; display: block; overflow-x: auto; }
table.obs, table.compare { width: 100%; border-collapse: collapse; font-size: .92rem; display: block; overflow-x: auto; }
table.obs th, table.obs td, table.compare th, table.compare td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); vertical-align: top; }
table.obs th, table.obs td, table.compare th, table.compare td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); vertical-align: top; }
table.obs caption, table.compare caption { text-align: left; color: var(--text-muted, #7386a3); font-size: .85rem; padding: 4px 0; }
table.obs caption, table.compare caption { text-align: left; color: var(--text-muted, #7386a3); font-size: .85rem; padding: 4px 0; }
+/* The price over time (views.js priceChart): tiles, then a step line with the high and low on the left. */
+.pc { margin: 8px 0 18px; }
+.pc-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; margin: 0 0 12px; }
+.pc-tile { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); min-width: 0; }
+.pc-tile dt { color: var(--text-muted, #7386a3); font-size: .8rem; }
+.pc-tile dd { margin: 2px 0 0; display: flex; flex-direction: column; gap: 1px; }
+.pc-tile strong { font-size: 1.05rem; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.pc-tile .meta { font-size: .8rem; }
+.pc-best { border-color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--border, #1f2d47)); }
+.pc-best .meta::before { content: "\2713\00a0"; }
+.pc-plot { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px 10px; }
+.pc-y { position: relative; min-width: 3ch; font-size: .78rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
+.pc-y span { position: absolute; right: 0; transform: translateY(-50%); white-space: nowrap; }
+.pc-area { position: relative; height: 128px; margin: 0 6px; }
+.pc-area svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
+.pc-grid { stroke: var(--border, #1f2d47); stroke-width: 1; vector-effect: non-scaling-stroke; }
+.pc-line { fill: none; stroke: var(--accent, #3b82f6); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
+.pc-dot, .pc-hit { position: absolute; transform: translate(-50%, -50%); border-radius: 50%; }
+.pc-dot { width: 10px; height: 10px; box-shadow: 0 0 0 2px var(--bg-primary, #0b1220); pointer-events: none; }
+.pc-end { background: var(--accent, #3b82f6); }
+.pc-low { background: var(--bg-primary, #0b1220); border: 2px solid var(--accent, #3b82f6); }
+.pc-hit { width: 18px; height: 18px; }
+@media (hover: hover) { .pc-hit:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 25%, transparent); } }
+.pc-x { grid-column: 2; display: flex; justify-content: space-between; gap: 8px; margin: 0 6px; font-size: .78rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
+.pc figcaption { margin-top: 6px; font-size: .82rem; }
+@media (forced-colors: active) { .pc-line { stroke: CanvasText; } .pc-end { background: CanvasText; } .pc-low { border-color: CanvasText; } }
tr.removed { opacity: .6; }
tr.removed { opacity: .6; }
.vote button[aria-pressed="true"] { outline: 2px solid var(--on-accent, #fff); }
.vote button[aria-pressed="true"] { outline: 2px solid var(--on-accent, #fff); }
.mod form { margin: 10px 0; }
.mod form { margin: 10px 0; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+/**
+ * A deal's price over time, from its observations (reads.observations: newest first).
+ *
+ * Only stated prices count, only in the currency of the latest stated price (a listing seen in two
+ * currencies is never compared across them: nothing is converted), and never from a removed source.
+ * Prices stay the text the source stated; the numbers only order and measure them.
+ *
+ * priceTrend(observations) → null (no stated price) or
+ * { currency, count, points: [{ at, num, price }] oldest first,
+ * first, latest, lowest, highest: { at, num, price },
+ * change: { amount, percent } | null (one price only),
+ * atLowest: the latest price is the lowest seen (with two or more prices that differ) }
+ *
+ * The offer page draws it (render/views.js priceChart) and the JSON twin and API carry it as
+ * price_trend (publication.js trendDto).
+ */
+
+const decimals = (s) => { const m = /\.(\d+)$/.exec(String(s)); return m ? m[1].length : 0; };
+
+function priceTrend(observations) {
+ const stated = (observations || []).filter((o) => o.price != null && o.price_num != null && Number.isFinite(Number(o.price_num)) && !o.source_removed_at);
+ const ref = stated.find((o) => o.currency) || stated[0];
+ if (!ref) return null;
+ const points = stated.filter((o) => (o.currency || null) === (ref.currency || null))
+ .map((o) => ({ at: Number(o.observed_at), num: Number(o.price_num), price: o.price }))
+ .reverse();
+ const first = points[0];
+ const latest = points[points.length - 1];
+ // Ties go to the earliest time: "lowest since" is when it was first that low.
+ const lowest = points.reduce((a, p) => (p.num < a.num ? p : a));
+ const highest = points.reduce((a, p) => (p.num > a.num ? p : a));
+ const places = Math.max(decimals(first.price), decimals(latest.price));
+ const change = points.length > 1 ? {
+ amount: Number((latest.num - first.num).toFixed(places)),
+ percent: first.num > 0 ? Math.round(((latest.num - first.num) / first.num) * 100) : null,
+ } : null;
+ return {
+ currency: ref.currency || null,
+ count: points.length,
+ points,
+ first, latest, lowest, highest,
+ change,
+ places,
+ atLowest: points.length > 1 && lowest.num < highest.num && latest.num <= lowest.num,
+ };
+}
+
+module.exports = { priceTrend };
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const { iso } = require('./values');
const { iso } = require('./values');
const { attributionOf, hidesFromExtension } = require('./source-policy');
const { attributionOf, hidesFromExtension } = require('./source-policy');
+const { priceTrend } = require('./price-trend');
function createPublication({ config, store, reads, catalog }) {
function createPublication({ config, store, reads, catalog }) {
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
@@ -69,12 +70,14 @@ function createPublication({ config, store, reads, catalog }) {
@@ -69,12 +70,14 @@ function createPublication({ config, store, reads, catalog }) {
const tally = await reads.tally(ids);
const tally = await reads.tally(ids);
const snap = await reads.lastSnapshot(root.id);
const snap = await reads.lastSnapshot(root.id);
const sources = await reads.sources(ids);
const sources = await reads.sources(ids);
+ const observations = await reads.observations(ids);
return {
return {
root,
root,
ids,
ids,
store: await catalog.store(root.store_id),
store: await catalog.store(root.store_id),
product: await catalog.product(root.product_id),
product: await catalog.product(root.product_id),
- observations: await reads.observations(ids),
+ observations,
+ trend: priceTrend(observations),
latest,
latest,
freshness: freshness(latest, now),
freshness: freshness(latest, now),
sources,
sources,
@@ -111,6 +114,13 @@ function createPublication({ config, store, reads, catalog }) {
@@ -111,6 +114,13 @@ function createPublication({ config, store, reads, catalog }) {
};
};
}
}
+ /** The price over time as JSON: the stated prices, their times, and the change since the first. */
+ function trendDto(t) {
+ if (!t) return null;
+ const at = (p) => ({ price: p.price, observed_at: iso(p.at) });
+ return { currency: t.currency, observations: t.count, first: at(t.first), latest: at(t.latest), lowest: at(t.lowest), highest: at(t.highest), change: t.change, at_lowest: t.atLowest };
+ }
+
/** The JSON representation (API and /d/:slug.json): the same facts as the page. */
/** The JSON representation (API and /d/:slug.json): the same facts as the page. */
function offerDto(v) {
function offerDto(v) {
const r = v.root;
const r = v.root;
@@ -130,6 +140,8 @@ function createPublication({ config, store, reads, catalog }) {
@@ -130,6 +140,8 @@ function createPublication({ config, store, reads, catalog }) {
freshness: v.freshness,
freshness: v.freshness,
attribution: v.attribution || null,
attribution: v.attribution || null,
observations: v.observations.map(observationDto),
observations: v.observations.map(observationDto),
+ // Lowest, highest and the change since first seen, in the latest price's currency only (never converted).
+ price_trend: trendDto(v.trend),
sources: v.sources.map((s) => ({
sources: v.sources.map((s) => ({
id: s.id, kind: s.kind, listing: s.offer_id,
id: s.id, kind: s.kind, listing: s.offer_id,
ref: s.ref_service === 'sources' ? { service: 'sources', type: 'item', id: s.ref_id, revision: s.ref_revision } : null,
ref: s.ref_service === 'sources' ? { service: 'sources', type: 'item', id: s.ref_id, revision: s.ref_revision } : null,
@@ -115,6 +115,54 @@ function observationTable(v, urls) {
@@ -115,6 +115,54 @@ function observationTable(v, urls) {
<tbody>${rows}</tbody></table>`;
<tbody>${rows}</tbody></table>`;
}
}
+const day = (ms) => when(ms).slice(0, 10);
+const pct = (n, max) => Math.round((n / max) * 1000) / 10;
+
+/**
+ * The price over time (domain/price-trend.js) above the history table: four tiles (latest, lowest,
+ * highest, change since first seen) and a step line, since a price holds until the next observation.
+ * Drawn on the server, readable without JavaScript; each point names its price and time on hover,
+ * and the table under it lists every observation.
+ */
+function priceChart(t) {
+ if (!t || t.count < 2) return '';
+ const cur = t.currency ? ` ${t.currency}` : '';
+ const span = t.latest.at - t.first.at;
+ const flat = t.highest.num === t.lowest.num;
+ const x = (p, i) => (span > 0 ? (p.at - t.first.at) / span : i / (t.count - 1)) * 1000;
+ const y = (n) => (flat ? 50 : 10 + ((t.highest.num - n) / (t.highest.num - t.lowest.num)) * 80);
+ const pts = t.points.map((p, i) => ({ p, x: Math.round(x(p, i) * 10) / 10, y: Math.round(y(p.num) * 10) / 10 }));
+ const d = pts.map((q, i) => (i === 0 ? `M${q.x} ${q.y}` : `H${q.x}V${q.y}`)).join('');
+ const dot = (q, cls, label) => html`<span class="${cls}" style="left:${pct(q.x, 1000)}%;top:${q.y}%"${label ? html` title="${label}"` : ''}></span>`;
+ const lowQ = pts.find((q) => q.p === t.lowest);
+ const endQ = pts[pts.length - 1];
+ // Hover targets name each point; past 60 points only the latest and the lowest do.
+ const hits = pts.length <= 60 ? pts.map((q) => dot(q, 'pc-hit', `${q.p.price}${cur} · ${when(q.p.at)}`)) : [];
+ const ch = t.change;
+ const sign = ch.amount > 0 ? '+' : ch.amount < 0 ? '−' : '';
+ const changeText = ch.amount === 0 ? 'No change' : `${sign}${Math.abs(ch.amount).toFixed(t.places)}${cur}`;
+ const changeNote = `${ch.amount !== 0 && ch.percent != null ? `${sign}${Math.abs(ch.percent)}% ` : ''}from ${t.first.price}${cur} on ${day(t.first.at)}`;
+ const summary = `Price over time${cur ? ` in${cur}` : ''}: ${t.first.price} on ${day(t.first.at)}, ${t.latest.price} on ${day(t.latest.at)}; lowest ${t.lowest.price} (${day(t.lowest.at)}), highest ${t.highest.price} (${day(t.highest.at)}), ${t.count} prices.`;
+ const tile = (label, value, note, cls = '') => html`<div class="pc-tile${cls}"><dt>${label}</dt><dd><strong>${value}</strong><span class="meta">${note}</span></dd></div>`;
+ return html`<figure class="pc" aria-label="Price over time">
+<dl class="pc-tiles">
+${tile('Latest', `${t.latest.price}${cur}`, day(t.latest.at))}
+${tile('Lowest', `${t.lowest.price}${cur}`, t.atLowest ? 'the lowest we have seen' : day(t.lowest.at), t.atLowest ? ' pc-best' : '')}
+${tile('Highest', `${t.highest.price}${cur}`, day(t.highest.at))}
+${tile('Since first seen', changeText, changeNote)}
+</dl>
+<div class="pc-plot">
+<div class="pc-y" aria-hidden="true">${flat ? html`<span style="top:50%">${t.latest.price}</span>` : html`<span style="top:10%">${t.highest.price}</span><span style="top:90%">${t.lowest.price}</span>`}</div>
+<div class="pc-area" role="img" aria-label="${summary}">
+<svg viewBox="0 0 1000 100" preserveAspectRatio="none" aria-hidden="true" focusable="false">${flat ? html`<line class="pc-grid" x1="0" x2="1000" y1="50" y2="50"/>` : html`<line class="pc-grid" x1="0" x2="1000" y1="10" y2="10"/><line class="pc-grid" x1="0" x2="1000" y1="90" y2="90"/>`}<path class="pc-line" d="${d}"/></svg>
+${hits}${lowQ !== endQ && !flat ? dot(lowQ, 'pc-dot pc-low', '') : ''}${dot(endQ, 'pc-dot pc-end', '')}
+</div>
+<div class="pc-x" aria-hidden="true"><span>${day(t.first.at)}</span><span>${day(t.latest.at)}</span></div>
+</div>
+<figcaption class="meta">A price holds until the next observation, and only as of its time. ${t.currency ? `Prices in other currencies are not compared.` : ''}</figcaption>
+</figure>`;
+}
+
function sourceLabel(o) {
function sourceLabel(o) {
if (o.source_kind === 'sources_item') return html`${o.source_key || 'source'}${safeHref(o.source_url) ? html` · <a href="${safeHref(o.source_url)}" rel="nofollow noopener">item</a>` : ''}`;
if (o.source_kind === 'sources_item') return html`${o.source_key || 'source'}${safeHref(o.source_url) ? html` · <a href="${safeHref(o.source_url)}" rel="nofollow noopener">item</a>` : ''}`;
if (o.origin === 'community' && o.source_kind === 'submission') return 'the submission';
if (o.origin === 'community' && o.source_kind === 'submission') return 'the submission';
@@ -163,6 +211,7 @@ ${r.seller ? html`<dt>Sold by</dt><dd>${urls.sellerPath ? html`<a href="${urls.s
@@ -163,6 +211,7 @@ ${r.seller ? html`<dt>Sold by</dt><dd>${urls.sellerPath ? html`<a href="${urls.s
<dt>Posted</dt><dd>${timeEl(r.created_at)} ${r.origin === 'import' ? '(imported)' : '(by a member)'}</dd>
<dt>Posted</dt><dd>${timeEl(r.created_at)} ${r.origin === 'import' ? '(imported)' : '(by a member)'}</dd>
</dl>
</dl>
<h2 id="history">Price history</h2>
<h2 id="history">Price history</h2>
+${priceChart(v.trend)}
${observationTable(v, urls)}
${observationTable(v, urls)}
<h2 id="sources">Sources</h2>
<h2 id="sources">Sources</h2>
<ul class="sources">${v.sources.map((s) => html`<li>${s.kind === 'sources_item' ? html`OpenVibe.Sources item <code>${s.ref_id}</code> from <code>${s.source_key || 'a source'}</code>${safeHref(s.url) ? html` — <a href="${safeHref(s.url)}" rel="nofollow noopener">original</a>` : ''}${s.retrieved_at ? html`, retrieved ${timeEl(s.retrieved_at)}` : ''}${s.license_note ? html` · ${s.license_note}` : ''}` : s.kind === 'submission' ? html`Submitted by a member ${timeEl(s.created_at)}` : html`Observations reported by a member`}${s.removed_at ? html` <span class="badge expired">removed: ${s.removed_reason}</span>` : ''}${s.offer_id !== r.id ? html` <span class="meta">(merged listing)</span>` : ''}</li>`)}</ul>
<ul class="sources">${v.sources.map((s) => html`<li>${s.kind === 'sources_item' ? html`OpenVibe.Sources item <code>${s.ref_id}</code> from <code>${s.source_key || 'a source'}</code>${safeHref(s.url) ? html` — <a href="${safeHref(s.url)}" rel="nofollow noopener">original</a>` : ''}${s.retrieved_at ? html`, retrieved ${timeEl(s.retrieved_at)}` : ''}${s.license_note ? html` · ${s.license_note}` : ''}` : s.kind === 'submission' ? html`Submitted by a member ${timeEl(s.created_at)}` : html`Observations reported by a member`}${s.removed_at ? html` <span class="badge expired">removed: ${s.removed_reason}</span>` : ''}${s.offer_id !== r.id ? html` <span class="meta">(merged listing)</span>` : ''}</li>`)}</ul>
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * The price over time (domain/price-trend.js): lowest, highest and the change since first seen, in the
+ * latest price's currency only; the offer page's tiles and step line; price_trend in the JSON twin.
+ */
+const assert = require('assert');
+const { priceTrend } = require('../server/domain/price-trend');
+const { boot, check, done, HOUR } = require('./helpers/boot');
+
+const obs = (at, price, currency = 'USD', extra = {}) => ({ observed_at: at, price, price_num: price == null ? null : Number(price), currency, ...extra });
+
+(async () => {
+ await check('priceTrend: newest-first observations → oldest-first points, lowest, highest, change', async () => {
+ const t = priceTrend([obs(4, '64.00'), obs(3, '59.00'), obs(2, '89.00'), obs(1, '79.00')]);
+ assert.deepStrictEqual(t.points.map((p) => p.price), ['79.00', '89.00', '59.00', '64.00']);
+ assert.strictEqual(t.lowest.price, '59.00');
+ assert.strictEqual(t.highest.price, '89.00');
+ assert.deepStrictEqual(t.change, { amount: -15, percent: -19 });
+ assert.strictEqual(t.atLowest, false);
+ assert.strictEqual(t.currency, 'USD');
+ });
+
+ await check('priceTrend: only the latest currency, only stated prices, never a removed source', async () => {
+ const t = priceTrend([obs(5, '50.00'), obs(4, null), obs(3, '40.00', 'EUR'), obs(2, '30.00', 'USD', { source_removed_at: 9 }), obs(1, '60.00')]);
+ assert.deepStrictEqual(t.points.map((p) => p.price), ['60.00', '50.00']);
+ assert.strictEqual(t.atLowest, true, 'the latest is the lowest seen');
+ assert.strictEqual(priceTrend([obs(1, null)]), null);
+ assert.strictEqual(priceTrend([]), null);
+ const one = priceTrend([obs(1, '9.99')]);
+ assert.strictEqual(one.change, null);
+ assert.strictEqual(one.atLowest, false, 'one price is not "the lowest we have seen"');
+ });
+
+ await check('priceTrend: the same price twice is no change and not a low; ties keep the earliest time', async () => {
+ const t = priceTrend([obs(2, '20'), obs(1, '20')]);
+ assert.deepStrictEqual(t.change, { amount: 0, percent: 0 });
+ assert.strictEqual(t.atLowest, false);
+ assert.strictEqual(t.lowest.at, 1);
+ });
+
+ const t = await boot();
+ const [alice, bob] = ['alice', 'bob'].map((n) => t.network.addUser(n));
+
+ await check('an offer page with several prices: tiles, a step line naming each point, the table still under it', async () => {
+ const deal = await t.submit(alice, { url: 'https://gadgets.example/kettle', title: 'Glass kettle', price: '79.00', currency: 'USD', condition: 'new' });
+ const one = await t.get(`/d/${deal.slug}`);
+ assert.doesNotMatch(one.text, /class="pc"/, 'one price draws no chart');
+ for (const price of ['89.00', '59.00', '64.00']) {
+ t.clock.advance(2 * HOUR);
+ const r = await t.get(`/d/${deal.slug}/observe`, { as: bob, form: { price, currency: 'USD' } });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 200));
+ }
+ const page = await t.get(`/d/${deal.slug}`);
+ assert.match(page.text, /<figure class="pc"/);
+ assert.match(page.text, /<dt>Lowest<\/dt><dd><strong>59\.00 USD<\/strong>/);
+ assert.match(page.text, /<dt>Highest<\/dt><dd><strong>89\.00 USD<\/strong>/);
+ assert.match(page.text, /<dt>Since first seen<\/dt><dd><strong>−15\.00 USD<\/strong><span class="meta">−19% from 79\.00 USD on /);
+ assert.match(page.text, /<path class="pc-line" d="M0 [\d.]+H/);
+ assert.strictEqual((page.text.match(/class="pc-hit"/g) || []).length, 4, 'every point names its price and time');
+ assert.match(page.text, /role="img" aria-label="Price over time in USD: 79\.00 on /);
+ assert.ok(page.text.indexOf('class="pc"') < page.text.indexOf('<table class="obs">'), 'the chart sits above the table');
+
+ const json = await t.offerJson(deal.slug);
+ assert.strictEqual(json.price_trend.currency, 'USD');
+ assert.strictEqual(json.price_trend.observations, 4);
+ assert.strictEqual(json.price_trend.lowest.price, '59.00');
+ assert.match(json.price_trend.lowest.observed_at, /^\d{4}-\d\d-\d\dT/);
+ assert.deepStrictEqual(json.price_trend.change, { amount: -15, percent: -19 });
+ assert.strictEqual(json.price_trend.at_lowest, false);
+
+ t.clock.advance(2 * HOUR);
+ await t.get(`/d/${deal.slug}/observe`, { as: bob, form: { price: '55.00', currency: 'USD' } });
+ const low = await t.get(`/d/${deal.slug}`);
+ assert.match(low.text, /class="pc-tile pc-best"><dt>Lowest<\/dt><dd><strong>55\.00 USD<\/strong><span class="meta">the lowest we have seen/);
+ assert.strictEqual((await t.offerJson(deal.slug)).price_trend.at_lowest, true);
+ });
+
+ await t.close();
+ done();
+})();