openvibers/openvibe.trade Commits
The Trade front page shows the site's numbers, AI briefs, euro reference rates, new annual and quarterly reports, the week's filings by form, the newest filings and companies with something new, with answers to common questions; the full lists move to /instruments and /filings (#82)
Changes
@@@ -2,6 +2,7 @@## Unreleased++- The front page is curated (`server/render/home.js`): a company search and the numbers beside the hero (companies, filings in 24 hours, filings recorded, companies with figures, AI briefs, currencies), then AI briefs as cards, euro reference rates as cards with the change from the same source, new 10-K/10-Q reports, the week's filings by form in plain words, the newest filings, companies with something new, what is here and six questions (FAQPage JSON-LD). The instruments list moved to `/instruments` (301 from `/?page=N`); `/filings[?form=]` is new; `/sitemaps/pages.xml` lists `/`, `/instruments`, `/filings` and `/sources` (the sitemap listed nothing before: instrument pages stay noindex until a person reviews their context). The navbar reads Companies, Filings, Sources, Watchlists.- Account export and deletion (ADR-033) through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0):- **Deleted:** watchlists, alert rules (with their deliveries) and drafts.- **Authorless:** contributed instruments, aliases, purges, context revisions and hand-recorded observations.@@@ -149,7 +149,9 @@ Trade shows only what a source stated:| Route | What ||---|---|--| `/` | instruments, recent documents, feeds |++| `/` | the front page (`server/render/home.js`): a company search and the site's numbers, the latest AI briefs, euro reference rates (each currency's newest rate, its date and source, and the change from the same source's previous rate), new 10-K/10-Q reports, the last 7 days' filings by form (plain names), the newest filings, companies with something new, what is here and six questions (FAQPage JSON-LD); `/?page=N` answers 301 to `/instruments?page=N` |++| `/instruments` | every instrument, the newest filing or figure first (the front page's old list), 50 a page |++| `/filings`, `/filings?form=` | the newest SEC filings, 50 a page, with the last 7 days' counts by form; one form's list is noindex with `/filings` as its canonical || `/resolve?q=` | resolution; one match → 302 to the instrument || `/i/:symbol`, `/i/:symbol.json` | the instrument page (observations with timestamps and sources, context with disclosure, documents, source freshness, watch/alert forms), and the same data as JSON || `/i/:symbol/documents.xml`, `.atom`, `.json` | the instrument's documents as RSS, Atom and JSON Feed |@@@ -67,3 +67,50 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }@media (max-width: 1024px) {table.data { display: block; overflow-x: auto; }}++/* The front page (server/render/home.js): numbers beside the hero, cards, the rates table, form chips, questions. */++.home .page { max-width: 1080px; }++.tr-hero { padding-top: 28px; }++.tr-search { display: flex; gap: 8px; margin: 20px 0 0; max-width: 560px; }++.tr-search input[type=search] { flex: 1 1 auto; width: auto; min-width: 0; padding: 10px 12px; font-size: 1rem; border-radius: 10px; }++.tr-search button { margin: 0; padding: 10px 18px; border-radius: 10px; }++.tr-stats { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }++.tr-stats li { min-width: 0; }++.tr-stats a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; text-decoration: none; transition: border-color .15s; }++.tr-stats a:hover, .tr-stats a:focus-visible { border-color: var(--accent, #3b82f6); }++.tr-stats b { font-size: 1.6rem; line-height: 1.1; font-variant-numeric: tabular-nums; }++.tr-stats span { font-size: .85rem; line-height: 1.35; color: var(--text-secondary, #96a7c2); }++.tr-sec { margin-top: 44px; }++.tr-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }++.page .tr-head h2 { margin: 0; font-family: var(--ov-font, 'Inter', system-ui, sans-serif); font-size: clamp(1.3rem, 2.4vw, 1.75rem); letter-spacing: -.01em; }++.tr-more { font-weight: 600; font-size: .95rem; text-decoration: none; }++.tr-more:hover { text-decoration: underline; text-underline-offset: 3px; }++.tr-lede { margin: 4px 0 14px; color: var(--text-secondary, #96a7c2); }++.tr-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); }++.tr-card { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); overflow-wrap: anywhere; }++.tr-card p { margin: 0; }++.tr-card-top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }++.tr-small { font-size: .82rem; margin-top: auto !important; }++.tr-wrap { overflow-x: auto; }++.tr-rates th[scope=row] { font-weight: 600; white-space: nowrap; }++.tr-up { color: var(--success, #22c55e); font-weight: 600; }++.tr-down { color: var(--warning, #f59e0b); font-weight: 600; }++.tr-chips { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }++.tr-chips li { min-width: 0; max-width: 100%; }++.tr-chips a, .tr-chips [aria-current] { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; overflow-wrap: anywhere; }++.tr-chips [aria-current] { border-color: var(--accent, #3b82f6); font-weight: 700; }++.tr-chips a:hover, .tr-chips a:focus-visible { border-color: var(--accent, #3b82f6); }++.tr-n { font-size: .78rem; font-weight: 700; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }++.tr-faq { display: grid; gap: 8px; }++.tr-faq details { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); padding: 0 16px; }++.tr-faq summary { cursor: pointer; font-weight: 600; padding: 12px 0; }++.tr-faq p { margin: 0 0 14px; color: var(--text-secondary, #96a7c2); }++.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++@media (max-width: 600px) { .tr-search { flex-wrap: wrap; } .tr-search button { width: 100%; } .tr-stats b { font-size: 1.3rem; } .tr-stats a { padding: 12px; } .tr-cards > li:nth-child(n+5) { display: none; } }++@media (prefers-reduced-motion: reduce) { .tr-stats a { transition: none; } }++.home .disclaimer { max-width: 1048px; }++.tr-more-rates { margin-top: 8px; }++.tr-more-rates summary { cursor: pointer; font-weight: 600; padding: 6px 0; }++.tr-rates { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr)); }++.tr-rate { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); min-width: 0; }++.tr-rate p { margin: 0; overflow-wrap: anywhere; }++.tr-rate-value data { font-size: 1.3rem; font-weight: 700; font-variant-numeric: tabular-nums; }@@@ -34,6 +34,7 @@ function createBriefs({ store, config, ctx, ai, log = console }) {WHERE i.status = 'active' AND b.id = (SELECT id FROM trade_ai_briefs x WHERE x.instrument_id = b.instrument_id ORDER BY created_at DESC, id DESC LIMIT 1)ORDER BY b.created_at DESC LIMIT ?`),lastHour: db.prepare('SELECT COUNT(*) AS n FROM trade_ai_briefs WHERE created_at > ?'),++ stats: db.prepare('SELECT COUNT(*) AS n, COUNT(DISTINCT instrument_id) AS companies FROM trade_ai_briefs'),insert: db.prepare(`INSERT INTO trade_ai_briefs (id, instrument_id, summary, points, cites, gaps, as_of, inputs_hash, run_id, model, trigger, created_at)VALUES (@id, @instrument_id, @summary, @points, @cites, @gaps, @as_of, @inputs_hash, @run_id, @model, @trigger, @created_at)`),series: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = ?@@@ -187,6 +188,8 @@ function createBriefs({ store, config, ctx, ai, log = console }) {inputsFor,latest: async (instrument) => view(await q.latest.get(instrument.id)),recent: async (limit = 8) => (await q.recent.all(limit)).map(view),++ /** { briefs written, companies with one } */++ stats: async () => { const r = await q.stats.get(); return { total: Number(r.n), companies: Number(r.companies) }; },due: async (limit) => await q.due.all(limit),lastHour,};@@@ -30,6 +30,17 @@ function createDocuments({ store, ctx }) {countFor: db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents WHERE instrument_id = ? AND removed_at IS NULL'),recent: db.prepare(`SELECT d.* FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_idWHERE d.removed_at IS NULL AND i.status = 'active' ORDER BY COALESCE(d.published_at, d.first_seen_at) DESC, d.id DESC LIMIT ?`),++ // /filings and the front page: newest first, optionally of some form types; counts by form type since a time.++ recentOf: db.prepare(`SELECT d.* FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_id++ WHERE d.removed_at IS NULL AND i.status = 'active' AND (@forms::text[] IS NULL OR d.form_type = ANY(@forms::text[]))++ ORDER BY COALESCE(d.published_at, d.first_seen_at) DESC, d.id DESC LIMIT @limit OFFSET @offset`),++ countOf: db.prepare(`SELECT COUNT(*) AS n FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_id++ WHERE d.removed_at IS NULL AND i.status = 'active' AND (@forms::text[] IS NULL OR d.form_type = ANY(@forms::text[]))`),++ formCounts: db.prepare(`SELECT d.form_type, COUNT(*) AS n FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_id++ WHERE d.removed_at IS NULL AND i.status = 'active' AND d.form_type IS NOT NULL AND COALESCE(d.published_at, d.first_seen_at) > ?++ GROUP BY d.form_type ORDER BY n DESC, d.form_type LIMIT 40`),++ stats: db.prepare(`SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE d.first_seen_at > ?) AS day, COUNT(DISTINCT d.instrument_id) AS companies++ FROM trade_source_documents d WHERE d.removed_at IS NULL`),};const api = {@@@ -38,6 +49,13 @@ function createDocuments({ store, ctx }) {forInstrument: async (instrument, { limit = 50, offset = 0 } = {}) => await q.forInstrument.all(instrument.id, limit, offset),count: async (instrument) => (await q.countFor.get(instrument.id)).n,recent: async (limit = 50) => await q.recent.all(limit),++ /** Newest documents of these form types (null: any), paginated. */++ recentOf: async (forms, { limit = 50, offset = 0 } = {}) => await q.recentOf.all({ forms: forms && forms.length ? forms : null, limit, offset }),++ countOf: async (forms) => Number((await q.countOf.get({ forms: forms && forms.length ? forms : null })).n),++ /** Form type → documents published (or first seen) since `since`, the most first. */++ formCounts: async (since) => (await q.formCounts.all(since)).map((r) => ({ form_type: r.form_type, count: Number(r.n) })),++ /** { total, day (first seen since `since`), companies } over documents still shown. */++ stats: async (since) => { const r = await q.stats.get(since); return { total: Number(r.total), day: Number(r.day), companies: Number(r.companies) }; },/** From a mapped Sources item (domain/mapping.js). → { document, created, changed } */async upsert(doc, instrument, { traceparent } = {}) {@@@ -65,6 +65,7 @@ function createInstruments({ store }) {insertAlias: db.prepare(`INSERT INTO trade_instrument_aliases (instrument_id, kind, value, normalized, added_by, created_at)VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),count: db.prepare("SELECT COUNT(*) AS n FROM trade_instruments WHERE status = 'active'"),++ countByKind: db.prepare("SELECT kind, COUNT(*) AS n FROM trade_instruments WHERE status = 'active' GROUP BY kind"),// The home's list: the instruments with the newest document or observation first, then the rest by symbol (about// 10,000 companies from SEC's list, most of them quiet on any given day).page: db.prepare(`SELECT i.* FROM trade_instruments i@@@ -130,6 +131,8 @@ function createInstruments({ store }) {indexCandidates: async () => await q.indexCandidates.all(),withPending: async () => await q.withPending.all(),editorAdded: async () => await q.editorAdded.all(),++ /** kind → active instruments of that kind ({ equity: 8028, currency: 28, … }). */++ async countByKind() { return Object.fromEntries((await q.countByKind.all()).map((r) => [r.kind, Number(r.n)])); },async page({ limit = 50, offset = 0 } = {}) {return { total: (await q.count.get()).n, instruments: await q.page.all(limit, offset) };},@@@ -41,6 +41,17 @@ function createObservations({ store, ctx }) {history: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = @instrument AND (@metric::text IS NULL OR metric = @metric)AND observed_at < @before ORDER BY observed_at DESC, recorded_at DESC LIMIT @limit`),count: db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations WHERE instrument_id = ?'),++ // The front page's currency table: each currency's two newest rates from the source of its newest one (so the++ // change compares like with like), and how many companies have figures from their filings.++ rates: db.prepare(`SELECT * FROM (++ SELECT o.*, i.symbol, i.name,++ ROW_NUMBER() OVER (PARTITION BY o.instrument_id, o.source_key ORDER BY o.observed_at DESC, o.recorded_at DESC, o.id DESC) AS rn,++ FIRST_VALUE(o.source_key) OVER (PARTITION BY o.instrument_id ORDER BY o.observed_at DESC, o.recorded_at DESC, o.id DESC) AS newest_source++ FROM trade_market_observations o JOIN trade_instruments i ON i.id = o.instrument_id++ WHERE i.kind = 'currency' AND i.status = 'active' AND o.metric = 'rate') x++ WHERE x.source_key = x.newest_source AND x.rn <= 2 ORDER BY x.symbol, x.observed_at DESC`),++ stats: db.prepare(`SELECT COUNT(*) AS total, COUNT(DISTINCT o.instrument_id) FILTER (WHERE i.kind <> 'currency') AS companies++ FROM trade_market_observations o JOIN trade_instruments i ON i.id = o.instrument_id`),};function validate(input, instrument) {@@@ -82,6 +93,18 @@ function createObservations({ store, ctx }) {get: async (id) => await q.byId.get(id) || null,count: async (instrument) => (await q.count.get(instrument.id)).n,latest: async (instrument) => await q.latest.all(instrument.id),++ /** Each currency's newest rate and the one before it from the same source: [{ latest, previous|null, symbol, name }]. */++ async rates() {++ const by = new Map();++ for (const r of await q.rates.all()) {++ const e = by.get(r.symbol) || { symbol: r.symbol, name: r.name, latest: null, previous: null };++ if (!e.latest) e.latest = r; else if (!e.previous) e.previous = r;++ by.set(r.symbol, e);++ }++ return [...by.values()];++ },++ /** { total observations, companies with figures } */++ stats: async () => { const r = await q.stats.get(); return { total: Number(r.total), companies: Number(r.companies) }; },async history(instrument, { metric = null, before = null, limit = 50 } = {}) {const b = parseTime(before);return await q.history.all({ instrument: instrument.id, metric: metric == null ? null : metric, before: b == null ? Number.MAX_SAFE_INTEGER : b, limit: Math.min(Math.max(Number(limit) || 50, 1), 500) });@@@ -8,6 +8,7 @@* GET /llms-full.txt the same map, with a short summary of every indexable* instrument (never the source's full text)* GET /sitemap.xml sitemap index++ * GET /sitemaps/pages.xml the front page, /instruments, /filings and /sources* GET /sitemaps/instruments.xml INDEXABLE instrument pages only (the gate decides:* person-reviewed context, no stale price); lastmod = the* latest instrument, observation or document change@@@ -74,7 +75,9 @@ function createDiscovery(ctx) {details: 'Every number on an instrument page is an observation with observed_at, retrieved_at and its source; an instrument without observations shows no number. Stale sources are labelled "stale since <time>" and never replaced. Each instrument page has a JSON twin at /i/<SYMBOL>.json with the same data. AI briefs (OpenVibe.AI) are written only from the SEC numbers and filings on the page, cite each one, are labelled as AI-written and not reviewed by a person, and never give advice; editor context drafted by AI is not published before a person reviews it. There is no custody, order execution, escrow, marketplace or personal advice.',sections: [{ title: 'Start here', links: [-- { title: 'Instruments', url: abs('/'), note: 'all instruments' },++ { title: 'Front page', url: abs('/'), note: 'AI briefs, euro reference rates, new annual and quarterly reports, filings by form, newest filings' },++ { title: 'Companies and currencies', url: abs('/instruments'), note: 'all instruments, the newest filing or figure first' },++ { title: 'Filings', url: abs('/filings'), note: 'the newest SEC filings; /filings?form=10-K for one form type' },{ title: 'Source freshness', url: abs('/sources') },{ title: 'Sitemap', url: abs('/sitemap.xml') },] },@@@ -118,7 +121,20 @@ function createDiscovery(ctx) {const changes = await pageChanges();const times = active.filter((i, n) => decisions[n].indexable).map((i) => pageLastmod(i, changes)).filter(Boolean);const newest = times.length ? new Date(Math.max(...times)).toISOString() : null;-- xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/instruments.xml'), ...(newest ? { lastmod: newest } : {}) }]));++ const pagesNewest = (await documents.recent(1))[0];++ xml(res, seo.sitemapIndex([++ { loc: abs('/sitemaps/pages.xml'), ...(pagesNewest ? { lastmod: new Date(Math.max(pagesNewest.first_seen_at, pagesNewest.published_at || 0)).toISOString() } : {}) },++ { loc: abs('/sitemaps/instruments.xml'), ...(newest ? { lastmod: newest } : {}) },++ ]));++ });++++ // The pages a person browses: the front page, every company and currency, the filings, source freshness. Their++ // lastmod is the newest filing's (they list it); /sources has none of its own.++ define(router, 'get', '/sitemaps/pages.xml', 'pagesSitemap', async (_req, res) => {++ const newest = (await documents.recent(1))[0];++ const lastmod = newest ? Math.max(newest.first_seen_at, newest.published_at || 0) : null;++ const decide = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 } });++ xml(res, seo.sitemap(['/', '/instruments', '/filings', '/sources'].map((path) => ({ loc: abs(path), lastmod: path === '/sources' ? null : lastmod, decision: decide(path) }))).files[0]);});define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', async (_req, res) => {@@@ -3,7 +3,10 @@/*** Public pages, server-rendered and complete without JavaScript:*-- * GET / instruments, recent documents, feeds++ * GET / the front page: numbers, AI briefs, euro rates, new reports, filings by form, newest++ * filings, companies with something new, questions (render/home.js)++ * GET /instruments every instrument, the newest filing or figure first (was the front page's list)++ * GET /filings[?form=] the newest SEC filings, optionally of one form type* GET /resolve?q= deterministic resolution (CIK → ticker → exact name); one match → 302* GET /i/:symbol the instrument page: observations with timestamps and sources, context* with its disclosure, documents, source freshness ("stale since")@@@ -22,6 +25,7 @@ const rateLimit = require('express-rate-limit');const { ApiError } = require('./errors');const { paginate } = require('openvibe-publishing/ssr');const views = require('../render/views');++const home = require('../render/home');const { SITE_SUMMARY } = require('../render/layout');const { define } = require('./routes');@@@ -35,36 +39,89 @@ function createPages(ctx) {title: 'What shipped on OpenVibe.Trade', canonical: urls.abs('/updates'), decision: listing('/updates'),body: frame.updatesBody({ service: 'trade', siteName: 'OpenVibe.Trade' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,}));-- define(router, 'get', '/', 'showHome', async (req, res) => {-- const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);-- const per = 50;-- const first = await instruments.page({ limit: 0, offset: 0 });-- 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) => {++ /** Each listed instrument's headline number (its latest price, close or rate, else its first metric), else its newest filing. */++ async function headlines(list, now) {++ const latest = new Map(await Promise.all(list.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];})));// Without a headline number, the newest filing stands in on the list.-- const latestDocs = new Map((await Promise.all(page.instruments.filter((i) => !latest.get(i.id)).map(async (i) => {++ const latestDocs = new Map((await Promise.all(list.filter((i) => !latest.get(i.id)).map(async (i) => {const [d] = await documents.forInstrument(i, { limit: 1 });return d ? [i.id, documents.dto(d)] : null;}))).filter(Boolean));-- 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}`);++ return { latest, latestDocs };++ }++ const withInstruments = async (docs) => await Promise.all(docs.map(async (d) => ({ document: documents.dto(d), instrument: await instruments.get(d.instrument_id) })));++ const DAY = 24 * 3600 * 1000;++ const 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)' }];++++ // The front page (render/home.js): the numbers, briefs, rates, reports, filings by form, newest filings and movers.++ define(router, 'get', '/', 'showHome', async (req, res) => {++ // The instruments list's later pages moved to /instruments with the front page.++ const p = parseInt(req.query.page, 10);++ if (p > 1) { common.vary(res); res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })); return res.redirect(301, `/instruments?page=${p}`); }++ const now = ctx.store.now();++ const [kinds, docStats, obsStats, briefStats, briefs, rateRows, reportDocs, forms, recentDocs, movers] = await Promise.all([++ instruments.countByKind(), documents.stats(now - DAY), observations.stats(), ctx.briefs.stats(), ctx.briefs.recent(6),++ observations.rates(), documents.recentOf(['10-K', '10-Q', '20-F', '40-F'], { limit: 8 }), documents.formCounts(now - 7 * DAY),++ documents.recent(10), instruments.page({ limit: 12, offset: 0 }),++ ]);++ const rates = await Promise.all(rateRows.map(async (r) => ({ ...r, dto: await observations.dto(r.latest, { now }) })));++ const faq = home.faqItems();common.page(req, res, {-- title: null, canonical, decision: listing(pageNo === 1 ? '/' : `/?page=${pageNo}`),++ title: 'SEC filings, company figures, AI briefs', canonical: urls.abs('/'), decision: listing('/'),++ description: 'SEC filings as they arrive, the figures inside them, euro reference rates and AI briefs that cite every number, for about 8,000 US companies. Information only.',// The home page carries the site's ai-summary meta and WebPage JSON-LD (v1.3.0 forwards it).-- summary: pageNo === 1 ? SITE_SUMMARY : null,-- 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, latest, latestDocs, briefs: pageNo === 1 ? await ctx.briefs.recent(6) : [] }),++ summary: SITE_SUMMARY, styles: [showcase.STYLESHEET], bodyClass: 'home', feeds: FEEDS,++ jsonLd: [++ seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en', potentialAction: { '@type': 'SearchAction', target: `${urls.abs('/resolve')}?q={q}`, 'query-input': 'required name=q' } }),++ home.faqJsonLd(faq),++ ],++ body: home.homePage({++ stats: {++ companies: (kinds.equity || 0) + (kinds.fund || 0), currencies: kinds.currency || 0, instruments: Object.values(kinds).reduce((a, b) => a + b, 0),++ filings: docStats.total, filingsDay: docStats.day, figureCompanies: obsStats.companies, briefs: briefStats.total,++ },++ briefs, rates, reports: await withInstruments(reportDocs), forms, recent: await withInstruments(recentDocs),++ movers: { instruments: movers.instruments, ...await headlines(movers.instruments, now) }, sync: await sync.state(), faq,++ }, urls),++ });++ });++++ define(router, 'get', '/instruments', 'showInstruments', async (req, res) => {++ const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);++ const per = 50;++ const first = await instruments.page({ limit: 0, offset: 0 });++ const pager = paginate({ page: pageNo, perPage: per, total: first.total, href: (p) => (p === 1 ? '/instruments' : `/instruments?page=${p}`) });++ if (pager.outOfRange && first.total) return common.notFound(req, res);++ const page = await instruments.page({ limit: pager.limit, offset: pager.offset });++ const path = pageNo === 1 ? '/instruments' : `/instruments?page=${pageNo}`;++ common.page(req, res, {++ title: pageNo === 1 ? 'Companies and currencies' : `Companies and currencies, page ${pageNo}`, canonical: urls.abs(path), decision: listing(path),++ description: 'US-listed companies from the SEC\'s list and euro reference currencies, the newest filing or figure first, each with its source and time.',++ feeds: FEEDS, body: views.instrumentsPage({ page, urls, pager, ...await headlines(page.instruments, ctx.store.now()) }),++ });++ });++++ // /filings[?form=]: the newest SEC filings (all forms indexable; one form's list is noindex, it repeats the whole).++ define(router, 'get', '/filings', 'showFilings', async (req, res) => {++ const form = typeof req.query.form === 'string' && /^[A-Za-z0-9 ./-]{1,20}$/.test(req.query.form) ? req.query.form.toUpperCase() : null;++ const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);++ const forms = form ? [form] : null;++ const total = await documents.countOf(forms);++ const base = form ? `/filings?form=${encodeURIComponent(form)}` : '/filings';++ const pager = paginate({ page: pageNo, perPage: 50, total, href: (p) => (p === 1 ? base : `${base}${form ? '&' : '?'}page=${p}`) });++ if (pager.outOfRange && total) return common.notFound(req, res);++ const docs = await withInstruments(await documents.recentOf(forms, { limit: pager.limit, offset: pager.offset }));++ const path = pageNo === 1 ? base : `${base}${form ? '&' : '?'}page=${pageNo}`;++ const label = form ? (views.formLabel(form) ? `${views.formLabel(form)}s (${form})` : `${form} filings`) : 'SEC filings as they arrive';++ common.page(req, res, {++ title: pageNo === 1 ? label : `${label}, page ${pageNo}`, canonical: urls.abs(form ? '/filings' : path),++ decision: form ? seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: urls.abs('/filings'), wordCount: 0, noindex: true }, { policy: { minWords: 0 } }) : listing(path),++ description: 'The newest SEC filings from EDGAR: annual and quarterly reports, current reports, proxies and prospectuses, each with when it was published and retrieved.',++ feeds: FEEDS, body: views.filingsPage({ docs, pager, form, counts: await documents.formCounts(ctx.store.now() - 7 * DAY), urls }),});});nnew file mode 100644@@@ -0,0 +1,163 @@++'use strict';++++/**++ * The front page (/): what OpenVibe.Trade is, a company search and the site's numbers, then the latest AI briefs, the++ * euro reference rates, the newest annual and quarterly reports, this week's filings by form, the newest filings,++ * companies with new numbers, what is here and the questions people ask. Every section is built from rows the page++ * passes; one with nothing to show renders nothing.++ *++ * The honesty rules of views.js hold: a number appears only with when it was observed and its source; a stale one++ * says so; AI text is labelled; nothing here is advice.++ */++const { html, raw } = require('openvibe-publishing/ssr');++const showcase = require('openvibe-shared/showcase');++const { when, staleBadge, grouped, clip, formLabel, filingLine, instrumentsTable } = require('./views');++++const count = (n) => Number(n).toLocaleString('en-US');++++function section({ id, title, lede, more, body, note }) {++ if (!body) return '';++ return html`<section class="tr-sec" aria-labelledby="${id}">++<div class="tr-head"><h2 id="${id}">${title}</h2>${more ? html`<a class="tr-more" href="${more.href}">${more.label}<span aria-hidden="true"> →</span></a>` : ''}</div>++${lede ? html`<p class="tr-lede">${lede}</p>` : ''}++${raw(String(body))}++${note ? html`<p class="muted">${note}</p>` : ''}++</section>`;++}++++/** The numbers beside the hero, each a link; zero counts are left out. */++function stats(items) {++ const shown = items.filter((x) => x.n > 0);++ if (!shown.length) return '';++ return html`<ul class="tr-stats" aria-label="OpenVibe.Trade in numbers">${shown.map((x) => html`<li><a href="${x.href}"><b>${count(x.n)}</b><span>${x.label}</span></a></li>`)}</ul>`;++}++++/** The newest AI briefs as cards. */++function briefCards(briefs, urls) {++ if (!briefs.length) return '';++ return html`<ul class="tr-cards">${briefs.map((b) => html`<li class="tr-card">++<p class="tr-card-top"><a href="${urls.path.instrument({ symbol: b.symbol })}#ai-brief"><strong>${b.symbol}</strong></a> <span class="muted">${b.name}</span></p>++<p>${clip(b.summary, 200)}</p>++<p class="muted tr-small">AI brief, ${when(b.created_at)}${b.as_of ? html` · figures to ${when(b.as_of)}` : ''}</p>++</li>`)}</ul>`;++}++++/** "+0.04%" between two stated rates of the same source (null when either is missing or zero). */++function change(latest, previous) {++ if (!latest || !previous) return null;++ const a = Number(previous.value);++ const b = Number(latest.value);++ if (!Number.isFinite(a) || !Number.isFinite(b) || a === 0) return null;++ const pct = ((b - a) / a) * 100;++ return { pct, text: `${pct > 0 ? '+' : pct < 0 ? '−' : '±'}${Math.abs(pct).toFixed(2)}%` };++}++++const SOURCE_NAME = { 'ecb-euro-reference-rates': 'European Central Bank', 'frankfurter-eur-rates': 'Frankfurter (ECB data)' };++++// The currencies most people look for first; the rest of the euro reference rates fold away under them.++const MAJOR = ['USD', 'GBP', 'JPY', 'CHF', 'CNY', 'CAD', 'AUD', 'INR', 'KRW', 'MXN', 'BRL', 'SEK'];++++/** The euro reference rates as cards: what one euro bought in each currency, the change, when, and from which source. */++function ratesTable(rates, urls) {++ if (!rates.length) return '';++ const rank = (r) => (MAJOR.includes(r.symbol) ? MAJOR.indexOf(r.symbol) : MAJOR.length);++ const sorted = [...rates].sort((a, b) => rank(a) - rank(b) || a.symbol.localeCompare(b.symbol));++ const major = sorted.filter((r) => MAJOR.includes(r.symbol));++ const rest = sorted.filter((r) => !MAJOR.includes(r.symbol));++ const grid = (list) => html`<ul class="tr-rates">${list.map((r) => rateCard(r, urls))}</ul>`;++ if (!major.length || !rest.length) return grid(sorted);++ return html`${grid(major)}<details class="tr-more-rates"><summary>All ${rates.length} currencies</summary>${grid(rest)}</details>`;++}++++/** One currency: its rate per euro, the change from the same source's previous rate, the rate date and the source. */++function rateCard(r, urls) {++ const c = change(r.latest, r.previous);++ const day = (v) => html`<time datetime="${new Date(v).toISOString()}">${new Date(v).toISOString().slice(0, 10)}</time>`;++ return html`<li class="tr-rate">++<p class="tr-rate-top"><a href="${urls.path.instrument({ symbol: r.symbol })}"><strong>${r.symbol}</strong></a> <span class="muted">${r.name}</span></p>++<p class="tr-rate-value"><data value="${r.latest.value}">${grouped(r.latest.value)}</data> <span class="muted">per euro</span></p>++<p class="tr-small">${c ? html`<span class="${c.pct > 0 ? 'tr-up' : c.pct < 0 ? 'tr-down' : ''}">${c.text}</span> <span class="muted">since ${day(r.previous.observed_at)}</span>` : html`<span class="muted">no earlier rate</span>`}</p>++<p class="muted tr-small">Rate of ${day(r.latest.observed_at)} · ${SOURCE_NAME[r.latest.source_key] || r.latest.source_key}${r.dto && r.dto.freshness && r.dto.freshness.stale ? html` ${staleBadge(r.dto.freshness)}` : ''}</p>++</li>`;++}++++/** The newest annual and quarterly reports as cards. */++function reportCards(list, urls) {++ if (!list.length) return '';++ return html`<ul class="tr-cards">${list.map((r) => html`<li class="tr-card">++<p class="tr-card-top"><a href="${urls.path.instrument(r.instrument)}#${r.document.id}"><strong>${r.instrument.symbol}</strong></a> <span class="badge">${r.document.form_type}</span></p>++<p>${r.instrument.name}</p>++<p class="muted tr-small">${formLabel(r.document.form_type) || 'Filing'}, ${r.document.published_at ? html`published ${when(r.document.published_at)}` : html`retrieved ${when(r.document.retrieved_at)}`}</p>++</li>`)}</ul>`;++}++++/** This week's filings by form: chips with the plain name, the code and the count, each to /filings?form=. */++function formChips(counts) {++ if (!counts.length) return '';++ return html`<ul class="tr-chips">${counts.slice(0, 16).map((c) => html`<li><a href="/filings?form=${encodeURIComponent(c.form_type)}">${formLabel(c.form_type) ? html`${formLabel(c.form_type)} <span class="muted">(${c.form_type})</span>` : c.form_type}<span class="tr-n"><span class="sr-only">: </span>${count(c.count)}</span></a></li>`)}</ul>`;++}++++/** Questions people ask, answered from how the site works (also the page's FAQPage JSON-LD). */++function faqItems() {++ return [++ { q: 'Is this investment advice?', a: 'No. OpenVibe.Trade shows public information with its sources: SEC filings, the numbers in them and euro reference rates. There is no order entry, no brokerage, no custody of money and no personal recommendation here.' },++ { q: 'Where does the data come from?', a: 'Filings come from the SEC\'s EDGAR feed and company list, read by OpenVibe.Sources. Company figures such as revenue, net income and cash come from the SEC\'s XBRL company facts, exactly as each company filed them. Currency rates come from the European Central Bank\'s reference rates, directly and through Frankfurter.' },++ { q: 'How fresh is it?', a: 'Every value shows when it was observed and when it was retrieved. The SEC\'s filing feed is read every 15 minutes and the rates every day. When a source falls behind, its values are marked stale with the time they went stale, and nothing is filled in by guessing.' },++ { q: 'What is an AI brief?', a: 'A short summary OpenVibe.AI writes from the numbers and filings on a company\'s page. Every point links to the figure it rests on. Briefs are labelled as AI-written, are not reviewed by a person and are not advice.' },++ { q: 'Can I get alerts?', a: 'Yes. Sign in with your free OpenVibe account to keep watchlists and set alert rules for a new filing, a kind of form, or a number crossing a line. Alerts arrive as OpenVibe notifications.' },++ { q: 'How do I find a company?', a: 'Search by ticker, SEC CIK number or company name. One match takes you straight to its page; when several match, they are listed and none is guessed.' },++ ];++}++++function faqJsonLd(items) {++ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: items.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) };++}++++/**++ * d: { stats: { companies, currencies, filings, filingsDay, filers, figures, figureCompanies, briefs },++ * briefs, rates: [{ symbol, name, latest, previous, dto }], reports, recent: [{ document, instrument }],++ * forms: [{ form_type, count }], movers: { instruments, latest, latestDocs }, sync, faq }++ */++function homePage(d, urls) {++ const s = d.stats;++ const hero = html`<section class="sc-hero sc-split tr-hero"><div class="sc-hero-main">++<p class="sc-eyebrow">OpenVibe.Trade</p>++<h1>Market information that shows<span class="sc-accent"> its source</span></h1>++<p class="sc-hero-lede">US-listed companies from the SEC's own list, their filings as they arrive, the key numbers in those filings, euro reference rates and short AI briefs that cite every figure. Every value says when it was observed and which source stated it.</p>++<form class="search tr-search" id="find" method="get" action="/resolve" role="search"><label class="sr-only" for="q">Find a company by ticker, CIK or name</label><input id="q" name="q" type="search" maxlength="200" required placeholder="Ticker, CIK or company name, like AAPL"><button type="submit">Find</button></form>++<div class="sc-actions"><a class="sc-btn sc-primary" href="/filings">Latest filings</a><a class="sc-btn" href="#briefs">AI briefs</a><a class="sc-btn" href="/watchlists">Watchlists and alerts</a></div>++</div>++<div class="sc-hero-aside">${stats([++ { n: s.companies, label: 'companies on the SEC\'s list', href: '/instruments' },++ { n: s.filingsDay, label: 'filings in the last 24 hours', href: '/filings' },++ { n: s.filings, label: 'filings recorded', href: '/filings' },++ { n: s.figureCompanies, label: 'companies with figures from their filings', href: '/instruments' },++ { n: s.briefs, label: 'AI briefs written', href: '#briefs' },++ { n: s.currencies, label: 'currencies with euro rates', href: '#rates' },++ ])}</div></section>`;++++ return html`${hero}++<p class="muted">What this site is not: there is no order entry, no brokerage, no custody of money or assets, no marketplace and no personal recommendation here.</p>++${section({ id: 'briefs', title: 'Latest AI briefs', lede: 'Short summaries of a company\'s latest numbers and filings, each point linked to the figure it rests on.', body: briefCards(d.briefs, urls), note: 'Written by OpenVibe.AI from SEC filings and the numbers in them, not reviewed by a person. Informational only; not investment advice.' })}++${section({ id: 'rates', title: 'Euro reference rates', lede: 'What one euro was worth in each currency on the latest rate date, as each source stated it. A change compares the two newest rates of the same source.', body: ratesTable(d.rates, urls) })}++${section({ id: 'reports', title: 'New annual and quarterly reports', lede: 'The latest 10-K and 10-Q filings, with the period figures read from them on each company\'s page.', more: { href: '/filings?form=10-Q', label: 'All quarterly reports' }, body: reportCards(d.reports, urls) })}++${section({ id: 'forms', title: 'Filed in the last 7 days', lede: 'Filings by form type, the most first.', more: { href: '/filings', label: 'Every filing' }, body: formChips(d.forms) })}++${section({ id: 'recent', title: 'Newest filings', lede: 'As they arrive from the SEC, each with when it was published and when it was retrieved.', more: { href: '/filings', label: 'More filings' }, body: d.recent.length ? html`<ul class="docs">${d.recent.map((r) => filingLine(r, urls))}</ul>` : '' })}++${section({ id: 'movers', title: 'Companies with something new', lede: 'The newest filing or figure first.', more: { href: '/instruments', label: `All ${count(s.instruments)} companies and currencies` }, body: d.movers.instruments.length ? instrumentsTable({ instruments: d.movers.instruments, latest: d.movers.latest, latestDocs: d.movers.latestDocs, urls }) : '' })}++${raw(showcase.features({++ title: 'What is here',++ items: [++ { icon: 'ov:search', title: 'Find a company', text: 'By ticker, SEC CIK or company name. One match takes you straight there; several are listed, never guessed between.', href: '/instruments' },++ { icon: 'ov:history', title: 'The numbers from the filings', text: 'Revenue, net income, EPS, cash flow and the balance sheet, exactly as each company filed them with the SEC, each with its period and filing.' },++ { icon: 'ov:ai', title: 'AI briefs that cite', text: 'OpenVibe.AI reads only the numbers and filings on the page and writes a short brief; every point links to the figure it rests on. Labelled, never advice.', href: '#briefs' },++ { icon: 'ov:docs', title: 'Filings as they arrive', text: 'SEC filings with their publication and retrieval dates, by form, with feeds in RSS, Atom and JSON.', href: '/filings' },++ { icon: 'ov:bell', title: 'Watchlists and alert rules', text: 'Private to you when signed in: an alert for a new filing or a number crossing a line.', href: '/watchlists' },++ { icon: 'ov:check', title: 'Source freshness', text: 'Every source with when it last answered; a source that falls behind marks its values stale.', href: '/sources' },++ ],++ }))}++<section class="tr-sec" aria-labelledby="faq"><div class="tr-head"><h2 id="faq">Questions and answers</h2></div>++<div class="tr-faq">${d.faq.map((x) => html`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div></section>++<p>Feeds of recent documents: <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a>. Source freshness: <a href="/sources">sources</a>.${d.sync && d.sync.last_ok_at ? html` Last synchronised with OpenVibe.Sources ${when(d.sync.last_ok_at)}.` : ''}</p>`;++}++++module.exports = { homePage, faqItems, faqJsonLd, change };@@@ -41,7 +41,8 @@ let RELEASE = 'dev';function setRelease(id) { if (id) RELEASE = String(id); }const NAV_LINKS = [-- { label: 'Instruments', href: '/' },++ { label: 'Companies', href: '/instruments' },++ { label: 'Filings', href: '/filings' },{ label: 'Sources', href: '/sources' },{ label: 'Watchlists', href: '/watchlists' },];@@@ -83,43 +83,65 @@ function clip(text, n) {return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), n - 20))}…`;}--function home({ page, recent, urls, sync, pager, latest = new Map(), latestDocs = new Map(), briefs = [] }) {++/** The instruments table: each row's headline number (or newest filing), as on the old front page. */++function instrumentsTable({ instruments, latest = new Map(), latestDocs = new Map(), urls, caption = null }) {// 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 = instruments.some((i) => i.exchange);// The headline number when there is one; else the newest filing (most companies on a given day only filed); else// nothing to show yet beyond the SEC's company list.const latestCell = (i, o, d) => (o? html`<td class="num"><data value="${o.value}">${grouped(o.value)}</data> ${o.unit} <span class="muted">${labelOf(o.metric)}${o.period ? `, ${o.period}` : html`, ${when(o.observed_at)}`}</span>${o.freshness && o.freshness.stale ? html` ${staleBadge(o.freshness)}` : ''}</td>`: d ? html`<td><a href="${urls.path.instrument(i)}#${d.id}">${d.form_type ? html`<span class="badge">${d.form_type}</span> ` : ''}filed ${when(d.published_at || d.first_seen_at)}</a></td>`: html`<td><span class="muted">on the SEC's company list</span></td>`);-- const rows = page.instruments.map((i) => html`<tr>++ const rows = 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>${exchanges ? html`<td>${i.exchange || html`<span class="muted">not recorded</span>`}</td>` : ''}${latestCell(i, latest.get(i.id), latestDocs.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'}-- — ${r.document.published_at ? html`published ${when(r.document.published_at)}` : html`<span class="muted">no publication date stated</span>`}, retrieved ${when(r.document.retrieved_at)}</li>`);-- const first = !pager || pager.page === 1;-- return html`--${first ? raw(tradeShowcase()) : html`<h1>Sourced market information</h1>`}--<p class="muted">What this site is not: there is no order entry, no brokerage, no custody of money or assets, no marketplace and no personal recommendation here.</p>--<form class="search" id="find" method="get" action="/resolve" role="search">++ return html`<table tabindex="0" class="data">${caption ? html`<caption>${caption}</caption>` : ''}<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>`;++}++++/** /instruments: every instrument, the newest filing or figure first (the old front page's list). */++function instrumentsPage({ page, urls, pager, latest, latestDocs }) {++ return html`${raw(breadcrumbsHtml([{ name: 'Trade', url: '/' }, { name: 'Companies and currencies' }]))}++<h1>Companies and currencies</h1>++<p class="lede">${page.total.toLocaleString('en-US')} instruments: US-listed companies from the SEC's own list and the currencies in the euro reference rates, the newest filing or figure first.</p>++<form class="search" method="get" action="/resolve" role="search"><label for="q">Find an instrument by ticker, CIK or company name</label><input id="q" name="q" type="search" maxlength="200" required><button type="submit">Find</button></form>--${first ? html`<h2 id="briefs">Latest AI briefs</h2>--${recentBriefs(briefs, urls)}` : ''}--<h2>Instruments</h2>--<p class="muted">Newest filing or figure first.</p>--${page.instruments.length ? html`<table tabindex="0" 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>--${recent.length ? html`<ul class="docs">${docs}</ul>` : html`<p class="empty">No documents have been recorded yet.</p>`}--<p>Feeds of recent documents: <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a>. Source freshness: <a href="/sources">sources</a>.${sync && sync.last_ok_at ? html` Last synchronised with OpenVibe.Sources ${when(sync.last_ok_at)}.` : ''}</p>`;++${page.instruments.length ? html`${instrumentsTable({ instruments: page.instruments, latest, latestDocs, urls })}${raw(paginationHtml(pager))}` : html`<p class="empty">No instruments have been added yet.</p>`}`;}++/** One filing as a list line: company, form (with its plain name), title, published and retrieved. */++function filingLine(r, urls) {++ return html`<li><a href="${urls.path.instrument(r.instrument)}#${r.document.id}"><strong>${r.instrument.symbol}</strong></a>++ ${r.document.form_type ? html`<span class="badge" title="${formLabel(r.document.form_type) || ''}">${r.document.form_type}</span>` : ''} ${r.document.title || r.document.filer_name || 'Untitled document'}++ — ${r.document.published_at ? html`published ${when(r.document.published_at)}` : html`<span class="muted">no publication date stated</span>`}, retrieved ${when(r.document.retrieved_at)}</li>`;++}++++/** /filings[?form=]: the newest SEC filings, optionally of one kind of form. */++function filingsPage({ docs, pager, form, counts, urls }) {++ return html`${raw(breadcrumbsHtml([{ name: 'Trade', url: '/' }, { name: 'Filings', url: form ? '/filings' : undefined }, ...(form ? [{ name: form }] : [])].map((c) => (c.url === undefined ? { name: c.name } : c))))}++<h1>${form ? `${formLabel(form) ? `${formLabel(form)}s` : 'Filings'} (${form})` : 'SEC filings as they arrive'}</h1>++<p class="lede">Filings from the SEC's EDGAR feed, gathered by OpenVibe.Sources, the newest first, each with when it was published and when it was retrieved. Information only; not investment advice.</p>++${counts.length ? html`<p class="muted">By form, the last 7 days:</p><ul class="tr-chips">${form ? html`<li><a href="/filings">All forms</a></li>` : ''}${counts.map((c) => html`<li>${c.form_type === form ? html`<span aria-current="page">${c.form_type}<span class="tr-n">${c.count.toLocaleString('en-US')}</span></span>` : html`<a href="/filings?form=${encodeURIComponent(c.form_type)}">${c.form_type}<span class="tr-n"><span class="sr-only">: </span>${c.count.toLocaleString('en-US')}</span></a>`}</li>`)}</ul>` : ''}++${docs.length ? html`<ul class="docs">${docs.map((r) => filingLine(r, urls))}</ul>${raw(paginationHtml(pager))}` : html`<p class="empty">No filings of this form have been recorded.</p>`}++<p>Feeds of recent documents: <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a>.</p>`;++}++++/** SEC form types in plain words (the SEC's own descriptions, shortened); unknown forms have none. */++const FORM_LABEL = {++ '10-K': 'Annual report', '10-K/A': 'Amended annual report', '10-Q': 'Quarterly report', '10-Q/A': 'Amended quarterly report',++ '8-K': 'Current report', '8-K/A': 'Amended current report', '6-K': 'Foreign issuer report', '20-F': 'Foreign annual report', '40-F': 'Canadian annual report',++ 'DEF 14A': 'Proxy statement', 'PRE 14A': 'Preliminary proxy statement', 'DEFA14A': 'Additional proxy material',++ 'S-1': 'Registration for a new offering', 'S-1/A': 'Amended registration', 'S-3': 'Shelf registration', 'S-3ASR': 'Automatic shelf registration', 'S-4': 'Merger registration', 'S-8': 'Employee stock plan registration',++ '424B2': 'Prospectus supplement', '424B3': 'Prospectus', '424B4': 'Final prospectus', '424B5': 'Prospectus supplement',++ 'SC 13D': 'Beneficial ownership report', 'SC 13G': 'Passive ownership report', '11-K': 'Employee plan annual report', 'ARS': 'Annual report to shareholders',++ 'N-CSR': 'Fund shareholder report', '10-D': 'Asset-backed distribution report', 'POS AM': 'Post-effective amendment',++};++const formLabel = (f) => FORM_LABEL[f] || null;++// ── Resolve ──────────────────────────────────────────────────────────────────function resolvePage({ result, urls }) {@@@ -401,4 +423,4 @@ function errorPage({ status, title, message }) {return html`<h1>${title}</h1><p>${message}</p><p class="muted">Error ${status}. <a href="/">All instruments</a></p>`;}--module.exports = { home, resolvePage, instrumentPage, sourcesPage, watchlistsPage, editorHome, editorInstrument, errorPage, when };++module.exports = { instrumentsTable, instrumentsPage, filingsPage, filingLine, formLabel, FORM_LABEL, recentBriefs, tradeShowcase, resolvePage, instrumentPage, sourcesPage, watchlistsPage, editorHome, editorInstrument, errorPage, when, staleBadge, grouped, clip };@@@ -1,13 +1,13 @@'use strict';--// The home's "Latest" column: a figure with no period names when it was observed as a real <time>, not as escaped++// The instruments table's "Latest" column (the front page's "Companies with something new" and /instruments): a figure with no period names when it was observed as a real <time>, not as escaped// markup (the browser check of 2026-10-17 found "<time datetime=…>" text widening the table past the page).const assert = require('assert');const views = require('../server/render/views');const urls = { path: { instrument: (i) => `/i/${i.symbol}` } };--const page = { instruments: [{ id: 1, symbol: 'AUD', name: 'Australian Dollar', kind: 'currency', exchange: null }] };++const instruments = [{ id: 1, symbol: 'AUD', name: 'Australian Dollar', kind: 'currency', exchange: null }];const latest = new Map([[1, { value: 1.6172, unit: 'AUD', metric: 'rate', period: null, observed_at: '2026-10-17T00:00:00.000Z' }]]);--const out = String(views.home({ page, recent: [], urls, sync: null, pager: null, latest }));++const out = String(views.instrumentsTable({ instruments, urls, latest }));const cell = out.slice(out.indexOf('<td class="num">'), out.indexOf('</td>', out.indexOf('<td class="num">')));assert.ok(cell.includes('<time datetime="2026-10-17T00:00:00.000Z">2026-10-17 00:00 UTC</time>'), cell);assert.ok(!out.includes('<time'), 'no escaped markup');@@@ -160,6 +160,43 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');assert.ok(home.includes('"@type":"WebPage"'), 'the home carries WebPage JSON-LD');});++ await check('the front page: one h1, a search, the numbers, its sections and questions (FAQPage JSON-LD); lists moved to /instruments and /filings', async () => {++ const r = await t.get('/');++ const body = r.text.slice(r.text.indexOf('<main'));++ assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');++ assert.ok(body.includes('action="/resolve"') && body.includes('class="tr-stats"') && body.includes('id="recent"') && body.includes('id="movers"') && body.includes('id="faq"'));++ assert.ok(body.includes('<a href="/filings?form=10-K">Annual report <span class="muted">(10-K)</span>'), 'filings by form, in plain words');++ assert.ok(body.includes('id="reports"') && body.indexOf('ACME', body.indexOf('id="reports"')) > 0, 'the 10-K among new annual and quarterly reports');++ assert.ok(!body.includes('id="rates"'), 'no rates section without a currency rate');++ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ const faq = ld.find((x) => x['@type'] === 'FAQPage');++ assert.ok(faq && ld.some((x) => x['@type'] === 'WebSite'));++ for (const q of faq.mainEntity) assert.ok(body.includes(`<summary>${q.name.replace(/'/g, ''')}</summary>`), `the question is on the page: ${q.name}`);++ assert.doesNotMatch(body, /coming soon/i);++ const old = await t.get('/?page=2');++ assert.deepStrictEqual([old.status, old.headers.get('location')], [301, '/instruments?page=2']);++ const list = await t.get('/instruments');++ assert.ok(list.status === 200 && list.text.includes('<h1>Companies and currencies</h1>') && list.text.includes('Acme Corp'));++ const filings = await t.get('/filings');++ assert.ok(filings.status === 200 && filings.text.includes('ACME') && !/name="robots" content="noindex/.test(filings.text));++ const tenK = await t.get('/filings?form=10-K');++ assert.ok(tenK.text.includes('Annual reports (10-K)') && /name="robots" content="noindex/.test(tenK.text), 'one form: noindex, canonical /filings');++ assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.trade/sitemaps/pages.xml'));++ const pages = (await t.get('/sitemaps/pages.xml')).text;++ for (const p of ['/', '/instruments', '/filings', '/sources']) assert.ok(pages.includes(`<loc>https://openvibe.trade${p}</loc>`), p);++ });++++ await check('the front page\'s euro rates: each currency\'s newest rate, its date and source, and the change from the same source', async () => {++ const usd = await t.instrument({ symbol: 'USD', name: 'US Dollar', kind: 'currency' });++ await t.observe(usd, { metric: 'rate', value: '1.1200', unit: 'USD', currency: 'USD', source_key: 'frankfurter-eur-rates', source_ref: 'usd-1', observed_at: t.iso(t.T0 - 2 * 86400e3), retrieved_at: t.iso(t.T0) });++ await t.observe(usd, { metric: 'rate', value: '1.1312', unit: 'USD', currency: 'USD', source_key: 'frankfurter-eur-rates', source_ref: 'usd-2', observed_at: t.iso(t.T0 - 86400e3), retrieved_at: t.iso(t.T0) });++ const body = (await t.get('/')).text;++ const at = body.indexOf('id="rates"');++ assert.ok(at > 0 && body.indexOf('<data value="1.1312">1.1312</data>', at) > at, 'the newest rate');++ assert.ok(body.includes('+1.00%'), 'the change from the previous rate of the same source');++ assert.ok(body.includes('Frankfurter (ECB data)'));++ });++await t.close();done();})();@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 29, // measured 27.4 with the AI briefs section and feature (2026-10-15); 24.6 before (18.2 before the showcase, 2026-10-07)-- htmlBrotliKB: 7, // 6.2 (4.7 before the showcase)++ htmlRawKB: 36, // ~32.5 with the curated front page (numbers, sections, six questions and their JSON-LD; 2026-10-22); 27.4 with the AI briefs (2026-10-15)++ htmlBrotliKB: 8.5, // 7.7 with the curated front page; 6.2 before (4.7 before the showcase)jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 2 (trade.css, openvibe-shared showcase.css)-- cssRawKB: 18.5, // 18.0 with tables scrolling sideways up to 1024 px (2026-10-17); 17.8 with the AI brief styles (2026-10-15); 16.2 before (4.7 before showcase.css)-- cssBrotliKB: 4.7, // 4.5 with the tablet table rule (2026-10-17); 3.9 (1.3 before showcase.css)++ cssRawKB: 26, // ~23 with the front page's numbers, cards, rate cards, chips and questions (2026-10-22); 18.0 before (4.7 before showcase.css)++ cssBrotliKB: 6, // ~5.3 with the front page (2026-10-22); 4.5 with the tablet table rule (1.3 before showcase.css)externalFiles: 1, // 0};
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
## Unreleased
## Unreleased
+- The front page is curated (`server/render/home.js`): a company search and the numbers beside the hero (companies, filings in 24 hours, filings recorded, companies with figures, AI briefs, currencies), then AI briefs as cards, euro reference rates as cards with the change from the same source, new 10-K/10-Q reports, the week's filings by form in plain words, the newest filings, companies with something new, what is here and six questions (FAQPage JSON-LD). The instruments list moved to `/instruments` (301 from `/?page=N`); `/filings[?form=]` is new; `/sitemaps/pages.xml` lists `/`, `/instruments`, `/filings` and `/sources` (the sitemap listed nothing before: instrument pages stay noindex until a person reviews their context). The navbar reads Companies, Filings, Sources, Watchlists.
- Account export and deletion (ADR-033) through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0):
- Account export and deletion (ADR-033) through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0):
- **Deleted:** watchlists, alert rules (with their deliveries) and drafts.
- **Deleted:** watchlists, alert rules (with their deliveries) and drafts.
- **Authorless:** contributed instruments, aliases, purges, context revisions and hand-recorded observations.
- **Authorless:** contributed instruments, aliases, purges, context revisions and hand-recorded observations.
@@ -149,7 +149,9 @@ Trade shows only what a source stated:
@@ -149,7 +149,9 @@ Trade shows only what a source stated:
| Route | What |
| Route | What |
|---|---|
|---|---|
-| `/` | instruments, recent documents, feeds |
+| `/` | the front page (`server/render/home.js`): a company search and the site's numbers, the latest AI briefs, euro reference rates (each currency's newest rate, its date and source, and the change from the same source's previous rate), new 10-K/10-Q reports, the last 7 days' filings by form (plain names), the newest filings, companies with something new, what is here and six questions (FAQPage JSON-LD); `/?page=N` answers 301 to `/instruments?page=N` |
+| `/instruments` | every instrument, the newest filing or figure first (the front page's old list), 50 a page |
+| `/filings`, `/filings?form=` | the newest SEC filings, 50 a page, with the last 7 days' counts by form; one form's list is noindex with `/filings` as its canonical |
| `/resolve?q=` | resolution; one match → 302 to the instrument |
| `/resolve?q=` | resolution; one match → 302 to the instrument |
| `/i/:symbol`, `/i/:symbol.json` | the instrument page (observations with timestamps and sources, context with disclosure, documents, source freshness, watch/alert forms), and the same data as JSON |
| `/i/:symbol`, `/i/:symbol.json` | the instrument page (observations with timestamps and sources, context with disclosure, documents, source freshness, watch/alert forms), and the same data as JSON |
| `/i/:symbol/documents.xml`, `.atom`, `.json` | the instrument's documents as RSS, Atom and JSON Feed |
| `/i/:symbol/documents.xml`, `.atom`, `.json` | the instrument's documents as RSS, Atom and JSON Feed |
@@ -67,3 +67,50 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
@@ -67,3 +67,50 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
@media (max-width: 1024px) {
@media (max-width: 1024px) {
table.data { display: block; overflow-x: auto; }
table.data { display: block; overflow-x: auto; }
}
}
+/* The front page (server/render/home.js): numbers beside the hero, cards, the rates table, form chips, questions. */
+.home .page { max-width: 1080px; }
+.tr-hero { padding-top: 28px; }
+.tr-search { display: flex; gap: 8px; margin: 20px 0 0; max-width: 560px; }
+.tr-search input[type=search] { flex: 1 1 auto; width: auto; min-width: 0; padding: 10px 12px; font-size: 1rem; border-radius: 10px; }
+.tr-search button { margin: 0; padding: 10px 18px; border-radius: 10px; }
+.tr-stats { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.tr-stats li { min-width: 0; }
+.tr-stats a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; text-decoration: none; transition: border-color .15s; }
+.tr-stats a:hover, .tr-stats a:focus-visible { border-color: var(--accent, #3b82f6); }
+.tr-stats b { font-size: 1.6rem; line-height: 1.1; font-variant-numeric: tabular-nums; }
+.tr-stats span { font-size: .85rem; line-height: 1.35; color: var(--text-secondary, #96a7c2); }
+.tr-sec { margin-top: 44px; }
+.tr-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }
+.page .tr-head h2 { margin: 0; font-family: var(--ov-font, 'Inter', system-ui, sans-serif); font-size: clamp(1.3rem, 2.4vw, 1.75rem); letter-spacing: -.01em; }
+.tr-more { font-weight: 600; font-size: .95rem; text-decoration: none; }
+.tr-more:hover { text-decoration: underline; text-underline-offset: 3px; }
+.tr-lede { margin: 4px 0 14px; color: var(--text-secondary, #96a7c2); }
+.tr-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); }
+.tr-card { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); overflow-wrap: anywhere; }
+.tr-card p { margin: 0; }
+.tr-card-top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }
+.tr-small { font-size: .82rem; margin-top: auto !important; }
+.tr-wrap { overflow-x: auto; }
+.tr-rates th[scope=row] { font-weight: 600; white-space: nowrap; }
+.tr-up { color: var(--success, #22c55e); font-weight: 600; }
+.tr-down { color: var(--warning, #f59e0b); font-weight: 600; }
+.tr-chips { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.tr-chips li { min-width: 0; max-width: 100%; }
+.tr-chips a, .tr-chips [aria-current] { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; overflow-wrap: anywhere; }
+.tr-chips [aria-current] { border-color: var(--accent, #3b82f6); font-weight: 700; }
+.tr-chips a:hover, .tr-chips a:focus-visible { border-color: var(--accent, #3b82f6); }
+.tr-n { font-size: .78rem; font-weight: 700; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
+.tr-faq { display: grid; gap: 8px; }
+.tr-faq details { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); padding: 0 16px; }
+.tr-faq summary { cursor: pointer; font-weight: 600; padding: 12px 0; }
+.tr-faq p { margin: 0 0 14px; color: var(--text-secondary, #96a7c2); }
+.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+@media (max-width: 600px) { .tr-search { flex-wrap: wrap; } .tr-search button { width: 100%; } .tr-stats b { font-size: 1.3rem; } .tr-stats a { padding: 12px; } .tr-cards > li:nth-child(n+5) { display: none; } }
+@media (prefers-reduced-motion: reduce) { .tr-stats a { transition: none; } }
+.home .disclaimer { max-width: 1048px; }
+.tr-more-rates { margin-top: 8px; }
+.tr-more-rates summary { cursor: pointer; font-weight: 600; padding: 6px 0; }
+.tr-rates { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr)); }
+.tr-rate { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); min-width: 0; }
+.tr-rate p { margin: 0; overflow-wrap: anywhere; }
+.tr-rate-value data { font-size: 1.3rem; font-weight: 700; font-variant-numeric: tabular-nums; }
@@ -34,6 +34,7 @@ function createBriefs({ store, config, ctx, ai, log = console }) {
@@ -34,6 +34,7 @@ function createBriefs({ store, config, ctx, ai, log = console }) {
WHERE i.status = 'active' AND b.id = (SELECT id FROM trade_ai_briefs x WHERE x.instrument_id = b.instrument_id ORDER BY created_at DESC, id DESC LIMIT 1)
WHERE i.status = 'active' AND b.id = (SELECT id FROM trade_ai_briefs x WHERE x.instrument_id = b.instrument_id ORDER BY created_at DESC, id DESC LIMIT 1)
ORDER BY b.created_at DESC LIMIT ?`),
ORDER BY b.created_at DESC LIMIT ?`),
lastHour: db.prepare('SELECT COUNT(*) AS n FROM trade_ai_briefs WHERE created_at > ?'),
lastHour: db.prepare('SELECT COUNT(*) AS n FROM trade_ai_briefs WHERE created_at > ?'),
+ stats: db.prepare('SELECT COUNT(*) AS n, COUNT(DISTINCT instrument_id) AS companies FROM trade_ai_briefs'),
insert: db.prepare(`INSERT INTO trade_ai_briefs (id, instrument_id, summary, points, cites, gaps, as_of, inputs_hash, run_id, model, trigger, created_at)
insert: db.prepare(`INSERT INTO trade_ai_briefs (id, instrument_id, summary, points, cites, gaps, as_of, inputs_hash, run_id, model, trigger, created_at)
VALUES (@id, @instrument_id, @summary, @points, @cites, @gaps, @as_of, @inputs_hash, @run_id, @model, @trigger, @created_at)`),
VALUES (@id, @instrument_id, @summary, @points, @cites, @gaps, @as_of, @inputs_hash, @run_id, @model, @trigger, @created_at)`),
series: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = ?
series: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = ?
@@ -187,6 +188,8 @@ function createBriefs({ store, config, ctx, ai, log = console }) {
@@ -187,6 +188,8 @@ function createBriefs({ store, config, ctx, ai, log = console }) {
inputsFor,
inputsFor,
latest: async (instrument) => view(await q.latest.get(instrument.id)),
latest: async (instrument) => view(await q.latest.get(instrument.id)),
recent: async (limit = 8) => (await q.recent.all(limit)).map(view),
recent: async (limit = 8) => (await q.recent.all(limit)).map(view),
+ /** { briefs written, companies with one } */
+ stats: async () => { const r = await q.stats.get(); return { total: Number(r.n), companies: Number(r.companies) }; },
due: async (limit) => await q.due.all(limit),
due: async (limit) => await q.due.all(limit),
lastHour,
lastHour,
};
};
@@ -30,6 +30,17 @@ function createDocuments({ store, ctx }) {
@@ -30,6 +30,17 @@ function createDocuments({ store, ctx }) {
countFor: db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents WHERE instrument_id = ? AND removed_at IS NULL'),
countFor: db.prepare('SELECT COUNT(*) AS n FROM trade_source_documents WHERE instrument_id = ? AND removed_at IS NULL'),
recent: db.prepare(`SELECT d.* FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_id
recent: db.prepare(`SELECT d.* FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_id
WHERE d.removed_at IS NULL AND i.status = 'active' ORDER BY COALESCE(d.published_at, d.first_seen_at) DESC, d.id DESC LIMIT ?`),
WHERE d.removed_at IS NULL AND i.status = 'active' ORDER BY COALESCE(d.published_at, d.first_seen_at) DESC, d.id DESC LIMIT ?`),
+ // /filings and the front page: newest first, optionally of some form types; counts by form type since a time.
+ recentOf: db.prepare(`SELECT d.* FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_id
+ WHERE d.removed_at IS NULL AND i.status = 'active' AND (@forms::text[] IS NULL OR d.form_type = ANY(@forms::text[]))
+ ORDER BY COALESCE(d.published_at, d.first_seen_at) DESC, d.id DESC LIMIT @limit OFFSET @offset`),
+ countOf: db.prepare(`SELECT COUNT(*) AS n FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_id
+ WHERE d.removed_at IS NULL AND i.status = 'active' AND (@forms::text[] IS NULL OR d.form_type = ANY(@forms::text[]))`),
+ formCounts: db.prepare(`SELECT d.form_type, COUNT(*) AS n FROM trade_source_documents d JOIN trade_instruments i ON i.id = d.instrument_id
+ WHERE d.removed_at IS NULL AND i.status = 'active' AND d.form_type IS NOT NULL AND COALESCE(d.published_at, d.first_seen_at) > ?
+ GROUP BY d.form_type ORDER BY n DESC, d.form_type LIMIT 40`),
+ stats: db.prepare(`SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE d.first_seen_at > ?) AS day, COUNT(DISTINCT d.instrument_id) AS companies
+ FROM trade_source_documents d WHERE d.removed_at IS NULL`),
};
};
const api = {
const api = {
@@ -38,6 +49,13 @@ function createDocuments({ store, ctx }) {
@@ -38,6 +49,13 @@ function createDocuments({ store, ctx }) {
forInstrument: async (instrument, { limit = 50, offset = 0 } = {}) => await q.forInstrument.all(instrument.id, limit, offset),
forInstrument: async (instrument, { limit = 50, offset = 0 } = {}) => await q.forInstrument.all(instrument.id, limit, offset),
count: async (instrument) => (await q.countFor.get(instrument.id)).n,
count: async (instrument) => (await q.countFor.get(instrument.id)).n,
recent: async (limit = 50) => await q.recent.all(limit),
recent: async (limit = 50) => await q.recent.all(limit),
+ /** Newest documents of these form types (null: any), paginated. */
+ recentOf: async (forms, { limit = 50, offset = 0 } = {}) => await q.recentOf.all({ forms: forms && forms.length ? forms : null, limit, offset }),
+ countOf: async (forms) => Number((await q.countOf.get({ forms: forms && forms.length ? forms : null })).n),
+ /** Form type → documents published (or first seen) since `since`, the most first. */
+ formCounts: async (since) => (await q.formCounts.all(since)).map((r) => ({ form_type: r.form_type, count: Number(r.n) })),
+ /** { total, day (first seen since `since`), companies } over documents still shown. */
+ stats: async (since) => { const r = await q.stats.get(since); return { total: Number(r.total), day: Number(r.day), companies: Number(r.companies) }; },
/** From a mapped Sources item (domain/mapping.js). → { document, created, changed } */
/** From a mapped Sources item (domain/mapping.js). → { document, created, changed } */
async upsert(doc, instrument, { traceparent } = {}) {
async upsert(doc, instrument, { traceparent } = {}) {
@@ -65,6 +65,7 @@ function createInstruments({ store }) {
@@ -65,6 +65,7 @@ function createInstruments({ store }) {
insertAlias: db.prepare(`INSERT INTO trade_instrument_aliases (instrument_id, kind, value, normalized, added_by, created_at)
insertAlias: db.prepare(`INSERT INTO trade_instrument_aliases (instrument_id, kind, value, normalized, added_by, created_at)
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),
count: db.prepare("SELECT COUNT(*) AS n FROM trade_instruments WHERE status = 'active'"),
count: db.prepare("SELECT COUNT(*) AS n FROM trade_instruments WHERE status = 'active'"),
+ countByKind: db.prepare("SELECT kind, COUNT(*) AS n FROM trade_instruments WHERE status = 'active' GROUP BY kind"),
// The home's list: the instruments with the newest document or observation first, then the rest by symbol (about
// The home's list: the instruments with the newest document or observation first, then the rest by symbol (about
// 10,000 companies from SEC's list, most of them quiet on any given day).
// 10,000 companies from SEC's list, most of them quiet on any given day).
page: db.prepare(`SELECT i.* FROM trade_instruments i
page: db.prepare(`SELECT i.* FROM trade_instruments i
@@ -130,6 +131,8 @@ function createInstruments({ store }) {
@@ -130,6 +131,8 @@ function createInstruments({ store }) {
indexCandidates: async () => await q.indexCandidates.all(),
indexCandidates: async () => await q.indexCandidates.all(),
withPending: async () => await q.withPending.all(),
withPending: async () => await q.withPending.all(),
editorAdded: async () => await q.editorAdded.all(),
editorAdded: async () => await q.editorAdded.all(),
+ /** kind → active instruments of that kind ({ equity: 8028, currency: 28, … }). */
+ async countByKind() { return Object.fromEntries((await q.countByKind.all()).map((r) => [r.kind, Number(r.n)])); },
async page({ limit = 50, offset = 0 } = {}) {
async page({ limit = 50, offset = 0 } = {}) {
return { total: (await q.count.get()).n, instruments: await q.page.all(limit, offset) };
return { total: (await q.count.get()).n, instruments: await q.page.all(limit, offset) };
},
},
@@ -41,6 +41,17 @@ function createObservations({ store, ctx }) {
@@ -41,6 +41,17 @@ function createObservations({ store, ctx }) {
history: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = @instrument AND (@metric::text IS NULL OR metric = @metric)
history: db.prepare(`SELECT * FROM trade_market_observations WHERE instrument_id = @instrument AND (@metric::text IS NULL OR metric = @metric)
AND observed_at < @before ORDER BY observed_at DESC, recorded_at DESC LIMIT @limit`),
AND observed_at < @before ORDER BY observed_at DESC, recorded_at DESC LIMIT @limit`),
count: db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations WHERE instrument_id = ?'),
count: db.prepare('SELECT COUNT(*) AS n FROM trade_market_observations WHERE instrument_id = ?'),
+ // The front page's currency table: each currency's two newest rates from the source of its newest one (so the
+ // change compares like with like), and how many companies have figures from their filings.
+ rates: db.prepare(`SELECT * FROM (
+ SELECT o.*, i.symbol, i.name,
+ ROW_NUMBER() OVER (PARTITION BY o.instrument_id, o.source_key ORDER BY o.observed_at DESC, o.recorded_at DESC, o.id DESC) AS rn,
+ FIRST_VALUE(o.source_key) OVER (PARTITION BY o.instrument_id ORDER BY o.observed_at DESC, o.recorded_at DESC, o.id DESC) AS newest_source
+ FROM trade_market_observations o JOIN trade_instruments i ON i.id = o.instrument_id
+ WHERE i.kind = 'currency' AND i.status = 'active' AND o.metric = 'rate') x
+ WHERE x.source_key = x.newest_source AND x.rn <= 2 ORDER BY x.symbol, x.observed_at DESC`),
+ stats: db.prepare(`SELECT COUNT(*) AS total, COUNT(DISTINCT o.instrument_id) FILTER (WHERE i.kind <> 'currency') AS companies
+ FROM trade_market_observations o JOIN trade_instruments i ON i.id = o.instrument_id`),
};
};
function validate(input, instrument) {
function validate(input, instrument) {
@@ -82,6 +93,18 @@ function createObservations({ store, ctx }) {
@@ -82,6 +93,18 @@ function createObservations({ store, ctx }) {
get: async (id) => await q.byId.get(id) || null,
get: async (id) => await q.byId.get(id) || null,
count: async (instrument) => (await q.count.get(instrument.id)).n,
count: async (instrument) => (await q.count.get(instrument.id)).n,
latest: async (instrument) => await q.latest.all(instrument.id),
latest: async (instrument) => await q.latest.all(instrument.id),
+ /** Each currency's newest rate and the one before it from the same source: [{ latest, previous|null, symbol, name }]. */
+ async rates() {
+ const by = new Map();
+ for (const r of await q.rates.all()) {
+ const e = by.get(r.symbol) || { symbol: r.symbol, name: r.name, latest: null, previous: null };
+ if (!e.latest) e.latest = r; else if (!e.previous) e.previous = r;
+ by.set(r.symbol, e);
+ }
+ return [...by.values()];
+ },
+ /** { total observations, companies with figures } */
+ stats: async () => { const r = await q.stats.get(); return { total: Number(r.total), companies: Number(r.companies) }; },
async history(instrument, { metric = null, before = null, limit = 50 } = {}) {
async history(instrument, { metric = null, before = null, limit = 50 } = {}) {
const b = parseTime(before);
const b = parseTime(before);
return await q.history.all({ instrument: instrument.id, metric: metric == null ? null : metric, before: b == null ? Number.MAX_SAFE_INTEGER : b, limit: Math.min(Math.max(Number(limit) || 50, 1), 500) });
return await q.history.all({ instrument: instrument.id, metric: metric == null ? null : metric, before: b == null ? Number.MAX_SAFE_INTEGER : b, limit: Math.min(Math.max(Number(limit) || 50, 1), 500) });
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* GET /llms-full.txt the same map, with a short summary of every indexable
* GET /llms-full.txt the same map, with a short summary of every indexable
* instrument (never the source's full text)
* instrument (never the source's full text)
* GET /sitemap.xml sitemap index
* GET /sitemap.xml sitemap index
+ * GET /sitemaps/pages.xml the front page, /instruments, /filings and /sources
* GET /sitemaps/instruments.xml INDEXABLE instrument pages only (the gate decides:
* GET /sitemaps/instruments.xml INDEXABLE instrument pages only (the gate decides:
* person-reviewed context, no stale price); lastmod = the
* person-reviewed context, no stale price); lastmod = the
* latest instrument, observation or document change
* latest instrument, observation or document change
@@ -74,7 +75,9 @@ function createDiscovery(ctx) {
@@ -74,7 +75,9 @@ function createDiscovery(ctx) {
details: 'Every number on an instrument page is an observation with observed_at, retrieved_at and its source; an instrument without observations shows no number. Stale sources are labelled "stale since <time>" and never replaced. Each instrument page has a JSON twin at /i/<SYMBOL>.json with the same data. AI briefs (OpenVibe.AI) are written only from the SEC numbers and filings on the page, cite each one, are labelled as AI-written and not reviewed by a person, and never give advice; editor context drafted by AI is not published before a person reviews it. There is no custody, order execution, escrow, marketplace or personal advice.',
details: 'Every number on an instrument page is an observation with observed_at, retrieved_at and its source; an instrument without observations shows no number. Stale sources are labelled "stale since <time>" and never replaced. Each instrument page has a JSON twin at /i/<SYMBOL>.json with the same data. AI briefs (OpenVibe.AI) are written only from the SEC numbers and filings on the page, cite each one, are labelled as AI-written and not reviewed by a person, and never give advice; editor context drafted by AI is not published before a person reviews it. There is no custody, order execution, escrow, marketplace or personal advice.',
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
- { title: 'Instruments', url: abs('/'), note: 'all instruments' },
+ { title: 'Front page', url: abs('/'), note: 'AI briefs, euro reference rates, new annual and quarterly reports, filings by form, newest filings' },
+ { title: 'Companies and currencies', url: abs('/instruments'), note: 'all instruments, the newest filing or figure first' },
+ { title: 'Filings', url: abs('/filings'), note: 'the newest SEC filings; /filings?form=10-K for one form type' },
{ title: 'Source freshness', url: abs('/sources') },
{ title: 'Source freshness', url: abs('/sources') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
] },
] },
@@ -118,7 +121,20 @@ function createDiscovery(ctx) {
@@ -118,7 +121,20 @@ function createDiscovery(ctx) {
const changes = await pageChanges();
const changes = await pageChanges();
const times = active.filter((i, n) => decisions[n].indexable).map((i) => pageLastmod(i, changes)).filter(Boolean);
const times = active.filter((i, n) => decisions[n].indexable).map((i) => pageLastmod(i, changes)).filter(Boolean);
const newest = times.length ? new Date(Math.max(...times)).toISOString() : null;
const newest = times.length ? new Date(Math.max(...times)).toISOString() : null;
- xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/instruments.xml'), ...(newest ? { lastmod: newest } : {}) }]));
+ const pagesNewest = (await documents.recent(1))[0];
+ xml(res, seo.sitemapIndex([
+ { loc: abs('/sitemaps/pages.xml'), ...(pagesNewest ? { lastmod: new Date(Math.max(pagesNewest.first_seen_at, pagesNewest.published_at || 0)).toISOString() } : {}) },
+ { loc: abs('/sitemaps/instruments.xml'), ...(newest ? { lastmod: newest } : {}) },
+ ]));
+ });
+
+ // The pages a person browses: the front page, every company and currency, the filings, source freshness. Their
+ // lastmod is the newest filing's (they list it); /sources has none of its own.
+ define(router, 'get', '/sitemaps/pages.xml', 'pagesSitemap', async (_req, res) => {
+ const newest = (await documents.recent(1))[0];
+ const lastmod = newest ? Math.max(newest.first_seen_at, newest.published_at || 0) : null;
+ const decide = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 } });
+ xml(res, seo.sitemap(['/', '/instruments', '/filings', '/sources'].map((path) => ({ loc: abs(path), lastmod: path === '/sources' ? null : lastmod, decision: decide(path) }))).files[0]);
});
});
define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', async (_req, res) => {
define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', async (_req, res) => {
@@ -3,7 +3,10 @@
@@ -3,7 +3,10 @@
/**
/**
* Public pages, server-rendered and complete without JavaScript:
* Public pages, server-rendered and complete without JavaScript:
*
*
- * GET / instruments, recent documents, feeds
+ * GET / the front page: numbers, AI briefs, euro rates, new reports, filings by form, newest
+ * filings, companies with something new, questions (render/home.js)
+ * GET /instruments every instrument, the newest filing or figure first (was the front page's list)
+ * GET /filings[?form=] the newest SEC filings, optionally of one form type
* GET /resolve?q= deterministic resolution (CIK → ticker → exact name); one match → 302
* GET /resolve?q= deterministic resolution (CIK → ticker → exact name); one match → 302
* GET /i/:symbol the instrument page: observations with timestamps and sources, context
* GET /i/:symbol the instrument page: observations with timestamps and sources, context
* with its disclosure, documents, source freshness ("stale since")
* with its disclosure, documents, source freshness ("stale since")
@@ -22,6 +25,7 @@ const rateLimit = require('express-rate-limit');
@@ -22,6 +25,7 @@ const rateLimit = require('express-rate-limit');
const { ApiError } = require('./errors');
const { ApiError } = require('./errors');
const { paginate } = require('openvibe-publishing/ssr');
const { paginate } = require('openvibe-publishing/ssr');
const views = require('../render/views');
const views = require('../render/views');
+const home = require('../render/home');
const { SITE_SUMMARY } = require('../render/layout');
const { SITE_SUMMARY } = require('../render/layout');
const { define } = require('./routes');
const { define } = require('./routes');
@@ -35,36 +39,89 @@ function createPages(ctx) {
@@ -35,36 +39,89 @@ function createPages(ctx) {
title: 'What shipped on OpenVibe.Trade', canonical: urls.abs('/updates'), decision: listing('/updates'),
title: 'What shipped on OpenVibe.Trade', canonical: urls.abs('/updates'), decision: listing('/updates'),
body: frame.updatesBody({ service: 'trade', siteName: 'OpenVibe.Trade' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'trade', siteName: 'OpenVibe.Trade' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}));
}));
- define(router, 'get', '/', 'showHome', async (req, res) => {
- const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
- const per = 50;
- const first = await instruments.page({ limit: 0, offset: 0 });
- 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) => {
+ /** Each listed instrument's headline number (its latest price, close or rate, else its first metric), else its newest filing. */
+ async function headlines(list, now) {
+ const latest = new Map(await Promise.all(list.map(async (i) => {
const rows = await observations.latest(i);
const rows = await observations.latest(i);
const pick = ['price', 'close', 'rate'].map((m) => rows.find((o) => o.metric === m)).find(Boolean) || rows[0];
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];
return [i.id, pick ? await observations.dto(pick, { now }) : null];
})));
})));
// Without a headline number, the newest filing stands in on the list.
// Without a headline number, the newest filing stands in on the list.
- const latestDocs = new Map((await Promise.all(page.instruments.filter((i) => !latest.get(i.id)).map(async (i) => {
+ const latestDocs = new Map((await Promise.all(list.filter((i) => !latest.get(i.id)).map(async (i) => {
const [d] = await documents.forInstrument(i, { limit: 1 });
const [d] = await documents.forInstrument(i, { limit: 1 });
return d ? [i.id, documents.dto(d)] : null;
return d ? [i.id, documents.dto(d)] : null;
}))).filter(Boolean));
}))).filter(Boolean));
- 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}`);
+ return { latest, latestDocs };
+ }
+ const withInstruments = async (docs) => await Promise.all(docs.map(async (d) => ({ document: documents.dto(d), instrument: await instruments.get(d.instrument_id) })));
+ const DAY = 24 * 3600 * 1000;
+ const 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)' }];
+
+ // The front page (render/home.js): the numbers, briefs, rates, reports, filings by form, newest filings and movers.
+ define(router, 'get', '/', 'showHome', async (req, res) => {
+ // The instruments list's later pages moved to /instruments with the front page.
+ const p = parseInt(req.query.page, 10);
+ if (p > 1) { common.vary(res); res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })); return res.redirect(301, `/instruments?page=${p}`); }
+ const now = ctx.store.now();
+ const [kinds, docStats, obsStats, briefStats, briefs, rateRows, reportDocs, forms, recentDocs, movers] = await Promise.all([
+ instruments.countByKind(), documents.stats(now - DAY), observations.stats(), ctx.briefs.stats(), ctx.briefs.recent(6),
+ observations.rates(), documents.recentOf(['10-K', '10-Q', '20-F', '40-F'], { limit: 8 }), documents.formCounts(now - 7 * DAY),
+ documents.recent(10), instruments.page({ limit: 12, offset: 0 }),
+ ]);
+ const rates = await Promise.all(rateRows.map(async (r) => ({ ...r, dto: await observations.dto(r.latest, { now }) })));
+ const faq = home.faqItems();
common.page(req, res, {
common.page(req, res, {
- title: null, canonical, decision: listing(pageNo === 1 ? '/' : `/?page=${pageNo}`),
+ title: 'SEC filings, company figures, AI briefs', canonical: urls.abs('/'), decision: listing('/'),
+ description: 'SEC filings as they arrive, the figures inside them, euro reference rates and AI briefs that cite every number, for about 8,000 US companies. Information only.',
// The home page carries the site's ai-summary meta and WebPage JSON-LD (v1.3.0 forwards it).
// The home page carries the site's ai-summary meta and WebPage JSON-LD (v1.3.0 forwards it).
- summary: pageNo === 1 ? SITE_SUMMARY : null,
- 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, latest, latestDocs, briefs: pageNo === 1 ? await ctx.briefs.recent(6) : [] }),
+ summary: SITE_SUMMARY, styles: [showcase.STYLESHEET], bodyClass: 'home', feeds: FEEDS,
+ jsonLd: [
+ seo.compact({ '@context': 'https://schema.org', '@type': 'WebSite', url: urls.abs('/'), name: 'OpenVibe.Trade', inLanguage: 'en', potentialAction: { '@type': 'SearchAction', target: `${urls.abs('/resolve')}?q={q}`, 'query-input': 'required name=q' } }),
+ home.faqJsonLd(faq),
+ ],
+ body: home.homePage({
+ stats: {
+ companies: (kinds.equity || 0) + (kinds.fund || 0), currencies: kinds.currency || 0, instruments: Object.values(kinds).reduce((a, b) => a + b, 0),
+ filings: docStats.total, filingsDay: docStats.day, figureCompanies: obsStats.companies, briefs: briefStats.total,
+ },
+ briefs, rates, reports: await withInstruments(reportDocs), forms, recent: await withInstruments(recentDocs),
+ movers: { instruments: movers.instruments, ...await headlines(movers.instruments, now) }, sync: await sync.state(), faq,
+ }, urls),
+ });
+ });
+
+ define(router, 'get', '/instruments', 'showInstruments', async (req, res) => {
+ const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
+ const per = 50;
+ const first = await instruments.page({ limit: 0, offset: 0 });
+ const pager = paginate({ page: pageNo, perPage: per, total: first.total, href: (p) => (p === 1 ? '/instruments' : `/instruments?page=${p}`) });
+ if (pager.outOfRange && first.total) return common.notFound(req, res);
+ const page = await instruments.page({ limit: pager.limit, offset: pager.offset });
+ const path = pageNo === 1 ? '/instruments' : `/instruments?page=${pageNo}`;
+ common.page(req, res, {
+ title: pageNo === 1 ? 'Companies and currencies' : `Companies and currencies, page ${pageNo}`, canonical: urls.abs(path), decision: listing(path),
+ description: 'US-listed companies from the SEC\'s list and euro reference currencies, the newest filing or figure first, each with its source and time.',
+ feeds: FEEDS, body: views.instrumentsPage({ page, urls, pager, ...await headlines(page.instruments, ctx.store.now()) }),
+ });
+ });
+
+ // /filings[?form=]: the newest SEC filings (all forms indexable; one form's list is noindex, it repeats the whole).
+ define(router, 'get', '/filings', 'showFilings', async (req, res) => {
+ const form = typeof req.query.form === 'string' && /^[A-Za-z0-9 ./-]{1,20}$/.test(req.query.form) ? req.query.form.toUpperCase() : null;
+ const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
+ const forms = form ? [form] : null;
+ const total = await documents.countOf(forms);
+ const base = form ? `/filings?form=${encodeURIComponent(form)}` : '/filings';
+ const pager = paginate({ page: pageNo, perPage: 50, total, href: (p) => (p === 1 ? base : `${base}${form ? '&' : '?'}page=${p}`) });
+ if (pager.outOfRange && total) return common.notFound(req, res);
+ const docs = await withInstruments(await documents.recentOf(forms, { limit: pager.limit, offset: pager.offset }));
+ const path = pageNo === 1 ? base : `${base}${form ? '&' : '?'}page=${pageNo}`;
+ const label = form ? (views.formLabel(form) ? `${views.formLabel(form)}s (${form})` : `${form} filings`) : 'SEC filings as they arrive';
+ common.page(req, res, {
+ title: pageNo === 1 ? label : `${label}, page ${pageNo}`, canonical: urls.abs(form ? '/filings' : path),
+ decision: form ? seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: urls.abs('/filings'), wordCount: 0, noindex: true }, { policy: { minWords: 0 } }) : listing(path),
+ description: 'The newest SEC filings from EDGAR: annual and quarterly reports, current reports, proxies and prospectuses, each with when it was published and retrieved.',
+ feeds: FEEDS, body: views.filingsPage({ docs, pager, form, counts: await documents.formCounts(ctx.store.now() - 7 * DAY), urls }),
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
+'use strict';
+
+/**
+ * The front page (/): what OpenVibe.Trade is, a company search and the site's numbers, then the latest AI briefs, the
+ * euro reference rates, the newest annual and quarterly reports, this week's filings by form, the newest filings,
+ * companies with new numbers, what is here and the questions people ask. Every section is built from rows the page
+ * passes; one with nothing to show renders nothing.
+ *
+ * The honesty rules of views.js hold: a number appears only with when it was observed and its source; a stale one
+ * says so; AI text is labelled; nothing here is advice.
+ */
+const { html, raw } = require('openvibe-publishing/ssr');
+const showcase = require('openvibe-shared/showcase');
+const { when, staleBadge, grouped, clip, formLabel, filingLine, instrumentsTable } = require('./views');
+
+const count = (n) => Number(n).toLocaleString('en-US');
+
+function section({ id, title, lede, more, body, note }) {
+ if (!body) return '';
+ return html`<section class="tr-sec" aria-labelledby="${id}">
+<div class="tr-head"><h2 id="${id}">${title}</h2>${more ? html`<a class="tr-more" href="${more.href}">${more.label}<span aria-hidden="true"> →</span></a>` : ''}</div>
+${lede ? html`<p class="tr-lede">${lede}</p>` : ''}
+${raw(String(body))}
+${note ? html`<p class="muted">${note}</p>` : ''}
+</section>`;
+}
+
+/** The numbers beside the hero, each a link; zero counts are left out. */
+function stats(items) {
+ const shown = items.filter((x) => x.n > 0);
+ if (!shown.length) return '';
+ return html`<ul class="tr-stats" aria-label="OpenVibe.Trade in numbers">${shown.map((x) => html`<li><a href="${x.href}"><b>${count(x.n)}</b><span>${x.label}</span></a></li>`)}</ul>`;
+}
+
+/** The newest AI briefs as cards. */
+function briefCards(briefs, urls) {
+ if (!briefs.length) return '';
+ return html`<ul class="tr-cards">${briefs.map((b) => html`<li class="tr-card">
+<p class="tr-card-top"><a href="${urls.path.instrument({ symbol: b.symbol })}#ai-brief"><strong>${b.symbol}</strong></a> <span class="muted">${b.name}</span></p>
+<p>${clip(b.summary, 200)}</p>
+<p class="muted tr-small">AI brief, ${when(b.created_at)}${b.as_of ? html` · figures to ${when(b.as_of)}` : ''}</p>
+</li>`)}</ul>`;
+}
+
+/** "+0.04%" between two stated rates of the same source (null when either is missing or zero). */
+function change(latest, previous) {
+ if (!latest || !previous) return null;
+ const a = Number(previous.value);
+ const b = Number(latest.value);
+ if (!Number.isFinite(a) || !Number.isFinite(b) || a === 0) return null;
+ const pct = ((b - a) / a) * 100;
+ return { pct, text: `${pct > 0 ? '+' : pct < 0 ? '−' : '±'}${Math.abs(pct).toFixed(2)}%` };
+}
+
+const SOURCE_NAME = { 'ecb-euro-reference-rates': 'European Central Bank', 'frankfurter-eur-rates': 'Frankfurter (ECB data)' };
+
+// The currencies most people look for first; the rest of the euro reference rates fold away under them.
+const MAJOR = ['USD', 'GBP', 'JPY', 'CHF', 'CNY', 'CAD', 'AUD', 'INR', 'KRW', 'MXN', 'BRL', 'SEK'];
+
+/** The euro reference rates as cards: what one euro bought in each currency, the change, when, and from which source. */
+function ratesTable(rates, urls) {
+ if (!rates.length) return '';
+ const rank = (r) => (MAJOR.includes(r.symbol) ? MAJOR.indexOf(r.symbol) : MAJOR.length);
+ const sorted = [...rates].sort((a, b) => rank(a) - rank(b) || a.symbol.localeCompare(b.symbol));
+ const major = sorted.filter((r) => MAJOR.includes(r.symbol));
+ const rest = sorted.filter((r) => !MAJOR.includes(r.symbol));
+ const grid = (list) => html`<ul class="tr-rates">${list.map((r) => rateCard(r, urls))}</ul>`;
+ if (!major.length || !rest.length) return grid(sorted);
+ return html`${grid(major)}<details class="tr-more-rates"><summary>All ${rates.length} currencies</summary>${grid(rest)}</details>`;
+}
+
+/** One currency: its rate per euro, the change from the same source's previous rate, the rate date and the source. */
+function rateCard(r, urls) {
+ const c = change(r.latest, r.previous);
+ const day = (v) => html`<time datetime="${new Date(v).toISOString()}">${new Date(v).toISOString().slice(0, 10)}</time>`;
+ return html`<li class="tr-rate">
+<p class="tr-rate-top"><a href="${urls.path.instrument({ symbol: r.symbol })}"><strong>${r.symbol}</strong></a> <span class="muted">${r.name}</span></p>
+<p class="tr-rate-value"><data value="${r.latest.value}">${grouped(r.latest.value)}</data> <span class="muted">per euro</span></p>
+<p class="tr-small">${c ? html`<span class="${c.pct > 0 ? 'tr-up' : c.pct < 0 ? 'tr-down' : ''}">${c.text}</span> <span class="muted">since ${day(r.previous.observed_at)}</span>` : html`<span class="muted">no earlier rate</span>`}</p>
+<p class="muted tr-small">Rate of ${day(r.latest.observed_at)} · ${SOURCE_NAME[r.latest.source_key] || r.latest.source_key}${r.dto && r.dto.freshness && r.dto.freshness.stale ? html` ${staleBadge(r.dto.freshness)}` : ''}</p>
+</li>`;
+}
+
+/** The newest annual and quarterly reports as cards. */
+function reportCards(list, urls) {
+ if (!list.length) return '';
+ return html`<ul class="tr-cards">${list.map((r) => html`<li class="tr-card">
+<p class="tr-card-top"><a href="${urls.path.instrument(r.instrument)}#${r.document.id}"><strong>${r.instrument.symbol}</strong></a> <span class="badge">${r.document.form_type}</span></p>
+<p>${r.instrument.name}</p>
+<p class="muted tr-small">${formLabel(r.document.form_type) || 'Filing'}, ${r.document.published_at ? html`published ${when(r.document.published_at)}` : html`retrieved ${when(r.document.retrieved_at)}`}</p>
+</li>`)}</ul>`;
+}
+
+/** This week's filings by form: chips with the plain name, the code and the count, each to /filings?form=. */
+function formChips(counts) {
+ if (!counts.length) return '';
+ return html`<ul class="tr-chips">${counts.slice(0, 16).map((c) => html`<li><a href="/filings?form=${encodeURIComponent(c.form_type)}">${formLabel(c.form_type) ? html`${formLabel(c.form_type)} <span class="muted">(${c.form_type})</span>` : c.form_type}<span class="tr-n"><span class="sr-only">: </span>${count(c.count)}</span></a></li>`)}</ul>`;
+}
+
+/** Questions people ask, answered from how the site works (also the page's FAQPage JSON-LD). */
+function faqItems() {
+ return [
+ { q: 'Is this investment advice?', a: 'No. OpenVibe.Trade shows public information with its sources: SEC filings, the numbers in them and euro reference rates. There is no order entry, no brokerage, no custody of money and no personal recommendation here.' },
+ { q: 'Where does the data come from?', a: 'Filings come from the SEC\'s EDGAR feed and company list, read by OpenVibe.Sources. Company figures such as revenue, net income and cash come from the SEC\'s XBRL company facts, exactly as each company filed them. Currency rates come from the European Central Bank\'s reference rates, directly and through Frankfurter.' },
+ { q: 'How fresh is it?', a: 'Every value shows when it was observed and when it was retrieved. The SEC\'s filing feed is read every 15 minutes and the rates every day. When a source falls behind, its values are marked stale with the time they went stale, and nothing is filled in by guessing.' },
+ { q: 'What is an AI brief?', a: 'A short summary OpenVibe.AI writes from the numbers and filings on a company\'s page. Every point links to the figure it rests on. Briefs are labelled as AI-written, are not reviewed by a person and are not advice.' },
+ { q: 'Can I get alerts?', a: 'Yes. Sign in with your free OpenVibe account to keep watchlists and set alert rules for a new filing, a kind of form, or a number crossing a line. Alerts arrive as OpenVibe notifications.' },
+ { q: 'How do I find a company?', a: 'Search by ticker, SEC CIK number or company name. One match takes you straight to its page; when several match, they are listed and none is guessed.' },
+ ];
+}
+
+function faqJsonLd(items) {
+ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: items.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) };
+}
+
+/**
+ * d: { stats: { companies, currencies, filings, filingsDay, filers, figures, figureCompanies, briefs },
+ * briefs, rates: [{ symbol, name, latest, previous, dto }], reports, recent: [{ document, instrument }],
+ * forms: [{ form_type, count }], movers: { instruments, latest, latestDocs }, sync, faq }
+ */
+function homePage(d, urls) {
+ const s = d.stats;
+ const hero = html`<section class="sc-hero sc-split tr-hero"><div class="sc-hero-main">
+<p class="sc-eyebrow">OpenVibe.Trade</p>
+<h1>Market information that shows<span class="sc-accent"> its source</span></h1>
+<p class="sc-hero-lede">US-listed companies from the SEC's own list, their filings as they arrive, the key numbers in those filings, euro reference rates and short AI briefs that cite every figure. Every value says when it was observed and which source stated it.</p>
+<form class="search tr-search" id="find" method="get" action="/resolve" role="search"><label class="sr-only" for="q">Find a company by ticker, CIK or name</label><input id="q" name="q" type="search" maxlength="200" required placeholder="Ticker, CIK or company name, like AAPL"><button type="submit">Find</button></form>
+<div class="sc-actions"><a class="sc-btn sc-primary" href="/filings">Latest filings</a><a class="sc-btn" href="#briefs">AI briefs</a><a class="sc-btn" href="/watchlists">Watchlists and alerts</a></div>
+</div>
+<div class="sc-hero-aside">${stats([
+ { n: s.companies, label: 'companies on the SEC\'s list', href: '/instruments' },
+ { n: s.filingsDay, label: 'filings in the last 24 hours', href: '/filings' },
+ { n: s.filings, label: 'filings recorded', href: '/filings' },
+ { n: s.figureCompanies, label: 'companies with figures from their filings', href: '/instruments' },
+ { n: s.briefs, label: 'AI briefs written', href: '#briefs' },
+ { n: s.currencies, label: 'currencies with euro rates', href: '#rates' },
+ ])}</div></section>`;
+
+ return html`${hero}
+<p class="muted">What this site is not: there is no order entry, no brokerage, no custody of money or assets, no marketplace and no personal recommendation here.</p>
+${section({ id: 'briefs', title: 'Latest AI briefs', lede: 'Short summaries of a company\'s latest numbers and filings, each point linked to the figure it rests on.', body: briefCards(d.briefs, urls), note: 'Written by OpenVibe.AI from SEC filings and the numbers in them, not reviewed by a person. Informational only; not investment advice.' })}
+${section({ id: 'rates', title: 'Euro reference rates', lede: 'What one euro was worth in each currency on the latest rate date, as each source stated it. A change compares the two newest rates of the same source.', body: ratesTable(d.rates, urls) })}
+${section({ id: 'reports', title: 'New annual and quarterly reports', lede: 'The latest 10-K and 10-Q filings, with the period figures read from them on each company\'s page.', more: { href: '/filings?form=10-Q', label: 'All quarterly reports' }, body: reportCards(d.reports, urls) })}
+${section({ id: 'forms', title: 'Filed in the last 7 days', lede: 'Filings by form type, the most first.', more: { href: '/filings', label: 'Every filing' }, body: formChips(d.forms) })}
+${section({ id: 'recent', title: 'Newest filings', lede: 'As they arrive from the SEC, each with when it was published and when it was retrieved.', more: { href: '/filings', label: 'More filings' }, body: d.recent.length ? html`<ul class="docs">${d.recent.map((r) => filingLine(r, urls))}</ul>` : '' })}
+${section({ id: 'movers', title: 'Companies with something new', lede: 'The newest filing or figure first.', more: { href: '/instruments', label: `All ${count(s.instruments)} companies and currencies` }, body: d.movers.instruments.length ? instrumentsTable({ instruments: d.movers.instruments, latest: d.movers.latest, latestDocs: d.movers.latestDocs, urls }) : '' })}
+${raw(showcase.features({
+ title: 'What is here',
+ items: [
+ { icon: 'ov:search', title: 'Find a company', text: 'By ticker, SEC CIK or company name. One match takes you straight there; several are listed, never guessed between.', href: '/instruments' },
+ { icon: 'ov:history', title: 'The numbers from the filings', text: 'Revenue, net income, EPS, cash flow and the balance sheet, exactly as each company filed them with the SEC, each with its period and filing.' },
+ { icon: 'ov:ai', title: 'AI briefs that cite', text: 'OpenVibe.AI reads only the numbers and filings on the page and writes a short brief; every point links to the figure it rests on. Labelled, never advice.', href: '#briefs' },
+ { icon: 'ov:docs', title: 'Filings as they arrive', text: 'SEC filings with their publication and retrieval dates, by form, with feeds in RSS, Atom and JSON.', href: '/filings' },
+ { icon: 'ov:bell', title: 'Watchlists and alert rules', text: 'Private to you when signed in: an alert for a new filing or a number crossing a line.', href: '/watchlists' },
+ { icon: 'ov:check', title: 'Source freshness', text: 'Every source with when it last answered; a source that falls behind marks its values stale.', href: '/sources' },
+ ],
+ }))}
+<section class="tr-sec" aria-labelledby="faq"><div class="tr-head"><h2 id="faq">Questions and answers</h2></div>
+<div class="tr-faq">${d.faq.map((x) => html`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div></section>
+<p>Feeds of recent documents: <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a>. Source freshness: <a href="/sources">sources</a>.${d.sync && d.sync.last_ok_at ? html` Last synchronised with OpenVibe.Sources ${when(d.sync.last_ok_at)}.` : ''}</p>`;
+}
+
+module.exports = { homePage, faqItems, faqJsonLd, change };
@@ -41,7 +41,8 @@ let RELEASE = 'dev';
@@ -41,7 +41,8 @@ let RELEASE = 'dev';
function setRelease(id) { if (id) RELEASE = String(id); }
function setRelease(id) { if (id) RELEASE = String(id); }
const NAV_LINKS = [
const NAV_LINKS = [
- { label: 'Instruments', href: '/' },
+ { label: 'Companies', href: '/instruments' },
+ { label: 'Filings', href: '/filings' },
{ label: 'Sources', href: '/sources' },
{ label: 'Sources', href: '/sources' },
{ label: 'Watchlists', href: '/watchlists' },
{ label: 'Watchlists', href: '/watchlists' },
];
];
@@ -83,43 +83,65 @@ function clip(text, n) {
@@ -83,43 +83,65 @@ function clip(text, n) {
return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), n - 20))}…`;
return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), n - 20))}…`;
}
}
-function home({ page, recent, urls, sync, pager, latest = new Map(), latestDocs = new Map(), briefs = [] }) {
+/** The instruments table: each row's headline number (or newest filing), as on the old front page. */
+function instrumentsTable({ instruments, latest = new Map(), latestDocs = new Map(), urls, caption = null }) {
// 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 = instruments.some((i) => i.exchange);
// The headline number when there is one; else the newest filing (most companies on a given day only filed); else
// The headline number when there is one; else the newest filing (most companies on a given day only filed); else
// nothing to show yet beyond the SEC's company list.
// nothing to show yet beyond the SEC's company list.
const latestCell = (i, o, d) => (o
const latestCell = (i, o, d) => (o
? html`<td class="num"><data value="${o.value}">${grouped(o.value)}</data> ${o.unit} <span class="muted">${labelOf(o.metric)}${o.period ? `, ${o.period}` : html`, ${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}` : html`, ${when(o.observed_at)}`}</span>${o.freshness && o.freshness.stale ? html` ${staleBadge(o.freshness)}` : ''}</td>`
: d ? html`<td><a href="${urls.path.instrument(i)}#${d.id}">${d.form_type ? html`<span class="badge">${d.form_type}</span> ` : ''}filed ${when(d.published_at || d.first_seen_at)}</a></td>`
: d ? html`<td><a href="${urls.path.instrument(i)}#${d.id}">${d.form_type ? html`<span class="badge">${d.form_type}</span> ` : ''}filed ${when(d.published_at || d.first_seen_at)}</a></td>`
: html`<td><span class="muted">on the SEC's company list</span></td>`);
: html`<td><span class="muted">on the SEC's company list</span></td>`);
- const rows = page.instruments.map((i) => html`<tr>
+ const rows = 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>${exchanges ? html`<td>${i.exchange || html`<span class="muted">not recorded</span>`}</td>` : ''}${latestCell(i, latest.get(i.id), latestDocs.get(i.id))}
<td>${i.name}</td><td>${i.kind}</td>${exchanges ? html`<td>${i.exchange || html`<span class="muted">not recorded</span>`}</td>` : ''}${latestCell(i, latest.get(i.id), latestDocs.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>
- ${r.document.form_type ? html`<span class="badge">${r.document.form_type}</span>` : ''} ${r.document.title || r.document.filer_name || 'Untitled document'}
- — ${r.document.published_at ? html`published ${when(r.document.published_at)}` : html`<span class="muted">no publication date stated</span>`}, retrieved ${when(r.document.retrieved_at)}</li>`);
- const first = !pager || pager.page === 1;
- return html`
-${first ? raw(tradeShowcase()) : html`<h1>Sourced market information</h1>`}
-<p class="muted">What this site is not: there is no order entry, no brokerage, no custody of money or assets, no marketplace and no personal recommendation here.</p>
-<form class="search" id="find" method="get" action="/resolve" role="search">
+ return html`<table tabindex="0" class="data">${caption ? html`<caption>${caption}</caption>` : ''}<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>`;
+}
+
+/** /instruments: every instrument, the newest filing or figure first (the old front page's list). */
+function instrumentsPage({ page, urls, pager, latest, latestDocs }) {
+ return html`${raw(breadcrumbsHtml([{ name: 'Trade', url: '/' }, { name: 'Companies and currencies' }]))}
+<h1>Companies and currencies</h1>
+<p class="lede">${page.total.toLocaleString('en-US')} instruments: US-listed companies from the SEC's own list and the currencies in the euro reference rates, the newest filing or figure first.</p>
+<form class="search" method="get" action="/resolve" role="search">
<label for="q">Find an instrument by ticker, CIK or company name</label>
<label for="q">Find an instrument by ticker, CIK or company name</label>
<input id="q" name="q" type="search" maxlength="200" required>
<input id="q" name="q" type="search" maxlength="200" required>
<button type="submit">Find</button>
<button type="submit">Find</button>
</form>
</form>
-${first ? html`<h2 id="briefs">Latest AI briefs</h2>
-${recentBriefs(briefs, urls)}` : ''}
-<h2>Instruments</h2>
-<p class="muted">Newest filing or figure first.</p>
-${page.instruments.length ? html`<table tabindex="0" 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>
-${recent.length ? html`<ul class="docs">${docs}</ul>` : html`<p class="empty">No documents have been recorded yet.</p>`}
-<p>Feeds of recent documents: <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a>. Source freshness: <a href="/sources">sources</a>.${sync && sync.last_ok_at ? html` Last synchronised with OpenVibe.Sources ${when(sync.last_ok_at)}.` : ''}</p>`;
+${page.instruments.length ? html`${instrumentsTable({ instruments: page.instruments, latest, latestDocs, urls })}${raw(paginationHtml(pager))}` : html`<p class="empty">No instruments have been added yet.</p>`}`;
}
}
+/** One filing as a list line: company, form (with its plain name), title, published and retrieved. */
+function filingLine(r, urls) {
+ return html`<li><a href="${urls.path.instrument(r.instrument)}#${r.document.id}"><strong>${r.instrument.symbol}</strong></a>
+ ${r.document.form_type ? html`<span class="badge" title="${formLabel(r.document.form_type) || ''}">${r.document.form_type}</span>` : ''} ${r.document.title || r.document.filer_name || 'Untitled document'}
+ — ${r.document.published_at ? html`published ${when(r.document.published_at)}` : html`<span class="muted">no publication date stated</span>`}, retrieved ${when(r.document.retrieved_at)}</li>`;
+}
+
+/** /filings[?form=]: the newest SEC filings, optionally of one kind of form. */
+function filingsPage({ docs, pager, form, counts, urls }) {
+ return html`${raw(breadcrumbsHtml([{ name: 'Trade', url: '/' }, { name: 'Filings', url: form ? '/filings' : undefined }, ...(form ? [{ name: form }] : [])].map((c) => (c.url === undefined ? { name: c.name } : c))))}
+<h1>${form ? `${formLabel(form) ? `${formLabel(form)}s` : 'Filings'} (${form})` : 'SEC filings as they arrive'}</h1>
+<p class="lede">Filings from the SEC's EDGAR feed, gathered by OpenVibe.Sources, the newest first, each with when it was published and when it was retrieved. Information only; not investment advice.</p>
+${counts.length ? html`<p class="muted">By form, the last 7 days:</p><ul class="tr-chips">${form ? html`<li><a href="/filings">All forms</a></li>` : ''}${counts.map((c) => html`<li>${c.form_type === form ? html`<span aria-current="page">${c.form_type}<span class="tr-n">${c.count.toLocaleString('en-US')}</span></span>` : html`<a href="/filings?form=${encodeURIComponent(c.form_type)}">${c.form_type}<span class="tr-n"><span class="sr-only">: </span>${c.count.toLocaleString('en-US')}</span></a>`}</li>`)}</ul>` : ''}
+${docs.length ? html`<ul class="docs">${docs.map((r) => filingLine(r, urls))}</ul>${raw(paginationHtml(pager))}` : html`<p class="empty">No filings of this form have been recorded.</p>`}
+<p>Feeds of recent documents: <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a>.</p>`;
+}
+
+/** SEC form types in plain words (the SEC's own descriptions, shortened); unknown forms have none. */
+const FORM_LABEL = {
+ '10-K': 'Annual report', '10-K/A': 'Amended annual report', '10-Q': 'Quarterly report', '10-Q/A': 'Amended quarterly report',
+ '8-K': 'Current report', '8-K/A': 'Amended current report', '6-K': 'Foreign issuer report', '20-F': 'Foreign annual report', '40-F': 'Canadian annual report',
+ 'DEF 14A': 'Proxy statement', 'PRE 14A': 'Preliminary proxy statement', 'DEFA14A': 'Additional proxy material',
+ 'S-1': 'Registration for a new offering', 'S-1/A': 'Amended registration', 'S-3': 'Shelf registration', 'S-3ASR': 'Automatic shelf registration', 'S-4': 'Merger registration', 'S-8': 'Employee stock plan registration',
+ '424B2': 'Prospectus supplement', '424B3': 'Prospectus', '424B4': 'Final prospectus', '424B5': 'Prospectus supplement',
+ 'SC 13D': 'Beneficial ownership report', 'SC 13G': 'Passive ownership report', '11-K': 'Employee plan annual report', 'ARS': 'Annual report to shareholders',
+ 'N-CSR': 'Fund shareholder report', '10-D': 'Asset-backed distribution report', 'POS AM': 'Post-effective amendment',
+};
+const formLabel = (f) => FORM_LABEL[f] || null;
+
// ── Resolve ──────────────────────────────────────────────────────────────────
// ── Resolve ──────────────────────────────────────────────────────────────────
function resolvePage({ result, urls }) {
function resolvePage({ result, urls }) {
@@ -401,4 +423,4 @@ function errorPage({ status, title, message }) {
@@ -401,4 +423,4 @@ function errorPage({ status, title, message }) {
return html`<h1>${title}</h1><p>${message}</p><p class="muted">Error ${status}. <a href="/">All instruments</a></p>`;
return html`<h1>${title}</h1><p>${message}</p><p class="muted">Error ${status}. <a href="/">All instruments</a></p>`;
}
}
-module.exports = { home, resolvePage, instrumentPage, sourcesPage, watchlistsPage, editorHome, editorInstrument, errorPage, when };
+module.exports = { instrumentsTable, instrumentsPage, filingsPage, filingLine, formLabel, FORM_LABEL, recentBriefs, tradeShowcase, resolvePage, instrumentPage, sourcesPage, watchlistsPage, editorHome, editorInstrument, errorPage, when, staleBadge, grouped, clip };
@@ -1,13 +1,13 @@
@@ -1,13 +1,13 @@
'use strict';
'use strict';
-// The home's "Latest" column: a figure with no period names when it was observed as a real <time>, not as escaped
+// The instruments table's "Latest" column (the front page's "Companies with something new" and /instruments): a figure with no period names when it was observed as a real <time>, not as escaped
// markup (the browser check of 2026-10-17 found "<time datetime=…>" text widening the table past the page).
// markup (the browser check of 2026-10-17 found "<time datetime=…>" text widening the table past the page).
const assert = require('assert');
const assert = require('assert');
const views = require('../server/render/views');
const views = require('../server/render/views');
const urls = { path: { instrument: (i) => `/i/${i.symbol}` } };
const urls = { path: { instrument: (i) => `/i/${i.symbol}` } };
-const page = { instruments: [{ id: 1, symbol: 'AUD', name: 'Australian Dollar', kind: 'currency', exchange: null }] };
+const instruments = [{ id: 1, symbol: 'AUD', name: 'Australian Dollar', kind: 'currency', exchange: null }];
const latest = new Map([[1, { value: 1.6172, unit: 'AUD', metric: 'rate', period: null, observed_at: '2026-10-17T00:00:00.000Z' }]]);
const latest = new Map([[1, { value: 1.6172, unit: 'AUD', metric: 'rate', period: null, observed_at: '2026-10-17T00:00:00.000Z' }]]);
-const out = String(views.home({ page, recent: [], urls, sync: null, pager: null, latest }));
+const out = String(views.instrumentsTable({ instruments, urls, latest }));
const cell = out.slice(out.indexOf('<td class="num">'), out.indexOf('</td>', out.indexOf('<td class="num">')));
const cell = out.slice(out.indexOf('<td class="num">'), out.indexOf('</td>', out.indexOf('<td class="num">')));
assert.ok(cell.includes('<time datetime="2026-10-17T00:00:00.000Z">2026-10-17 00:00 UTC</time>'), cell);
assert.ok(cell.includes('<time datetime="2026-10-17T00:00:00.000Z">2026-10-17 00:00 UTC</time>'), cell);
assert.ok(!out.includes('<time'), 'no escaped markup');
assert.ok(!out.includes('<time'), 'no escaped markup');
@@ -160,6 +160,43 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
@@ -160,6 +160,43 @@ const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
assert.ok(home.includes('"@type":"WebPage"'), 'the home carries WebPage JSON-LD');
assert.ok(home.includes('"@type":"WebPage"'), 'the home carries WebPage JSON-LD');
});
});
+ await check('the front page: one h1, a search, the numbers, its sections and questions (FAQPage JSON-LD); lists moved to /instruments and /filings', async () => {
+ const r = await t.get('/');
+ const body = r.text.slice(r.text.indexOf('<main'));
+ assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
+ assert.ok(body.includes('action="/resolve"') && body.includes('class="tr-stats"') && body.includes('id="recent"') && body.includes('id="movers"') && body.includes('id="faq"'));
+ assert.ok(body.includes('<a href="/filings?form=10-K">Annual report <span class="muted">(10-K)</span>'), 'filings by form, in plain words');
+ assert.ok(body.includes('id="reports"') && body.indexOf('ACME', body.indexOf('id="reports"')) > 0, 'the 10-K among new annual and quarterly reports');
+ assert.ok(!body.includes('id="rates"'), 'no rates section without a currency rate');
+ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ const faq = ld.find((x) => x['@type'] === 'FAQPage');
+ assert.ok(faq && ld.some((x) => x['@type'] === 'WebSite'));
+ for (const q of faq.mainEntity) assert.ok(body.includes(`<summary>${q.name.replace(/'/g, ''')}</summary>`), `the question is on the page: ${q.name}`);
+ assert.doesNotMatch(body, /coming soon/i);
+ const old = await t.get('/?page=2');
+ assert.deepStrictEqual([old.status, old.headers.get('location')], [301, '/instruments?page=2']);
+ const list = await t.get('/instruments');
+ assert.ok(list.status === 200 && list.text.includes('<h1>Companies and currencies</h1>') && list.text.includes('Acme Corp'));
+ const filings = await t.get('/filings');
+ assert.ok(filings.status === 200 && filings.text.includes('ACME') && !/name="robots" content="noindex/.test(filings.text));
+ const tenK = await t.get('/filings?form=10-K');
+ assert.ok(tenK.text.includes('Annual reports (10-K)') && /name="robots" content="noindex/.test(tenK.text), 'one form: noindex, canonical /filings');
+ assert.ok((await t.get('/sitemap.xml')).text.includes('https://openvibe.trade/sitemaps/pages.xml'));
+ const pages = (await t.get('/sitemaps/pages.xml')).text;
+ for (const p of ['/', '/instruments', '/filings', '/sources']) assert.ok(pages.includes(`<loc>https://openvibe.trade${p}</loc>`), p);
+ });
+
+ await check('the front page\'s euro rates: each currency\'s newest rate, its date and source, and the change from the same source', async () => {
+ const usd = await t.instrument({ symbol: 'USD', name: 'US Dollar', kind: 'currency' });
+ await t.observe(usd, { metric: 'rate', value: '1.1200', unit: 'USD', currency: 'USD', source_key: 'frankfurter-eur-rates', source_ref: 'usd-1', observed_at: t.iso(t.T0 - 2 * 86400e3), retrieved_at: t.iso(t.T0) });
+ await t.observe(usd, { metric: 'rate', value: '1.1312', unit: 'USD', currency: 'USD', source_key: 'frankfurter-eur-rates', source_ref: 'usd-2', observed_at: t.iso(t.T0 - 86400e3), retrieved_at: t.iso(t.T0) });
+ const body = (await t.get('/')).text;
+ const at = body.indexOf('id="rates"');
+ assert.ok(at > 0 && body.indexOf('<data value="1.1312">1.1312</data>', at) > at, 'the newest rate');
+ assert.ok(body.includes('+1.00%'), 'the change from the previous rate of the same source');
+ assert.ok(body.includes('Frankfurter (ECB data)'));
+ });
+
await t.close();
await t.close();
done();
done();
})();
})();
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 29, // measured 27.4 with the AI briefs section and feature (2026-10-15); 24.6 before (18.2 before the showcase, 2026-10-07)
- htmlBrotliKB: 7, // 6.2 (4.7 before the showcase)
+ htmlRawKB: 36, // ~32.5 with the curated front page (numbers, sections, six questions and their JSON-LD; 2026-10-22); 27.4 with the AI briefs (2026-10-15)
+ htmlBrotliKB: 8.5, // 7.7 with the curated front page; 6.2 before (4.7 before the showcase)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (trade.css, openvibe-shared showcase.css)
cssFiles: 2, // 2 (trade.css, openvibe-shared showcase.css)
- cssRawKB: 18.5, // 18.0 with tables scrolling sideways up to 1024 px (2026-10-17); 17.8 with the AI brief styles (2026-10-15); 16.2 before (4.7 before showcase.css)
- cssBrotliKB: 4.7, // 4.5 with the tablet table rule (2026-10-17); 3.9 (1.3 before showcase.css)
+ cssRawKB: 26, // ~23 with the front page's numbers, cards, rate cards, chips and questions (2026-10-22); 18.0 before (4.7 before showcase.css)
+ cssBrotliKB: 6, // ~5.3 with the front page (2026-10-22); 4.5 with the tablet table rule (1.3 before showcase.css)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};