openvibers/openvibe.work Commits

Listings carry the salary as the board wrote it, the board and the day we first saw it (#141)

OP OpenVibers committed 13 days ago · 13de767392 · 8 files, +304 −28

* Listings carry the salary as the board wrote it, the board and the day we first saw it

Every card now shows the salary exactly as the board wrote it, or that the employer did not
list one, and names the board it came from with the day we first saw the listing there. The
jobs page states our own salary disclosure rate, measured from the listings we fetched, with
the sample size in configuration; a listing's own page states the board and the exact fetch
date next to the link out, says that we never host the description, and says in one line why
there is no apply button and no CV upload. A listing the board stopped showing is hidden from
the search, and its old link says it is no longer on the board and when we last saw it. A
saved search says what it looks for and when it last ran, and says that alerts are one
OpenVibe account across the network.

Checked: npm ci --include=dev, then the whole suite (27/27 files), including a new
test/outcomes.test.js with one test per outcome, and the perf budget (the CSS brotli budget
raised from 10 to 10.2 with the measured page: 45.6 KB raw / 10.1 KB brotli).

* Say each thing once: one line for the promise, one label for the job type

The list page ended with three lines that said the same thing twice; it now ends with one:
"Each listing is a short excerpt that links to its board. You apply there; we host no
descriptions, CVs or apply buttons." A listing's own page keeps the fetch date under the
button and carries one line instead of two: "Short excerpt only. You apply on RemoteOK."

The provenance line no longer repeats the board's name: "Listing from Arbeitnow · posted
10 October 2026 · first seen 31 October 2026". A saved search says in one line that its
alerts come from OpenVibe.Watch, on the same account.

The job types are shown in words through one label function — Full time, Permanent,
Contract, Part time — on the cards, in the Type facet and on a listing's page; the facet's
query value stays the raw stored one. A listing's tags are the cards' chips, not a bulleted
list.

Checked: the whole suite (27/27 files) and the perf budget (html 38.4 KB / 9.1 brotli,
css 45.6 KB / 10.1 brotli, unchanged). The shots of the changed pages are re-taken at 390
and 1280, dark and light.

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex 4bee81e..bbbee8b 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -73,6 +73,9 @@ button.sc-btn.sc-primary { border: 0; }
.chip-ok { color: var(--success-text, var(--success, #22c55e)); border-color: color-mix(in srgb, var(--success, #22c55e) 45%, transparent); }
.chip-pay { color: var(--warning-text, var(--warning, #f59e0b)); border-color: color-mix(in srgb, var(--warning, #f59e0b) 45%, transparent); }
.chip-tag { color: var(--text-muted, #7386a3); font-weight: 500; }
++.job-pay { margin: 0 0 8px; font-size: .92rem; font-weight: 600; }
++.job-pay.muted { font-weight: 500; }
++.job-gone { font-size: .95rem; }
.job-excerpt { margin: 0 0 8px; max-width: 80ch; font-size: .92rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.job-provenance { margin: 0; font-size: .82rem; }
.job-source { font-weight: 600; }
@@@ -85,6 +88,8 @@ button.sc-btn.sc-primary { border: 0; }
.job-facts dt { color: var(--text-secondary, #96a7c2); }
.job-facts dd { margin: 0; overflow-wrap: anywhere; }
.job-apply { margin: 16px 0; }
++/* The fetch date sits under the link out, not beside it, so the two read together on a phone. */
++.job-apply .muted { display: block; margin-top: 8px; }
.job-excerpt-block { max-width: 78ch; }
.saved-list { list-style: none; margin: 12px 0; padding: 0; }
ddiff --git a/server/config.js b/server/config.js
iindex 5b76e81..755c39d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -97,6 +97,10 @@ function load(env = process.env) {
// hn-whoishiring key in Sources); its resolver reads the thread, never Work itself.
hnWhoishiringFrom: String(env.WORK_HN_WHOISHIRING_FROM || '').trim().toLowerCase() === 'sources' ? 'sources' : 'board',
},
++ // The disclosure rate the jobs page shows (server/jobs/store.js): of the last N listings we fetched, how
++ // many carried a salary. N is configuration, so the number on the page is never typed in by hand.
++ disclosure: { sample: Math.max(1, int(env.WORK_DISCLOSURE_SAMPLE, 100)) },
++
// The background ingest (server/ingest/): off unless WORK_INGEST=on, so a test and a development run
// never call a job board. Once on, each source is fetched at most every intervalMs (Remotive's terms
// allow about four calls a day: six hours is exactly four), and listings not seen for retentionDays go.
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 581f869..e9a83f2 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -13,6 +13,7 @@
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
++const kit = require('openvibe-shared/kit');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
@@@ -42,8 +43,19 @@ function createPageRoutes(ctx) {
const subjectOf = (req) => `user:${req.viewer.subject}`;
const sourceOf = (row) => sources.byId(row.source);
++ /**
++ * Our own disclosure rate, measured from the listings we fetched: how many of the last N carried a salary. N
++ * comes from configuration (WORK_DISCLOSURE_SAMPLE) and both counts are the database's own, so the line on the
++ * page is never a number typed in by hand.
++ */
++ const disclosureLine = (d) => {
++ if (!d || !d.total) return 'No listing has been fetched yet, so we cannot say what share of them state a salary.';
++ const pct = Math.round((d.salaried / d.total) * 100);
++ return `Of the last ${d.total} listing${d.total === 1 ? '' : 's'} we fetched, ${d.salaried} included a salary (${pct}%).`;
++ };
++
// ── Search: one form, one set of filters, one place they are read ──
-- const filterForm = (filters, { facets = null, action = '/jobs' } = {}) => html`<form class="job-search" method="get" action="${action}" role="search">
++ const filterForm = (filters, { facets = null, disclosure = null, action = '/jobs' } = {}) => html`<form class="job-search" method="get" action="${action}" role="search">
<label class="sr-only" for="q">Search listings</label>
<input type="search" id="q" name="q" value="${filters.q}" placeholder="Job title, skill, company" maxlength="120">
<label class="sr-only" for="location">Location</label>
@@@ -59,20 +71,20 @@ function createPageRoutes(ctx) {
<button class="sc-btn sc-primary" type="submit">Search</button>
${(filters.q || filters.remote || filters.type || filters.location) ? html`<a class="job-clear" href="${action}">Clear</a>` : ''}
</form>
--${facets ? facetBar(filters, facets, action) : ''}`;
++${facets ? facetBar(filters, facets, action, disclosure) : ''}`;
/** The facets: what is actually in the result set, as links that set one filter and keep the rest. */
-- const facetBar = (filters, facets, action) => {
++ const facetBar = (filters, facets, action, disclosure = null) => {
const link = (patch, label, active, count) => html`<li${active ? raw(' class="on"') : ''}><a href="${withFilters(filters, { ...patch, cursor: null }, action)}">${label}${count == null ? '' : html` <span class="facet-n">${count}</span>`}</a></li>`;
return html`<div class="job-facets">
--<p class="facet-head"><strong>${facets.matches}</strong> listing${facets.matches === 1 ? '' : 's'} match${facets.matches === 1 ? 'es' : ''} this search</p>
++<p class="facet-head"><strong>${facets.matches}</strong> listing${facets.matches === 1 ? '' : 's'} match${facets.matches === 1 ? 'es' : ''} this search${disclosure ? html` <span class="muted">· ${disclosureLine(disclosure)}</span>` : ''}</p>
<ul class="facets" aria-label="Remote"><li class="facet-label">Workplace</li>
${link({ remote: null }, 'Any', filters.remote === '', null)}
${link({ remote: 'true' }, 'Remote', filters.remote === 'true', facets.remote.true)}
${link({ remote: 'false' }, 'On-site', filters.remote === 'false', facets.remote.false)}
</ul>
${facets.type.length ? html`<ul class="facets" aria-label="Job type"><li class="facet-label">Type</li>
--${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type, t.count))}
++${facets.type.map((t) => link({ type: t.type }, listing.jobTypeLabel(t.type), filters.type === t.type, t.count))}
</ul>` : ''}
</div>`;
};
@@@ -239,7 +251,7 @@ ${raw(foldBlock({
const entries = p.rows.map((row) => {
const l = listing.meta(row);
const at = isoOf(l.posted_at) || isoOf(row.fetched_at) || new Date().toISOString();
-- const where = [l.location, l.remote ? 'Remote' : '', l.job_type].filter(Boolean).join(' · ');
++ const where = [l.location, l.remote ? 'Remote' : '', listing.jobTypeLabel(l.job_type)].filter(Boolean).join(' · ');
return `<entry><id>${xml(`${config.baseUrl}/jobs/${row.id}`)}</id><title>${xml(`${l.title} at ${l.company}`)}</title>`
+ `<link rel="alternate" type="text/html" href="${xml(`${config.baseUrl}/jobs/${row.id}`)}"/><updated>${at}</updated>`
+ `<author><name>${xml(sourceOf(row).name)}</name></author>`
@@@ -261,7 +273,10 @@ ${entries.join('\n')}
const filters = store.filtersOf(req.query);
const cursor = store.decodeCursor(req.query.cursor);
// A cursor we did not write is not a position in the result set; start from the top rather than guess.
-- const p = await store.search(s, filters, { cursor });
++ const [p, disclosure] = await Promise.all([
++ store.search(s, filters, { cursor }),
++ store.disclosureRate(s, { sample: config.disclosure.sample }),
++ ]);
const nextUrl = p.next ? withFilters(filters, { cursor: store.encodeCursor(p.next) }, '/jobs') : null;
const described = describe(filters);
page(req, res, {
@@@ -272,20 +287,20 @@ ${entries.join('\n')}
crumbs: [{ label: 'Jobs' }],
styles: [showcase.STYLESHEET],
body: html`<h1>Jobs</h1>
--${filterForm(filters, { facets: p.facets })}
++${filterForm(filters, { facets: p.facets, disclosure })}
<p class="job-alert"><a class="sc-btn" href="${alertHref(filters)}">Get alerts for this search</a> <span class="muted small">OpenVibe.Watch follows <a href="${feedPath(filters)}">its feed</a> and tells you when a new listing appears.</span></p>
${listing.listingList(p.rows, { sourceOf, empty: 'No listings match this search. Try fewer words, or clear a filter and search again.' })}
<nav class="job-pager" aria-label="Pages">
${cursor ? html`<a class="sc-btn" href="${withFilters(filters, { cursor: null }, '/jobs')}">First page</a>` : ''}
${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</a>` : html`<span class="muted">That is every listing matching this search.</span>`}
</nav>
--<p class="muted small">Read the full listing on its source: each entry links back to the board it came from.</p>`,
++<p class="muted small">Each listing is a short excerpt that links to its board. You apply there; we host no descriptions, CVs or apply buttons.</p>`,
});
});
// ── One listing ──────────────────────────────────────────
r.get('/jobs/:id', async (req, res) => {
-- const row = await store.getListing(s, req.params.id);
++ const row = await store.getListing(s, req.params.id, { includeExpired: true });
if (!row) {
return page(req, res, {
title: 'Listing not found',
@@@ -294,34 +309,57 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
}
const src = sourceOf(row);
const l = listing.meta(row);
++ const fetchedOn = row.fetched_at ? listing.dateWords(row.fetched_at) : 'a date we did not record';
++ // A listing the board stopped showing is out of the search, but the old link still answers: it says the
++ // listing is gone and when we last saw it, rather than pretending the row never existed.
++ if (row.expired) {
++ const lastSeen = row.last_seen_at ? listing.dateWords(row.last_seen_at) : '';
++ return page(req, res, {
++ title: 'No longer on the board',
++ description: `This listing is no longer on ${src.name}. We last saw it on ${lastSeen || 'a date we did not record'}.`,
++ crumbs: [{ label: 'Jobs', href: '/jobs' }, { label: 'No longer on the board' }],
++ styles: [showcase.STYLESHEET],
++ body: html`<h1>No longer on the board</h1>
++<p class="job-gone"><span class="badge bad">Gone</span> This listing is no longer on the board.${lastSeen ? html` Last seen on ${src.name} on ${lastSeen}.` : ' We did not record the day we last saw it.'}</p>
++<p>${l.title} at ${l.company} is not in our listings any more, so it is not in the search either. The board's own listing is still <a href="${l.url}" rel="nofollow noopener external" target="_blank">here</a>, but the board may have taken it down.</p>
++<p><a class="sc-btn" href="/jobs">Search the listings</a></p>`,
++ }, 404);
++ }
const tracked = signedIn(req) ? await applications.applicationByListing(s, subjectOf(req), row.id) : null;
page(req, res, {
index: true, cache: PUBLIC_CACHE,
title: `${l.title} at ${l.company}`,
description: listing.plainExcerpt(row).slice(0, 300) || `${l.title} at ${l.company}, from ${src.name}.`,
crumbs: [{ label: 'Jobs', href: '/jobs' }, { label: l.title }],
-- styles: [showcase.STYLESHEET],
++ styles: [showcase.STYLESHEET, 'kit.css'],
jsonLd: [listing.jobPosting(row, { siteUrl: config.baseUrl })],
body: html`<article class="job">
<h1>${l.title}</h1>
--<p class="job-meta"><span class="job-company">${companies.slugOf(row.company) ? html`<a href="/companies/${companies.slugOf(row.company)}">${l.company}</a>` : l.company}</span> · <span class="job-location">${l.location || 'Location not given'}</span>${l.remote ? ' · Remote' : ''}${l.job_type ? html` · <span class="job-type">${l.job_type}</span>` : ''}</p>
--${l.salary ? html`<p class="job-salary"><strong>Salary:</strong> ${l.salary} <span class="muted small">(as the board gives it)</span></p>` : ''}
--${l.tags.length ? html`<ul class="job-tags">${l.tags.map((t) => html`<li>${t}</li>`)}</ul>` : ''}
++<p class="job-meta"><span class="job-company">${companies.slugOf(row.company) ? html`<a href="/companies/${companies.slugOf(row.company)}">${l.company}</a>` : l.company}</span> · <span class="job-location">${l.location || 'Location not given'}</span>${l.remote ? ' · Remote' : ''}${l.job_type ? html` · <span class="job-type">${listing.jobTypeLabel(l.job_type)}</span>` : ''}</p>
++${listing.salaryLine(row, { note: true })}
++${l.tags.length ? html`<p class="job-chips">${l.tags.map((t) => html`<span class="chip chip-tag">${t}</span>`)}</p>` : ''}
<section class="job-excerpt-block" aria-labelledby="h-excerpt"><h2 id="h-excerpt">Excerpt</h2>
<p>${l.excerpt}</p>
--<p class="muted small">This is a short excerpt only. The full listing stays on the board.</p></section>
--<p class="job-apply"><a class="sc-btn sc-primary" href="${l.url}" rel="nofollow noopener external" target="_blank">Read the full listing on ${src.name}</a></p>
++<p class="muted small">Short excerpt only. You apply on ${src.name}.</p></section>
++<p class="job-apply"><a class="sc-btn sc-primary" href="${l.url}" rel="nofollow noopener external" target="_blank">Read the full listing on ${src.name}</a> <span class="muted small">Read from ${src.name} on ${fetchedOn}</span></p>
${signedIn(req)
? (tracked
? html`<p class="job-track"><span class="badge ok">Tracked: ${STATUS_LABEL[tracked.status] || tracked.status}</span> <a href="/applications/${tracked.id}">Open your application</a></p>`
: html`<form class="inline" method="post" action="/jobs/${row.id}/track"><button class="sc-btn" type="submit">Track this job</button></form>`)
: html`<p class="muted small"><a rel="nofollow" href="/auth/login?next=%2Fjobs%2F${row.id}">Sign in</a> to track this job and ask your agent for help applying.</p>`}
--<dl class="job-facts">
++${raw(kit.fold({
++ id: 'job-provenance',
++ title: 'Where this listing came from',
++ meta: src.name,
++ body: String(html`<dl class="job-facts">
<dt>Source</dt><dd>${sourceLine(row)} — <a href="/sources#${src.id}">its terms as applied</a></dd>
${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
<dt>Read from the board</dt><dd>${time(row.fetched_at)}</dd>
++<dt>First seen</dt><dd>${time(row.first_seen_at)}</dd>
++<dt>Last seen</dt><dd>${time(row.last_seen_at)}</dd>
<dt>Board's id</dt><dd><code>${row.source_id}</code></dd>
--</dl>
++</dl>`),
++ }))}
<p class="muted small">Found a problem with this listing, or want it gone? It is the board's listing, not ours — the source above is where it lives. In code: <code>GET /api/v1/jobs/${row.id}</code>.</p>
</article>`,
});
@@@ -422,11 +460,12 @@ ${c.count > c.rows.length ? html`<p><a class="sc-btn" href="/jobs?${new URLSearc
<p>${rows.length ? html`You are keeping ${rows.length} search${rows.length === 1 ? '' : 'es'}. The count is how many listings appeared since you last opened this page.` : 'You are not keeping any search yet.'}</p>
${withCounts.length ? html`<ul class="saved-list">${withCounts.map(({ row, count }) => html`<li class="saved-item">
<p class="saved-query"><a href="${store.toSavedWire(row).search_path}">${describeSaved(row)}</a> <span class="badge${count ? ' ok' : ''}">${count ? `${count} new` : 'nothing new'}</span></p>
--<p class="muted small">Saved ${time(row.created_at)} · last looked ${row.last_seen_at ? time(row.last_seen_at) : 'never'}</p>
++<p class="muted small">Saved ${time(row.created_at)} · last ran ${row.last_seen_at ? time(row.last_seen_at) : 'never'}</p>
<div class="saved-actions"><a class="sc-btn" href="${alertHref(store.filtersOf({ q: row.query, remote: row.remote, type: row.type, location: row.location }))}">Get alerts</a> <form method="post" action="/saved/${row.id}/delete" class="inline"><button class="sc-btn" type="submit">Forget it</button></form></div>
</li>`)}</ul>` : ''}
${savedForm(req, initial)}
<p class="muted small">Get alerts opens OpenVibe.Watch with the search's feed filled in: Watch checks it and tells you (or runs your agent) when a listing that fits appears. Every search has its feed at <code>/jobs.atom</code> with the same filters.</p>
++<p class="muted small">Alerts for a search come from OpenVibe.Watch, on the same account.</p>
<p class="muted small">In code: <code>GET /api/v1/saved-searches</code>.</p>`,
});
});
ddiff --git a/server/jobs/store.js b/server/jobs/store.js
iindex 35728e5..a4ce3b7 100644
---- a/server/jobs/store.js
++++ b/server/jobs/store.js
@@@ -142,7 +142,25 @@ async function facets(s, f) {
return { remote, type: typeRows.map((r) => ({ type: r.t, count: num(r.n) })), scope: remote.true + remote.false };
}
--const getListing = (s, id) => (LISTING_ID.test(String(id)) ? s.db.maybe('SELECT * FROM work_listings WHERE id = $1 AND expired = false', [id]) : Promise.resolve(null));
++/**
++ * One listing. A live one by default; with `includeExpired` the row is read even when the board stopped showing it,
++ * so the page can say that it is gone and when we last saw it, instead of pretending the row never existed.
++ */
++const getListing = (s, id, { includeExpired = false } = {}) => (LISTING_ID.test(String(id))
++ ? s.db.maybe(`SELECT * FROM work_listings WHERE id = $1${includeExpired ? '' : ' AND expired = false'}`, [id])
++ : Promise.resolve(null));
++
++/**
++ * Our own disclosure rate, measured from the rows we fetched: of the last `sample` listings, how many carried a
++ * salary. The sample size is configuration (WORK_DISCLOSURE_SAMPLE); both counts are the database's own.
++ */
++async function disclosureRate(s, { sample = 100 } = {}) {
++ const n = Math.min(1000, Math.max(1, Number(sample) || 100));
++ const row = await s.db.maybe(
++ `SELECT count(*) AS n, count(*) FILTER (WHERE coalesce(salary, '') <> '') AS salaried
++ FROM (SELECT salary FROM work_listings ORDER BY fetched_at DESC, id DESC LIMIT $1) t`, [n]);
++ return { sample: n, total: num(row && row.n), salaried: num(row && row.salaried) };
++}
/** How many live listings each source has, and when one was last seen — the home page's provenance line. */
async function countsBySource(s) {
@@@ -355,7 +373,7 @@ function decodeCursor(v) {
}
module.exports = {
-- search, facets, getListing, countsBySource, listRecent, listSalaried, homeSummary, recentIds, upsertListing, upsertListings, expireListing, liveCount,
++ search, facets, getListing, disclosureRate, countsBySource, listRecent, listSalaried, homeSummary, recentIds, upsertListing, upsertListings, expireListing, liveCount,
beginFetch, finishFetch, sourceFetches, expire, listSaved, createSaved, getSaved, deleteSaved, newCount, touchSaved, toWire, toSavedWire, filtersOf,
idOf, encodeCursor, decodeCursor, PAGE_SIZE, MAX_PAGE, MAX_SAVED, LISTING_ID,
};
ddiff --git a/server/render/listing.js b/server/render/listing.js
iindex 3c32526..29e246a 100644
---- a/server/render/listing.js
++++ b/server/render/listing.js
@@@ -34,17 +34,58 @@ const meta = (row) => ({
excerpt: row.excerpt,
posted_at: row.posted_at || null,
source: row.source,
++ source_id: row.source_id || '',
++ fetched_at: row.fetched_at || null,
++ first_seen_at: row.first_seen_at || null,
++ last_seen_at: row.last_seen_at || null,
++ expired: !!row.expired,
});
++/**
++ * The salary, in the board's own words, or the plain truth that the board gave none: never a guess, never a range we
++ * worked out ourselves. The same line is on the card and on the listing's own page, so they cannot drift apart.
++ */
++function salaryLine(row, { note = false } = {}) {
++ const salary = String((row && row.salary) || '').trim();
++ if (!salary) return html`<p class="job-pay muted">Salary not listed by the employer</p>`;
++ return html`<p class="job-pay">Salary: ${salary}${note ? html` <span class="muted small">(as the board gives it)</span>` : ''}</p>`;
++}
++
++/**
++ * A job type in the words people use: the boards write full_time and permanent, and a card does not show a
++ * machine's word for a job type. The stored value stays as it is — only what we show changes.
++ */
++const JOB_TYPE_LABELS = {
++ full_time: 'Full time',
++ part_time: 'Part time',
++ permanent: 'Permanent',
++ contract: 'Contract',
++ freelance: 'Freelance',
++ internship: 'Internship',
++ temporary: 'Temporary',
++};
++function jobTypeLabel(value) {
++ return String(value || '')
++ .split(',')
++ .map((t) => t.trim())
++ .filter(Boolean)
++ .map((t) => JOB_TYPE_LABELS[t.toLowerCase()] || t.toLowerCase().replace(/_/g, ' ').replace(/^\S/, (c) => c.toUpperCase()))
++ .join(', ');
++}
++
/** The source's name, linked to the original listing: the attribution its terms require, in one element. */
function sourceLink(row, source) {
const name = (source && source.name) || row.source;
return html`<a class="job-source" href="${row.url}" rel="nofollow noopener external" target="_blank">${name}</a>`;
}
--/** The one-line provenance: where it came from, linked back, and when we read that. */
++/**
++ * The one-line provenance: where it came from, linked back, when the board posted it, and when we first saw it — the
++ * board named, so a reader can tell one board's freshness from another's.
++ */
function provenance(row, source) {
-- return html`<p class="job-provenance"><span class="muted">Listing from</span> ${sourceLink(row, source)}${row.posted_at ? html` <span class="muted">· posted ${dateWords(row.posted_at)}</span>` : ''}</p>`;
++ const firstSeen = row.first_seen_at ? dateWords(row.first_seen_at) : '';
++ return html`<p class="job-provenance"><span class="muted">Listing from</span> ${sourceLink(row, source)}${row.posted_at ? html` <span class="muted">· posted ${dateWords(row.posted_at)}</span>` : ''}${firstSeen ? html` <span class="muted">· first seen ${firstSeen}</span>` : ''}</p>`;
}
/** A company's initial on a tint picked from its name: the same company always gets the same tile. */
@@@ -63,7 +104,8 @@ ${initialTile(l.company)}
<div class="job-main">
<h3 class="job-title"><a href="/jobs/${l.id}">${l.title}</a></h3>
<p class="job-meta"><span class="job-company">${l.company}</span><span class="job-location">${l.location || 'Location not given'}</span></p>
--<p class="job-chips">${l.remote ? html`<span class="chip chip-ok">Remote</span>` : ''}${l.job_type ? html`<span class="chip">${l.job_type}</span>` : ''}${l.salary ? html`<span class="chip chip-pay">${l.salary}</span>` : ''}${l.tags.slice(0, 3).map((t) => html`<span class="chip chip-tag">${t}</span>`)}</p>
++<p class="job-chips">${l.remote ? html`<span class="chip chip-ok">Remote</span>` : ''}${l.job_type ? html`<span class="chip">${jobTypeLabel(l.job_type)}</span>` : ''}${l.tags.slice(0, 3).map((t) => html`<span class="chip chip-tag">${t}</span>`)}</p>
++${salaryLine(row)}
<p class="job-excerpt">${l.excerpt}</p>
${provenance(row, source)}
</div>
@@@ -104,4 +146,4 @@ function jobPosting(row, { siteUrl }) {
/** The excerpt as plain text, for the meta description and the API. */
const plainExcerpt = (row) => stripHtml(row.excerpt || '');
--module.exports = { listingItem, listingList, sourceLink, provenance, jobPosting, dateWords, plainExcerpt, meta };
++module.exports = { listingItem, listingList, sourceLink, provenance, jobPosting, dateWords, plainExcerpt, meta, salaryLine, jobTypeLabel };
ddiff --git a/test/jobs.test.js b/test/jobs.test.js
iindex bad5d0a..02dbdc7 100644
---- a/test/jobs.test.js
++++ b/test/jobs.test.js
@@@ -125,7 +125,7 @@ const first = async (t, path) => (await t.get(path)).json();
assert.ok(html.includes('Meridian Rail'));
assert.ok(html.includes('Remote (EU)'));
assert.ok(html.includes('€70,000 – €85,000'), 'the salary the board gave');
-- assert.ok(html.includes('<li>kubernetes</li>'), 'the tags');
++ assert.ok(html.includes('<span class="chip chip-tag">kubernetes</span>'), 'the tags');
assert.ok(html.includes('Read the full listing on Remotive'), 'no apply link');
assert.ok(html.includes(`href="${job.url}"`), 'the apply link must be the original URL');
assert.match(html, /<dt>Source<\/dt>/, 'the source is not stated');
ddiff --git a/test/outcomes.test.js b/test/outcomes.test.js
nnew file mode 100644
iindex 0000000..54ddcfa
---- /dev/null
++++ b/test/outcomes.test.js
@@@ -0,0 +1,168 @@
++'use strict';
++/**
++ * The outcomes the market research asks of us (research-2026-10-30/market/outcomes/work.md), one test each:
++ * 1. the salary exactly as the board wrote it, or that it wrote none — never a guess;
++ * 2. the board named, linked, and when we first saw the listing there;
++ * 3. a listing the board stopped showing is hidden, and says so when the old link is followed;
++ * 4. our own salary disclosure rate, measured from the listings we fetched;
++ * 5. the source board and the exact fetch date next to the link out;
++ * 6. we never host the description: a short excerpt and a link out;
++ * 7. a saved search says what it looks for and when it last ran; alerts are one account across the network;
++ * 8. no apply button and no CV upload, and the one line why.
++ *
++ * Every number the pages show is checked against the database or against configuration, never against a literal
++ * written here.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
++/** The date as the pages write it: the day, the month and the year, in UTC. */
++const dateWords = (iso) => {
++ const d = new Date(iso);
++ assert.ok(!Number.isNaN(d.getTime()), `not a date: ${iso}`);
++ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
++};
++/** The card of one listing, as it stands on the list page. */
++const cardOf = (html, id) => {
++ const at = html.indexOf(`id="${id}"`);
++ assert.ok(at > 0, `the card of ${id} is not on the page`);
++ const end = html.indexOf('</li>', at);
++ return html.slice(at, end);
++};
++
++(async () => {
++ const t = await boot({ env: { WORK_INGEST_PAGES: '2', WORK_DISCLOSURE_SAMPLE: '100' } });
++ const kim = t.network.addUser('kim');
++ const SAME = { 'sec-fetch-site': 'same-origin' };
++ try {
++ await t.ctx.ingest.runAll();
++
++ await check('outcome 1: the salary as the board wrote it, or that the board wrote none', async () => {
++ const jobs = (await t.get('/api/v1/jobs?limit=100')).json().jobs;
++ const paid = jobs.find((j) => j.salary === '€70,000 – €85,000');
++ const unpaid = jobs.find((j) => !j.salary);
++ assert.ok(paid && unpaid, 'the fixtures need one listing with a salary and one without');
++ const list = (await t.get('/jobs')).text;
++ // The board's own words, character for character.
++ assert.ok(cardOf(list, paid.id).includes(`Salary: ${paid.salary}`), 'the card does not carry the board\'s salary');
++ // And where there is none, the card says so — it does not leave the line out, and it does not guess.
++ const none = cardOf(list, unpaid.id);
++ assert.ok(none.includes('Salary not listed by the employer'), 'the card does not say the salary is not listed');
++ assert.ok(!/Salary:\s*€|\$\s?\d/.test(none), 'a salary was invented');
++ // The listing's own page says the same thing.
++ const withPay = (await t.get(`/jobs/${paid.id}`)).text;
++ assert.ok(withPay.includes(`Salary: ${paid.salary}`), 'the page does not carry the board\'s salary');
++ assert.ok(withPay.includes('as the board gives it'));
++ const withoutPay = (await t.get(`/jobs/${unpaid.id}`)).text;
++ assert.ok(withoutPay.includes('Salary not listed by the employer'), 'the page does not say the salary is not listed');
++ });
++
++ await check('outcome 2: the card names the board, links to it, and says when we first saw it there', async () => {
++ const job = (await t.get('/api/v1/jobs?q=Platform%20Engineer')).json().jobs[0];
++ const row = await t.ctx.s.db.one('SELECT * FROM work_listings WHERE id = $1', [job.id]);
++ const card = cardOf((await t.get('/jobs')).text, job.id);
++ assert.ok(card.includes('Listing from'), 'the card does not say where the listing came from');
++ assert.ok(card.includes(`href="${job.url}"`), 'the card does not link the board\'s own listing');
++ assert.ok(card.includes(`first seen ${dateWords(row.first_seen_at)}`), 'the card does not say when we first saw it');
++ assert.ok(card.includes('rel="nofollow noopener external"'), 'the link out must stay external and nofollow');
++ });
++
++ await check('outcome 4: the jobs page shows our own disclosure rate, measured from what we fetched', async () => {
++ const page = (await t.get('/jobs')).text;
++ const expected = await t.ctx.s.db.one(
++ `SELECT count(*) AS n, count(*) FILTER (WHERE coalesce(salary, '') <> '') AS salaried
++ FROM (SELECT salary FROM work_listings ORDER BY fetched_at DESC, id DESC LIMIT $1) t`, [100]);
++ const pct = Math.round((expected.salaried / expected.n) * 100);
++ assert.ok(page.includes(`Of the last ${expected.n} listings we fetched, ${expected.salaried} included a salary (${pct}%)`),
++ 'the disclosure rate is not the one the database gives');
++ // The sample size is configuration, not a literal on the page: with a smaller sample the line follows.
++ const small = await boot({ env: { WORK_INGEST_PAGES: '2', WORK_DISCLOSURE_SAMPLE: '3' } });
++ try {
++ await small.ctx.ingest.runAll();
++ const want = await small.ctx.s.db.one(
++ `SELECT count(*) AS n, count(*) FILTER (WHERE coalesce(salary, '') <> '') AS salaried
++ FROM (SELECT salary FROM work_listings ORDER BY fetched_at DESC, id DESC LIMIT $1) t`, [3]);
++ const text = (await small.get('/jobs')).text;
++ assert.ok(text.includes(`Of the last ${want.n} listings we fetched, ${want.salaried} included a salary`),
++ 'the sample size did not come from the configuration');
++ } finally { await small.close(); }
++ });
++
++ await check('outcome 5: the listing page names the board and the exact fetch date next to the link out', async () => {
++ const job = (await t.get('/api/v1/jobs?q=Platform%20Engineer')).json().jobs[0];
++ const row = await t.ctx.s.db.one('SELECT * FROM work_listings WHERE id = $1', [job.id]);
++ const page = (await t.get(`/jobs/${job.id}`)).text;
++ assert.ok(page.includes('Read the full listing on Remotive'), 'the link out');
++ assert.ok(page.includes(`Read from Remotive on ${dateWords(row.fetched_at)}`), 'the fetch date next to the link out');
++ // The two sit together, in one line, so the reader sees them at once.
++ assert.match(page, /Read the full listing on Remotive<\/a>[^<]*<span class="muted small">Read from Remotive on /, 'the date is not next to the link');
++ });
++
++ await check('outcome 6: we never host the description, and the page says so', async () => {
++ const job = (await t.get('/api/v1/jobs?q=Platform%20Engineer')).json().jobs[0];
++ const page = (await t.get(`/jobs/${job.id}`)).text;
++ assert.ok(page.includes('Short excerpt only. You apply on Remotive.'), 'the promise is not a visible line');
++ assert.ok(page.includes('You will run the platform other engineers build on'), 'the excerpt is missing');
++ assert.ok(!page.includes('Waffle'), 'the full description was republished');
++ const list = (await t.get('/jobs')).text;
++ assert.ok(list.includes('Each listing is a short excerpt that links to its board.'), 'the list page does not say it either');
++ });
++
++ await check('outcome 8: no apply button and no CV upload, with the one line why', async () => {
++ const job = (await t.get('/api/v1/jobs?q=Platform%20Engineer')).json().jobs[0];
++ const page = (await t.get(`/jobs/${job.id}`)).text;
++ assert.ok(page.includes('Short excerpt only. You apply on Remotive.'), 'the page does not say where you apply');
++ assert.ok(!/<input[^>]*type="file"/.test(page), 'there is a file input on the page');
++ assert.ok(!/<button[^>]*>\s*Apply/i.test(page), 'there is an apply button on the page');
++ const list = (await t.get('/jobs')).text;
++ assert.ok(list.includes('we host no descriptions, CVs or apply buttons'), 'the list page does not say it either');
++ });
++
++ await check('the job types are in words on the cards, in the facet and on the page, and the filter keeps the raw value', async () => {
++ const jobs = (await t.get('/api/v1/jobs?limit=100')).json().jobs;
++ const one = jobs.find((j) => j.job_type === 'full_time');
++ const two = jobs.find((j) => j.job_type === 'permanent, full_time');
++ assert.ok(one && two, 'the fixtures need a listing of each kind');
++ const page = (await t.get('/jobs')).text;
++ assert.ok(cardOf(page, one.id).includes('<span class="chip">Full time</span>'), 'the card does not say the job type in words');
++ assert.ok(cardOf(page, two.id).includes('<span class="chip">Permanent, Full time</span>'), 'the card does not say the job type in words');
++ assert.ok(!page.includes('permanent, full_time'), 'a raw job type is shown');
++ assert.match(page, />Full time <span class="facet-n">/, 'the facet is not in words');
++ assert.ok(page.includes('href="/jobs?type=full_time"'), 'the facet does not keep the raw query value');
++ // The listing's own page says it in words too, and its tags are the cards' chips.
++ const detail = (await t.get(`/jobs/${one.id}`)).text;
++ assert.ok(detail.includes('<span class="job-type">Full time</span>'), 'the page does not say the job type in words');
++ assert.ok(detail.includes('<span class="chip chip-tag">'), 'the tags are not chips');
++ });
++
++ await check('outcome 3: a listing the board stopped showing is hidden, and says so when the link is followed', async () => {
++ const job = (await t.get('/api/v1/jobs?q=Platform%20Engineer')).json().jobs[0];
++ const row = await t.ctx.s.db.one('SELECT * FROM work_listings WHERE id = $1', [job.id]);
++ await t.ctx.s.db.exec('UPDATE work_listings SET expired = true WHERE id = $1', [job.id]);
++ // Hidden: out of the search and out of the list.
++ assert.ok(!(await t.get('/jobs')).text.includes(`id="${job.id}"`), 'the expired listing is still listed');
++ assert.strictEqual((await t.get(`/api/v1/jobs/${job.id}`)).status, 404, 'the API still offers it');
++ // And the old link answers with the truth: gone, and the day we last saw it.
++ const page = await t.get(`/jobs/${job.id}`);
++ assert.strictEqual(page.status, 404, 'a listing that is gone must not answer 200');
++ assert.ok(page.text.includes('No longer on the board'), 'the page does not say the listing is gone');
++ assert.ok(page.text.includes(`Last seen on Remotive on ${dateWords(row.last_seen_at)}`), 'the page does not say when we last saw it');
++ assert.ok(page.text.includes(job.url), 'the board\'s own listing is still linked');
++ });
++
++ await check('outcome 7: a saved search says what it looks for and when it last ran, and its alerts come from Watch', async () => {
++ const saved = await t.get('/saved', { as: kim, form: { q: 'engineer', remote: 'true' }, headers: SAME });
++ assert.strictEqual(saved.status, 303);
++ const page = (await t.get('/saved', { as: kim })).text;
++ assert.ok(page.includes('“engineer”'), 'the saved search does not say what it looks for');
++ assert.ok(page.includes('remote'), 'the saved search does not say what it looks for');
++ assert.match(page, /last ran <time datetime="[^"]+">/, 'the saved search does not say when it last ran');
++ assert.ok(page.includes('Alerts for a search come from OpenVibe.Watch, on the same account.'), 'the alerts are not said to come from Watch on the same account');
++ assert.ok(page.includes('openvibe.watch'), 'there is no link to Watch');
++ // And the search it keeps is the search it runs.
++ assert.match(page, /href="\/jobs\?q=engineer&amp;remote=true"/, 'no link to run it again');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex c537c91..f844bc3 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -23,8 +23,8 @@ const BUDGETS = {
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
cssFiles: 3, // 3 (app.css, the cached /shared/showcase.css and css/home.css, the curated home's counts, fields, cities, companies and questions, 2026-10-22)
-- cssRawKB: 46, // 45.3 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 41.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 39.5 the home's phone rows, limits and fold (2026-10-29); 34.8 openvibe-shared 4.26.1 (hero visual); 29.3 with Shared 4.13.0 (2026-10-26: account links); 23.1 with css/home.css (2026-10-22); 15.0 before the listings; 18.2 after (the search form, the listing list, the source blocks)
-- cssBrotliKB: 10, // 10.0 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 9.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.6 the home's phone rows, limits and fold (2026-10-29); 6.6–6.7 with Shared 4.13.0; raised from 8 for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 8.3
++ cssRawKB: 46, // 45.6 with the listings' salary line and the fetch date under the link out (2026-10-31); 45.3 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 41.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 39.5 the home's phone rows, limits and fold (2026-10-29); 34.8 openvibe-shared 4.26.1 (hero visual); 29.3 with Shared 4.13.0 (2026-10-26: account links); 23.1 with css/home.css (2026-10-22); 15.0 before the listings; 18.2 after (the search form, the listing list, the source blocks)
++ cssBrotliKB: 10.2, // raised from 10 on 2026-10-31, with the measured page: 45.6 KB raw / 10.1 KB brotli (the listings' salary line, the gone line and the fetch date under the link out); 10.0 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 9.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 8.6 the home's phone rows, limits and fold (2026-10-29); 6.6–6.7 with Shared 4.13.0; raised from 8 for openvibe-shared v4.39.0 (+1.5 KB raw of kit/room/showcase CSS); measured 8.3
externalFiles: 0, // 0
};