openvibers/openvibe.trade Commits
Trade: the front page shows each instrument's latest number, and the exchange column only when one is recorded (#53)
Changes
@@@ -24,7 +24,7 @@ const { SITE_SUMMARY } = require('../render/layout');const { define } = require('./routes');function createPages(ctx) {-- const { config, instruments, documents, reading, freshness, urls, watchlists, context, common, sync } = ctx;++ const { config, instruments, documents, reading, freshness, urls, watchlists, context, common, sync, observations } = ctx;const router = express.Router();const listing = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: urls.abs(path), wordCount: 0 }, { policy: { minWords: 0 } });@@@ -40,6 +40,14 @@ function createPages(ctx) {const pager = paginate({ page: pageNo, perPage: per, total: first.total, href: (p) => (p === 1 ? '/' : `/?page=${p}`) });if (pager.outOfRange && first.total) return common.notFound(req, res);const page = await instruments.page({ limit: pager.limit, offset: pager.offset });++ // Each listed instrument's headline number on the list itself: its latest price, close or rate (else its first++ // metric), with when it was observed and whether its source is current. Fifty at most per page.++ const now = Date.now();++ const latest = new Map(await Promise.all(page.instruments.map(async (i) => {++ const rows = await observations.latest(i);++ const pick = ['price', 'close', 'rate'].map((m) => rows.find((o) => o.metric === m)).find(Boolean) || rows[0];++ return [i.id, pick ? await observations.dto(pick, { now }) : null];++ })));const recent = (await Promise.all((await documents.recent(10)).map(async (d) => ({ document: documents.dto(d), instrument: await instruments.get(d.instrument_id) }))));const canonical = urls.abs(pageNo === 1 ? '/' : `/?page=${pageNo}`);common.page(req, res, {@@@ -49,7 +57,7 @@ function createPages(ctx) {styles: pageNo === 1 ? [showcase.STYLESHEET] : [],feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],-- body: views.home({ page, recent, urls, sync: await sync.state(), pager }),++ body: views.home({ page, recent, urls, sync: await sync.state(), pager, latest }),});});@@@ -60,10 +60,15 @@ function tradeShowcase() {});}--function home({ page, recent, urls, sync, pager }) {++function home({ page, recent, urls, sync, pager, latest = new Map() }) {++ // The exchange column only when an instrument on the page has one (a currency has none to record).++ const exchanges = page.instruments.some((i) => i.exchange);++ const latestCell = (o) => (o++ ? html`<td class="num"><data value="${o.value}">${o.value}</data> ${o.unit} <span class="muted">${o.metric}, ${when(o.observed_at)}</span>${o.freshness && o.freshness.stale ? html` ${staleBadge(o.freshness)}` : ''}</td>`++ : html`<td><span class="muted">no observation yet</span></td>`);const rows = page.instruments.map((i) => html`<tr><td><a href="${urls.path.instrument(i)}"><strong>${i.symbol}</strong></a></td>-- <td>${i.name}</td><td>${i.kind}</td><td>${i.exchange || html`<span class="muted">not recorded</span>`}</td>++ <td>${i.name}</td><td>${i.kind}</td>${exchanges ? html`<td>${i.exchange || html`<span class="muted">not recorded</span>`}</td>` : ''}${latestCell(latest.get(i.id))}</tr>`);const docs = recent.map((r) => html`<li><a href="${urls.path.instrument(r.instrument)}#${r.document.id}">${r.instrument.symbol}</a>${r.document.form_type ? html`<span class="badge">${r.document.form_type}</span>` : ''} ${r.document.title || r.document.filer_name || 'Untitled document'}@@@ -78,7 +83,7 @@ ${first ? raw(tradeShowcase()) : html`<h1>Sourced market information</h1>`}<button type="submit">Find</button></form><h2>Instruments</h2>--${page.instruments.length ? html`<table class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th><th scope="col">Exchange</th></tr></thead><tbody>${rows}</tbody></table>++${page.instruments.length ? html`<table class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th>${exchanges ? html`<th scope="col">Exchange</th>` : ''}<th scope="col">Latest</th></tr></thead><tbody>${rows}</tbody></table>${raw(paginationHtml(pager))}`: html`<p class="empty">No instruments have been added yet.</p>`}<h2>Recent documents</h2>@@@ -151,6 +151,10 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');const home = (await t.get('/')).text;assert.ok(home.includes('<meta name="ai-summary"'), 'the home carries the ai-summary meta');++ // The list shows each instrument's latest number with its metric and time; the exchange column stays while one++ // is recorded (ACME's NYSE), and an instrument with nothing observed says so.++ assert.match(home, /<th scope="col">Exchange<\/th><th scope="col">Latest<\/th>/);++ assert.match(home, /<td class="num"><data value="10\.50">10\.50<\/data> [^<]*<span class="muted">price\.close, /);assert.ok(home.includes('"@type":"WebPage"'), 'the home carries WebPage JSON-LD');});
@@ -24,7 +24,7 @@ const { SITE_SUMMARY } = require('../render/layout');
@@ -24,7 +24,7 @@ const { SITE_SUMMARY } = require('../render/layout');
const { define } = require('./routes');
const { define } = require('./routes');
function createPages(ctx) {
function createPages(ctx) {
- const { config, instruments, documents, reading, freshness, urls, watchlists, context, common, sync } = ctx;
+ const { config, instruments, documents, reading, freshness, urls, watchlists, context, common, sync, observations } = ctx;
const router = express.Router();
const router = express.Router();
const listing = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: urls.abs(path), wordCount: 0 }, { policy: { minWords: 0 } });
const listing = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: urls.abs(path), wordCount: 0 }, { policy: { minWords: 0 } });
@@ -40,6 +40,14 @@ function createPages(ctx) {
@@ -40,6 +40,14 @@ function createPages(ctx) {
const pager = paginate({ page: pageNo, perPage: per, total: first.total, href: (p) => (p === 1 ? '/' : `/?page=${p}`) });
const pager = paginate({ page: pageNo, perPage: per, total: first.total, href: (p) => (p === 1 ? '/' : `/?page=${p}`) });
if (pager.outOfRange && first.total) return common.notFound(req, res);
if (pager.outOfRange && first.total) return common.notFound(req, res);
const page = await instruments.page({ limit: pager.limit, offset: pager.offset });
const page = await instruments.page({ limit: pager.limit, offset: pager.offset });
+ // Each listed instrument's headline number on the list itself: its latest price, close or rate (else its first
+ // metric), with when it was observed and whether its source is current. Fifty at most per page.
+ const now = Date.now();
+ const latest = new Map(await Promise.all(page.instruments.map(async (i) => {
+ const rows = await observations.latest(i);
+ const pick = ['price', 'close', 'rate'].map((m) => rows.find((o) => o.metric === m)).find(Boolean) || rows[0];
+ return [i.id, pick ? await observations.dto(pick, { now }) : null];
+ })));
const recent = (await Promise.all((await documents.recent(10)).map(async (d) => ({ document: documents.dto(d), instrument: await instruments.get(d.instrument_id) }))));
const recent = (await Promise.all((await documents.recent(10)).map(async (d) => ({ document: documents.dto(d), instrument: await instruments.get(d.instrument_id) }))));
const canonical = urls.abs(pageNo === 1 ? '/' : `/?page=${pageNo}`);
const canonical = urls.abs(pageNo === 1 ? '/' : `/?page=${pageNo}`);
common.page(req, res, {
common.page(req, res, {
@@ -49,7 +57,7 @@ function createPages(ctx) {
@@ -49,7 +57,7 @@ function createPages(ctx) {
styles: pageNo === 1 ? [showcase.STYLESHEET] : [],
styles: pageNo === 1 ? [showcase.STYLESHEET] : [],
feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],
feeds: [{ type: 'rss', href: '/feed.xml', title: 'Recent documents (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'Recent documents (Atom)' }, { type: 'json', href: '/feed.json', title: 'Recent documents (JSON Feed)' }],
jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],
jsonLd: [seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en' })],
- body: views.home({ page, recent, urls, sync: await sync.state(), pager }),
+ body: views.home({ page, recent, urls, sync: await sync.state(), pager, latest }),
});
});
});
});
@@ -60,10 +60,15 @@ function tradeShowcase() {
@@ -60,10 +60,15 @@ function tradeShowcase() {
});
});
}
}
-function home({ page, recent, urls, sync, pager }) {
+function home({ page, recent, urls, sync, pager, latest = new Map() }) {
+ // The exchange column only when an instrument on the page has one (a currency has none to record).
+ const exchanges = page.instruments.some((i) => i.exchange);
+ const latestCell = (o) => (o
+ ? html`<td class="num"><data value="${o.value}">${o.value}</data> ${o.unit} <span class="muted">${o.metric}, ${when(o.observed_at)}</span>${o.freshness && o.freshness.stale ? html` ${staleBadge(o.freshness)}` : ''}</td>`
+ : html`<td><span class="muted">no observation yet</span></td>`);
const rows = page.instruments.map((i) => html`<tr>
const rows = page.instruments.map((i) => html`<tr>
<td><a href="${urls.path.instrument(i)}"><strong>${i.symbol}</strong></a></td>
<td><a href="${urls.path.instrument(i)}"><strong>${i.symbol}</strong></a></td>
- <td>${i.name}</td><td>${i.kind}</td><td>${i.exchange || html`<span class="muted">not recorded</span>`}</td>
+ <td>${i.name}</td><td>${i.kind}</td>${exchanges ? html`<td>${i.exchange || html`<span class="muted">not recorded</span>`}</td>` : ''}${latestCell(latest.get(i.id))}
</tr>`);
</tr>`);
const docs = recent.map((r) => html`<li><a href="${urls.path.instrument(r.instrument)}#${r.document.id}">${r.instrument.symbol}</a>
const docs = recent.map((r) => html`<li><a href="${urls.path.instrument(r.instrument)}#${r.document.id}">${r.instrument.symbol}</a>
${r.document.form_type ? html`<span class="badge">${r.document.form_type}</span>` : ''} ${r.document.title || r.document.filer_name || 'Untitled document'}
${r.document.form_type ? html`<span class="badge">${r.document.form_type}</span>` : ''} ${r.document.title || r.document.filer_name || 'Untitled document'}
@@ -78,7 +83,7 @@ ${first ? raw(tradeShowcase()) : html`<h1>Sourced market information</h1>`}
@@ -78,7 +83,7 @@ ${first ? raw(tradeShowcase()) : html`<h1>Sourced market information</h1>`}
<button type="submit">Find</button>
<button type="submit">Find</button>
</form>
</form>
<h2>Instruments</h2>
<h2>Instruments</h2>
-${page.instruments.length ? html`<table class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th><th scope="col">Exchange</th></tr></thead><tbody>${rows}</tbody></table>
+${page.instruments.length ? html`<table class="data"><thead><tr><th scope="col">Symbol</th><th scope="col">Name</th><th scope="col">Kind</th>${exchanges ? html`<th scope="col">Exchange</th>` : ''}<th scope="col">Latest</th></tr></thead><tbody>${rows}</tbody></table>
${raw(paginationHtml(pager))}`
${raw(paginationHtml(pager))}`
: html`<p class="empty">No instruments have been added yet.</p>`}
: html`<p class="empty">No instruments have been added yet.</p>`}
<h2>Recent documents</h2>
<h2>Recent documents</h2>
@@ -151,6 +151,10 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
@@ -151,6 +151,10 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
const home = (await t.get('/')).text;
const home = (await t.get('/')).text;
assert.ok(home.includes('<meta name="ai-summary"'), 'the home carries the ai-summary meta');
assert.ok(home.includes('<meta name="ai-summary"'), 'the home carries the ai-summary meta');
+ // The list shows each instrument's latest number with its metric and time; the exchange column stays while one
+ // is recorded (ACME's NYSE), and an instrument with nothing observed says so.
+ assert.match(home, /<th scope="col">Exchange<\/th><th scope="col">Latest<\/th>/);
+ assert.match(home, /<td class="num"><data value="10\.50">10\.50<\/data> [^<]*<span class="muted">price\.close, /);
assert.ok(home.includes('"@type":"WebPage"'), 'the home carries WebPage JSON-LD');
assert.ok(home.includes('"@type":"WebPage"'), 'the home carries WebPage JSON-LD');
});
});