openvibers/openvibe.trade Commits
Trade: readable figures and one citation per series on the AI brief (#70)
Changes
@@@ -75,7 +75,8 @@ function createBriefs({ store, config, ctx, ai, log = console }) {content: rows.map((r) => `${r.period || iso(r.observed_at)}: ${r.value} ${r.unit}${fromSec ? ` (filing ${String(r.source_ref).split(':')[1]})` : ` (${r.source_key})`}`).join('\n'),provenance: { service: 'trade', source_key: o.source_key },});-- refs.push(rows.map((r) => ({ kind: 'observation', id: r.id, at: r.observed_at })));++ // Cited by its newest row: the one the page's observations table shows, so each citation links to a row on the page.++ refs.push([{ kind: 'observation', id: o.id, at: o.observed_at }]);}for (const d of await q.docs.all(instrument.id, MAX_DOCS)) {if (sources.length >= MAX_SOURCES) break;@@@ -47,6 +47,13 @@ const QUARTER = /^CY\d{4}Q[1-4]$/;const YEAR = /^CY\d{4}$/;const INSTANT = /^CY\d{4}Q[1-4]I$/;++/** Sort key for a metric: the statement order of METRICS (quarter, then year), then anything else by name. */++function metricOrder(metric) {++ const [base, span] = String(metric).split('.');++ const i = Object.keys(METRICS).indexOf(base);++ return i < 0 ? `9:${metric}` : `${String(i).padStart(2, '0')}:${span === 'q' ? 0 : span === 'fy' ? 1 : 2}`;++}++/** "Revenue (quarter)" for revenue.q; the label for anything else Trade records. */function labelOf(metric) {const [base, span] = String(metric).split('.');@@@ -171,4 +178,4 @@ function createFacts({ store, ctx, sec, log = console }) {};}--module.exports = { createFacts, extract, labelOf, filingUrl, METRICS, SOURCE_KEY, SOURCE_NAME };++module.exports = { createFacts, extract, labelOf, metricOrder, filingUrl, METRICS, SOURCE_KEY, SOURCE_NAME };@@@ -1,6 +1,12 @@'use strict';--const { labelOf } = require('../domain/facts');++const { labelOf, metricOrder } = require('../domain/facts');++++/** The stated decimal with thousands separators, digit for digit ("39544000000" → "39,544,000,000"). */++function grouped(value) {++ const m = /^(-?)(\d+)(\.\d+)?$/.exec(String(value));++ return m ? `${m[1]}${m[2].replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${m[3] || ''}` : String(value);++}/*** Page bodies (HTML fragments for layout.renderPage). All escaping goes through* openvibe-publishing/ssr's `html` tagged template; `raw()` only wraps HTML built here or by the@@@ -81,7 +87,7 @@ function home({ page, recent, urls, sync, pager, latest = new Map(), briefs = []// 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">${labelOf(o.metric)}${o.period ? `, ${o.period}` : `, ${when(o.observed_at)}`}</span>${o.freshness && o.freshness.stale ? html` ${staleBadge(o.freshness)}` : ''}</td>`++ ? html`<td class="num"><data value="${o.value}">${grouped(o.value)}</data> ${o.unit} <span class="muted">${labelOf(o.metric)}${o.period ? `, ${o.period}` : `, ${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>@@@ -132,9 +138,10 @@ function observationsTable(instrument, observations) {if (!observations.length) {return html`<p class="empty" id="no-observations">No observations have been recorded for ${instrument.symbol}, so no number is shown.</p>`;}-- const rows = observations.map((o) => html`<tr id="${o.id}" class="${o.freshness.stale ? 'is-stale' : ''}">++ const ordered = observations.slice().sort((a, b) => (metricOrder(a.metric) < metricOrder(b.metric) ? -1 : metricOrder(a.metric) > metricOrder(b.metric) ? 1 : 0));++ const rows = ordered.map((o) => html`<tr id="${o.id}" class="${o.freshness.stale ? 'is-stale' : ''}"><th scope="row">${labelOf(o.metric)}${o.period ? html` <span class="muted">(${o.period})</span>` : ''}</th>-- <td class="num"><data value="${o.value}">${o.value}</data> ${o.unit}${o.currency && o.currency !== o.unit ? html` <span class="muted">${o.currency}</span>` : ''}</td>++ <td class="num"><data value="${o.value}">${grouped(o.value)}</data> ${o.unit}${o.currency && o.currency !== o.unit ? html` <span class="muted">${o.currency}</span>` : ''}</td><td>${when(o.observed_at)}</td><td>${o.source.url ? html`<a href="${o.source.url}" rel="nofollow noopener">${o.source.key}</a>` : o.source.key}</td><td>${when(o.retrieved_at)}</td>@@@ -116,7 +116,10 @@ const quiet = { log() {}, warn() {}, error() {} };assert.ok(page.text.includes('AI-written, not reviewed by a person'));const b = await t.ctx.briefs.latest(apple);assert.ok(page.text.includes(`href="#${b.points[0].cites[0].id}"`), 'the citation links to the observation row');-- assert.ok(page.text.includes(`id="${b.points[0].cites[0].id}"`) || page.text.includes('Revenue (quarter)'), 'the row is on the page');++ for (const c of b.points.flatMap((p) => p.cites)) assert.ok(page.text.includes(`id="${c.id}"`), `cited row ${c.id} is on the page`);++ assert.strictEqual(b.points[0].cites.length, 1, 'a series is cited once, by its newest row');++ assert.ok(page.text.includes('<data value="109417000000">109,417,000,000</data> USD'), 'the stated value, digit for digit, grouped');++ assert.ok(page.text.indexOf('Revenue (quarter)') < page.text.indexOf('Revenue (year)') && page.text.indexOf('Revenue (year)') < page.text.indexOf('Total assets'), 'statement order');assert.ok(page.text.includes('not investment advice'));const home = await t.get('/');assert.ok(home.text.includes('Latest AI briefs') && home.text.includes('#ai-brief'));
@@ -75,7 +75,8 @@ function createBriefs({ store, config, ctx, ai, log = console }) {
@@ -75,7 +75,8 @@ function createBriefs({ store, config, ctx, ai, log = console }) {
content: rows.map((r) => `${r.period || iso(r.observed_at)}: ${r.value} ${r.unit}${fromSec ? ` (filing ${String(r.source_ref).split(':')[1]})` : ` (${r.source_key})`}`).join('\n'),
content: rows.map((r) => `${r.period || iso(r.observed_at)}: ${r.value} ${r.unit}${fromSec ? ` (filing ${String(r.source_ref).split(':')[1]})` : ` (${r.source_key})`}`).join('\n'),
provenance: { service: 'trade', source_key: o.source_key },
provenance: { service: 'trade', source_key: o.source_key },
});
});
- refs.push(rows.map((r) => ({ kind: 'observation', id: r.id, at: r.observed_at })));
+ // Cited by its newest row: the one the page's observations table shows, so each citation links to a row on the page.
+ refs.push([{ kind: 'observation', id: o.id, at: o.observed_at }]);
}
}
for (const d of await q.docs.all(instrument.id, MAX_DOCS)) {
for (const d of await q.docs.all(instrument.id, MAX_DOCS)) {
if (sources.length >= MAX_SOURCES) break;
if (sources.length >= MAX_SOURCES) break;
@@ -47,6 +47,13 @@ const QUARTER = /^CY\d{4}Q[1-4]$/;
@@ -47,6 +47,13 @@ const QUARTER = /^CY\d{4}Q[1-4]$/;
const YEAR = /^CY\d{4}$/;
const YEAR = /^CY\d{4}$/;
const INSTANT = /^CY\d{4}Q[1-4]I$/;
const INSTANT = /^CY\d{4}Q[1-4]I$/;
+/** Sort key for a metric: the statement order of METRICS (quarter, then year), then anything else by name. */
+function metricOrder(metric) {
+ const [base, span] = String(metric).split('.');
+ const i = Object.keys(METRICS).indexOf(base);
+ return i < 0 ? `9:${metric}` : `${String(i).padStart(2, '0')}:${span === 'q' ? 0 : span === 'fy' ? 1 : 2}`;
+}
+
/** "Revenue (quarter)" for revenue.q; the label for anything else Trade records. */
/** "Revenue (quarter)" for revenue.q; the label for anything else Trade records. */
function labelOf(metric) {
function labelOf(metric) {
const [base, span] = String(metric).split('.');
const [base, span] = String(metric).split('.');
@@ -171,4 +178,4 @@ function createFacts({ store, ctx, sec, log = console }) {
@@ -171,4 +178,4 @@ function createFacts({ store, ctx, sec, log = console }) {
};
};
}
}
-module.exports = { createFacts, extract, labelOf, filingUrl, METRICS, SOURCE_KEY, SOURCE_NAME };
+module.exports = { createFacts, extract, labelOf, metricOrder, filingUrl, METRICS, SOURCE_KEY, SOURCE_NAME };
@@ -1,6 +1,12 @@
@@ -1,6 +1,12 @@
'use strict';
'use strict';
-const { labelOf } = require('../domain/facts');
+const { labelOf, metricOrder } = require('../domain/facts');
+
+/** The stated decimal with thousands separators, digit for digit ("39544000000" → "39,544,000,000"). */
+function grouped(value) {
+ const m = /^(-?)(\d+)(\.\d+)?$/.exec(String(value));
+ return m ? `${m[1]}${m[2].replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${m[3] || ''}` : String(value);
+}
/**
/**
* Page bodies (HTML fragments for layout.renderPage). All escaping goes through
* Page bodies (HTML fragments for layout.renderPage). All escaping goes through
* openvibe-publishing/ssr's `html` tagged template; `raw()` only wraps HTML built here or by the
* openvibe-publishing/ssr's `html` tagged template; `raw()` only wraps HTML built here or by the
@@ -81,7 +87,7 @@ function home({ page, recent, urls, sync, pager, latest = new Map(), briefs = []
@@ -81,7 +87,7 @@ function home({ page, recent, urls, sync, pager, latest = new Map(), briefs = []
// The exchange column only when an instrument on the page has one (a currency has none to record).
// 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 exchanges = page.instruments.some((i) => i.exchange);
const latestCell = (o) => (o
const latestCell = (o) => (o
- ? html`<td class="num"><data value="${o.value}">${o.value}</data> ${o.unit} <span class="muted">${labelOf(o.metric)}${o.period ? `, ${o.period}` : `, ${when(o.observed_at)}`}</span>${o.freshness && o.freshness.stale ? html` ${staleBadge(o.freshness)}` : ''}</td>`
+ ? html`<td class="num"><data value="${o.value}">${grouped(o.value)}</data> ${o.unit} <span class="muted">${labelOf(o.metric)}${o.period ? `, ${o.period}` : `, ${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>`);
: 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>
@@ -132,9 +138,10 @@ function observationsTable(instrument, observations) {
@@ -132,9 +138,10 @@ function observationsTable(instrument, observations) {
if (!observations.length) {
if (!observations.length) {
return html`<p class="empty" id="no-observations">No observations have been recorded for ${instrument.symbol}, so no number is shown.</p>`;
return html`<p class="empty" id="no-observations">No observations have been recorded for ${instrument.symbol}, so no number is shown.</p>`;
}
}
- const rows = observations.map((o) => html`<tr id="${o.id}" class="${o.freshness.stale ? 'is-stale' : ''}">
+ const ordered = observations.slice().sort((a, b) => (metricOrder(a.metric) < metricOrder(b.metric) ? -1 : metricOrder(a.metric) > metricOrder(b.metric) ? 1 : 0));
+ const rows = ordered.map((o) => html`<tr id="${o.id}" class="${o.freshness.stale ? 'is-stale' : ''}">
<th scope="row">${labelOf(o.metric)}${o.period ? html` <span class="muted">(${o.period})</span>` : ''}</th>
<th scope="row">${labelOf(o.metric)}${o.period ? html` <span class="muted">(${o.period})</span>` : ''}</th>
- <td class="num"><data value="${o.value}">${o.value}</data> ${o.unit}${o.currency && o.currency !== o.unit ? html` <span class="muted">${o.currency}</span>` : ''}</td>
+ <td class="num"><data value="${o.value}">${grouped(o.value)}</data> ${o.unit}${o.currency && o.currency !== o.unit ? html` <span class="muted">${o.currency}</span>` : ''}</td>
<td>${when(o.observed_at)}</td>
<td>${when(o.observed_at)}</td>
<td>${o.source.url ? html`<a href="${o.source.url}" rel="nofollow noopener">${o.source.key}</a>` : o.source.key}</td>
<td>${o.source.url ? html`<a href="${o.source.url}" rel="nofollow noopener">${o.source.key}</a>` : o.source.key}</td>
<td>${when(o.retrieved_at)}</td>
<td>${when(o.retrieved_at)}</td>
@@ -116,7 +116,10 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -116,7 +116,10 @@ const quiet = { log() {}, warn() {}, error() {} };
assert.ok(page.text.includes('AI-written, not reviewed by a person'));
assert.ok(page.text.includes('AI-written, not reviewed by a person'));
const b = await t.ctx.briefs.latest(apple);
const b = await t.ctx.briefs.latest(apple);
assert.ok(page.text.includes(`href="#${b.points[0].cites[0].id}"`), 'the citation links to the observation row');
assert.ok(page.text.includes(`href="#${b.points[0].cites[0].id}"`), 'the citation links to the observation row');
- assert.ok(page.text.includes(`id="${b.points[0].cites[0].id}"`) || page.text.includes('Revenue (quarter)'), 'the row is on the page');
+ for (const c of b.points.flatMap((p) => p.cites)) assert.ok(page.text.includes(`id="${c.id}"`), `cited row ${c.id} is on the page`);
+ assert.strictEqual(b.points[0].cites.length, 1, 'a series is cited once, by its newest row');
+ assert.ok(page.text.includes('<data value="109417000000">109,417,000,000</data> USD'), 'the stated value, digit for digit, grouped');
+ assert.ok(page.text.indexOf('Revenue (quarter)') < page.text.indexOf('Revenue (year)') && page.text.indexOf('Revenue (year)') < page.text.indexOf('Total assets'), 'statement order');
assert.ok(page.text.includes('not investment advice'));
assert.ok(page.text.includes('not investment advice'));
const home = await t.get('/');
const home = await t.get('/');
assert.ok(home.text.includes('Latest AI briefs') && home.text.includes('#ai-brief'));
assert.ok(home.text.includes('Latest AI briefs') && home.text.includes('#ai-brief'));