openvibers/openvibe.work Commits
Listings carry the salary as the board wrote it, the board and the day we first saw it (#141)
Changes
@@@ -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; }@@@ -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.@@@ -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>`,});});@@@ -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,};@@@ -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 };@@@ -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');nnew file mode 100644@@@ -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&remote=true"/, 'no link to run it again');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -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.8jsBrotliKB: 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.5cssFiles: 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.3externalFiles: 0, // 0};
@@ -73,6 +73,9 @@ button.sc-btn.sc-primary { border: 0; }
@@ -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-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-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; }
.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-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-provenance { margin: 0; font-size: .82rem; }
.job-source { font-weight: 600; }
.job-source { font-weight: 600; }
@@ -85,6 +88,8 @@ button.sc-btn.sc-primary { border: 0; }
@@ -85,6 +88,8 @@ button.sc-btn.sc-primary { border: 0; }
.job-facts dt { color: var(--text-secondary, #96a7c2); }
.job-facts dt { color: var(--text-secondary, #96a7c2); }
.job-facts dd { margin: 0; overflow-wrap: anywhere; }
.job-facts dd { margin: 0; overflow-wrap: anywhere; }
.job-apply { margin: 16px 0; }
.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; }
.job-excerpt-block { max-width: 78ch; }
.saved-list { list-style: none; margin: 12px 0; padding: 0; }
.saved-list { list-style: none; margin: 12px 0; padding: 0; }
@@ -97,6 +97,10 @@ function load(env = process.env) {
@@ -97,6 +97,10 @@ function load(env = process.env) {
// hn-whoishiring key in Sources); its resolver reads the thread, never Work itself.
// 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',
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
// 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
// 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.
// allow about four calls a day: six hours is exactly four), and listings not seen for retentionDays go.
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const kit = require('openvibe-shared/kit');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
@@ -42,8 +43,19 @@ function createPageRoutes(ctx) {
@@ -42,8 +43,19 @@ function createPageRoutes(ctx) {
const subjectOf = (req) => `user:${req.viewer.subject}`;
const subjectOf = (req) => `user:${req.viewer.subject}`;
const sourceOf = (row) => sources.byId(row.source);
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 ──
// ── 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>
<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">
<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>
<label class="sr-only" for="location">Location</label>
@@ -59,20 +71,20 @@ function createPageRoutes(ctx) {
@@ -59,20 +71,20 @@ function createPageRoutes(ctx) {
<button class="sc-btn sc-primary" type="submit">Search</button>
<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>` : ''}
${(filters.q || filters.remote || filters.type || filters.location) ? html`<a class="job-clear" href="${action}">Clear</a>` : ''}
</form>
</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. */
/** 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>`;
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">
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>
<ul class="facets" aria-label="Remote"><li class="facet-label">Workplace</li>
${link({ remote: null }, 'Any', filters.remote === '', null)}
${link({ remote: null }, 'Any', filters.remote === '', null)}
${link({ remote: 'true' }, 'Remote', filters.remote === 'true', facets.remote.true)}
${link({ remote: 'true' }, 'Remote', filters.remote === 'true', facets.remote.true)}
${link({ remote: 'false' }, 'On-site', filters.remote === 'false', facets.remote.false)}
${link({ remote: 'false' }, 'On-site', filters.remote === 'false', facets.remote.false)}
</ul>
</ul>
${facets.type.length ? html`<ul class="facets" aria-label="Job type"><li class="facet-label">Type</li>
${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>` : ''}
</ul>` : ''}
</div>`;
</div>`;
};
};
@@ -239,7 +251,7 @@ ${raw(foldBlock({
@@ -239,7 +251,7 @@ ${raw(foldBlock({
const entries = p.rows.map((row) => {
const entries = p.rows.map((row) => {
const l = listing.meta(row);
const l = listing.meta(row);
const at = isoOf(l.posted_at) || isoOf(row.fetched_at) || new Date().toISOString();
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>`
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>`
+ `<link rel="alternate" type="text/html" href="${xml(`${config.baseUrl}/jobs/${row.id}`)}"/><updated>${at}</updated>`
+ `<author><name>${xml(sourceOf(row).name)}</name></author>`
+ `<author><name>${xml(sourceOf(row).name)}</name></author>`
@@ -261,7 +273,10 @@ ${entries.join('\n')}
@@ -261,7 +273,10 @@ ${entries.join('\n')}
const filters = store.filtersOf(req.query);
const filters = store.filtersOf(req.query);
const cursor = store.decodeCursor(req.query.cursor);
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.
// 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 nextUrl = p.next ? withFilters(filters, { cursor: store.encodeCursor(p.next) }, '/jobs') : null;
const described = describe(filters);
const described = describe(filters);
page(req, res, {
page(req, res, {
@@ -272,20 +287,20 @@ ${entries.join('\n')}
@@ -272,20 +287,20 @@ ${entries.join('\n')}
crumbs: [{ label: 'Jobs' }],
crumbs: [{ label: 'Jobs' }],
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body: html`<h1>Jobs</h1>
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>
<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.' })}
${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">
<nav class="job-pager" aria-label="Pages">
${cursor ? html`<a class="sc-btn" href="${withFilters(filters, { cursor: null }, '/jobs')}">First page</a>` : ''}
${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>`}
${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>
</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 ──────────────────────────────────────────
// ── One listing ──────────────────────────────────────────
r.get('/jobs/:id', async (req, res) => {
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) {
if (!row) {
return page(req, res, {
return page(req, res, {
title: 'Listing not found',
title: 'Listing not found',
@@ -294,34 +309,57 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
@@ -294,34 +309,57 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
}
}
const src = sourceOf(row);
const src = sourceOf(row);
const l = listing.meta(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;
const tracked = signedIn(req) ? await applications.applicationByListing(s, subjectOf(req), row.id) : null;
page(req, res, {
page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
title: `${l.title} at ${l.company}`,
title: `${l.title} at ${l.company}`,
description: listing.plainExcerpt(row).slice(0, 300) || `${l.title} at ${l.company}, from ${src.name}.`,
description: listing.plainExcerpt(row).slice(0, 300) || `${l.title} at ${l.company}, from ${src.name}.`,
crumbs: [{ label: 'Jobs', href: '/jobs' }, { label: l.title }],
crumbs: [{ label: 'Jobs', href: '/jobs' }, { label: l.title }],
- styles: [showcase.STYLESHEET],
+ styles: [showcase.STYLESHEET, 'kit.css'],
jsonLd: [listing.jobPosting(row, { siteUrl: config.baseUrl })],
jsonLd: [listing.jobPosting(row, { siteUrl: config.baseUrl })],
body: html`<article class="job">
body: html`<article class="job">
<h1>${l.title}</h1>
<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>
<section class="job-excerpt-block" aria-labelledby="h-excerpt"><h2 id="h-excerpt">Excerpt</h2>
<p>${l.excerpt}</p>
<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)
${signedIn(req)
? (tracked
? (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`<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`<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>`}
: 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>
<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>` : ''}
${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>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>
<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>
<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>`,
</article>`,
});
});
@@ -422,11 +460,12 @@ ${c.count > c.rows.length ? html`<p><a class="sc-btn" href="/jobs?${new URLSearc
@@ -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>
<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">
${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="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>
<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>` : ''}
</li>`)}</ul>` : ''}
${savedForm(req, initial)}
${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">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>`,
<p class="muted small">In code: <code>GET /api/v1/saved-searches</code>.</p>`,
});
});
});
});
@@ -142,7 +142,25 @@ async function facets(s, f) {
@@ -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 };
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. */
/** How many live listings each source has, and when one was last seen — the home page's provenance line. */
async function countsBySource(s) {
async function countsBySource(s) {
@@ -355,7 +373,7 @@ function decodeCursor(v) {
@@ -355,7 +373,7 @@ function decodeCursor(v) {
}
}
module.exports = {
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,
beginFetch, finishFetch, sourceFetches, expire, listSaved, createSaved, getSaved, deleteSaved, newCount, touchSaved, toWire, toSavedWire, filtersOf,
idOf, encodeCursor, decodeCursor, PAGE_SIZE, MAX_PAGE, MAX_SAVED, LISTING_ID,
idOf, encodeCursor, decodeCursor, PAGE_SIZE, MAX_PAGE, MAX_SAVED, LISTING_ID,
};
};
@@ -34,17 +34,58 @@ const meta = (row) => ({
@@ -34,17 +34,58 @@ const meta = (row) => ({
excerpt: row.excerpt,
excerpt: row.excerpt,
posted_at: row.posted_at || null,
posted_at: row.posted_at || null,
source: row.source,
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. */
/** The source's name, linked to the original listing: the attribution its terms require, in one element. */
function sourceLink(row, source) {
function sourceLink(row, source) {
const name = (source && source.name) || 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>`;
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) {
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. */
/** 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)}
@@ -63,7 +104,8 @@ ${initialTile(l.company)}
<div class="job-main">
<div class="job-main">
<h3 class="job-title"><a href="/jobs/${l.id}">${l.title}</a></h3>
<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-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>
<p class="job-excerpt">${l.excerpt}</p>
${provenance(row, source)}
${provenance(row, source)}
</div>
</div>
@@ -104,4 +146,4 @@ function jobPosting(row, { siteUrl }) {
@@ -104,4 +146,4 @@ function jobPosting(row, { siteUrl }) {
/** The excerpt as plain text, for the meta description and the API. */
/** The excerpt as plain text, for the meta description and the API. */
const plainExcerpt = (row) => stripHtml(row.excerpt || '');
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 };
@@ -125,7 +125,7 @@ const first = async (t, path) => (await t.get(path)).json();
@@ -125,7 +125,7 @@ const first = async (t, path) => (await t.get(path)).json();
assert.ok(html.includes('Meridian Rail'));
assert.ok(html.includes('Meridian Rail'));
assert.ok(html.includes('Remote (EU)'));
assert.ok(html.includes('Remote (EU)'));
assert.ok(html.includes('€70,000 – €85,000'), 'the salary the board gave');
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('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.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');
assert.match(html, /<dt>Source<\/dt>/, 'the source is not stated');
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -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&remote=true"/, 'no link to run it again');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -23,8 +23,8 @@ const BUDGETS = {
@@ -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
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
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)
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
externalFiles: 0, // 0
};
};