openvibers/openvibe.work Commits

Work home shows the board at a glance: open listings, new today and this week, companies hiring, the fields and cities most listings name, the companies with the most openings, the newest jobs that state the pay, and the questions people ask (#60)

OP OpenVibers committed 22 days ago · 78c926c635 · 6 files, +135 −17

Changes

ddiff --git a/public/css/home.css b/public/css/home.css
nnew file mode 100644
iindex 0000000..b6d35d7
---- /dev/null
++++ b/public/css/home.css
@@@ -0,0 +1,27 @@
++/* OpenVibe.Work's home page only (server/http/pages.js): counts, fields, places, companies and questions. Theme tokens
++ only, so light and dark both read; every grid fits a 320px phone. */
++.home-stats { list-style: none; padding: 0; margin: 32px 0 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); }
++.home-stats li { border-radius: 14px; border: 1px solid var(--border, #1f2d47); border-top: 3px solid var(--accent, #3b82f6); background: var(--bg-secondary, #0f1729); }
++.home-stats a { display: block; height: 100%; box-sizing: border-box; padding: 14px 16px; color: inherit; text-decoration: none; border-radius: 14px; }
++.home-stats a:hover, .home-stats a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
++.home-stats b { display: block; font-size: 1.6rem; line-height: 1.1; font-weight: 800; font-variant-numeric: tabular-nums; }
++.home-stats span { display: block; margin-top: 4px; font-size: .86rem; color: var(--text-secondary, #96a7c2); }
++
++.home-cards { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }
++.home-cards a { display: flex; flex-direction: column; gap: 3px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color .15s ease, transform .15s ease; }
++.home-cards a:hover, .home-cards a:focus-visible { border-color: var(--accent, #3b82f6); transform: translateY(-1px); outline: 0; }
++.home-cards b { font-size: 1rem; overflow-wrap: anywhere; }
++.home-cards span { font-size: .85rem; color: var(--text-secondary, #96a7c2); }
++
++.home-chips { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; }
++.home-chips a { display: inline-flex; align-items: baseline; gap: 6px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; }
++.home-chips a:hover, .home-chips a:focus-visible { border-color: var(--accent, #3b82f6); }
++.home-chips span { font-size: .78rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
++
++.home-faq { display: grid; gap: 8px; max-width: 820px; }
++.home-faq details { border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.home-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 650; }
++.home-faq summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 12px; }
++.home-faq details p { margin: 0; padding: 0 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
++@media (max-width: 520px) { .home-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .home-cards a { padding: 10px 12px; } }
++@media (prefers-reduced-motion: reduce) { .home-cards a { transition: none; } .home-cards a:hover { transform: none; } }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex bae15d4..1f20ecd 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -22,6 +22,8 @@ const listing = require('../render/listing');
const store = require('../jobs/store');
const sources = require('../jobs/sources');
const applications = require('../applications');
++const seo = require('openvibe-shared/seo');
++const { products } = require('openvibe-contracts');
const SITE_NAME = 'OpenVibe.Work';
const TAGLINE = 'Find work on open job boards.';
@@@ -106,37 +108,70 @@ ${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type,
};
// ── Home ─────────────────────────────────────────────────
++ // Everything here is read from the live listings: the counts, the tags and places most listings carry, the
++ // companies with the most openings, the newest remote jobs and the newest that state the pay. A block with nothing
++ // to show is left out.
++ const FAQ = ((products.get('openvibe.work') || {}).faq || []);
++ const n = (v) => Number(v || 0).toLocaleString('en-US');
++ const sentence = (t) => (t ? t[0].toUpperCase() + t.slice(1) : t);
r.get('/', async (req, res) => {
-- const [latest, counts, rows] = await Promise.all([store.listRecent(s, 6, { remote: true }), store.countsBySource(s), store.search(s, {}, { limit: 1 })]);
-- const total = rows.facets.matches;
++ const [latest, counts, sum, salaried] = await Promise.all([store.listRecent(s, 6, { remote: true }), store.countsBySource(s), store.homeSummary(s), store.listSalaried(s, 4)]);
const fetches = await store.sourceFetches(s);
++ const total = sum.total;
++ const base = String(config.baseUrl).replace(/\/+$/, '');
const hero = showcase.hero({
-- eyebrow: `${SITE_NAME} · job listings with their provenance`,
++ eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: 'Find work.',
accent: 'Every listing shows where it came from.',
-- lede: `${SITE_NAME} gathers job listings from public job boards, keeps the board's own link on every one, and lets you search them. No paywall, no account needed to look. Sign in to keep searches, track the jobs you apply to, and have your OpenVibe.Actor agent help prepare each application.`,
-- actions: [{ label: 'Search the listings', href: '#search', primary: true }, { label: 'Where they come from', href: '/sources' }],
++ lede: `${SITE_NAME} gathers job listings from public job boards, keeps the board's own link on every one, and lets you search them. No paywall, no account needed to look. Sign in to keep searches, get alerts, track the jobs you apply to, and have your OpenVibe.Actor agent help prepare each application.`,
++ actions: [{ label: 'Search the listings', href: '#search', primary: true }, { label: 'Remote jobs', href: '/jobs?remote=true' }, { label: 'Where they come from', href: '/sources' }],
note: 'Open source (AGPL-3.0). Listings are shown with the source named and a link to the original — read the full listing there.',
});
++ const stats = [
++ { value: total, label: total === 1 ? 'open listing' : 'open listings', href: '/jobs' },
++ { value: sum.day, label: 'new in the last day', href: '/jobs' },
++ { value: sum.week, label: 'new this week', href: '/jobs' },
++ { value: sum.companies, label: 'companies hiring', href: '#h-hiring' },
++ { value: sum.remote, label: 'remote listings', href: '/jobs?remote=true' },
++ { value: sources.SOURCES.length, label: 'job boards read', href: '/sources' },
++ ].filter((x) => x.value);
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
-- description: 'Jobs from open job boards, each saying where it came from and when, with saved searches, alerts through OpenVibe.Watch and an application tracker.',
-- jsonLd: homeJsonLd(config),
-- styles: [showcase.STYLESHEET],
++ title: 'Jobs from open job boards, with sources',
++ description: `Search ${total ? `${n(total)} ` : ''}jobs from open job boards, each with its source and date, plus saved searches, alerts through OpenVibe.Watch and an application tracker.`,
++ jsonLd: [
++ ...homeJsonLd(config),
++ latest.length ? seo.jsonLd.itemList('Latest remote jobs on OpenVibe.Work', latest.map((row) => ({ name: `${row.title} at ${row.company}`, url: `${base}/jobs/${row.id}` })), { url: `${base}/jobs?remote=true` }) : null,
++ FAQ.length ? seo.jsonLd.faq(FAQ.map((f) => ({ q: f.question, a: f.answer }))) : null,
++ ],
++ styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],
body: html`${raw(hero)}
++${stats.length ? html`<ul class="home-stats" aria-label="${SITE_NAME} in numbers">${stats.map((x) => html`<li><a href="${x.href}"><b>${n(x.value)}</b><span>${x.label}</span></a></li>`)}</ul>` : ''}
<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2>
${filterForm({ q: '', remote: '', type: '', location: '' }, { action: '/jobs' })}
--<p class="muted">${total} listing${total === 1 ? '' : 's'} from ${sources.SOURCES.length} sources, newest first.</p></section>
++<p class="muted">${n(total)} listing${total === 1 ? '' : 's'} from ${sources.SOURCES.length} job boards, newest first.${sum.day ? ` ${n(sum.day)} arrived in the last day.` : ''}</p></section>
++${sum.tags.length ? html`<section class="sc-sec" aria-labelledby="h-fields"><h2 id="h-fields">Browse by field</h2>
++<p class="sc-lede">The labels the boards put on most listings right now. Each opens a search for it.</p>
++<ul class="home-cards">${sum.tags.map((t) => html`<li><a href="/jobs?${new URLSearchParams({ q: t.tag })}"><b>${sentence(t.tag)}</b><span>${n(t.count)} listing${t.count === 1 ? '' : 's'}</span></a></li>`)}</ul></section>` : ''}
++${sum.places.length ? html`<section class="sc-sec" aria-labelledby="h-places"><h2 id="h-places">Jobs by city</h2>
++<p class="sc-lede">The places most listings name. Remote jobs are <a href="/jobs?remote=true">their own search</a>.</p>
++<ul class="home-chips">${sum.places.map((p) => html`<li><a href="/jobs?${new URLSearchParams({ location: p.place })}">${p.place} <span>${n(p.count)}</span></a></li>`)}</ul></section>` : ''}
<section class="sc-sec" aria-labelledby="h-latest"><h2 id="h-latest">Latest remote jobs</h2>
${listing.listingList(latest, { sourceOf, empty: 'No listings yet — the ingest has not run.' })}
<p><a class="sc-btn" href="/jobs?remote=true">All remote listings</a></p></section>
++${salaried.length ? html`<section class="sc-sec" aria-labelledby="h-pay"><h2 id="h-pay">Newest jobs that state the pay</h2>
++<p class="sc-lede">${n(sum.salaried)} listing${sum.salaried === 1 ? '' : 's'} say what the job pays, in the board's own words.</p>
++${listing.listingList(salaried, { sourceOf, empty: '' })}</section>` : ''}
++${sum.hiring.length ? html`<section class="sc-sec" aria-labelledby="h-hiring"><h2 id="h-hiring">Companies with the most openings</h2>
++<p class="sc-lede">Counted across every board. Each opens a search for the company.</p>
++<ul class="home-cards">${sum.hiring.map((c) => html`<li><a href="/jobs?${new URLSearchParams({ q: c.company })}"><b>${c.company}</b><span>${n(c.count)} open listing${c.count === 1 ? '' : 's'}</span></a></li>`)}</ul></section>` : ''}
<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where the listings come from</h2>
<p>Each source below is a public job board read through its own API, under its own terms. Every listing on this site names its source and links to the original; we keep a short excerpt and send you to the board for the rest.</p>
${table(['Source', 'Listings', 'Last read', 'Terms'], sources.SOURCES.map((src) => {
const f = fetches.get(src.id);
return [
html`<a href="${src.homepage}" rel="noopener">${src.name}</a>`,
-- String((counts.get(src.id) || {}).count || 0),
++ n((counts.get(src.id) || {}).count || 0),
f && f.finished_at ? html`${time(f.finished_at)}${f.ok ? '' : html` <span class="muted small">(the last read failed)</span>`}` : html`<span class="muted">not read yet</span>`,
html`${src.attribution} — <a href="/sources#${src.id}">terms as applied</a>`,
];
@@@ -147,11 +182,13 @@ ${raw(showcase.steps({
{ title: 'Gathered from open boards', text: 'A background ingest reads each board\'s public API every few hours, keeps the listing\'s own id and URL, and stores a short excerpt — never the full description.' },
{ title: 'Search it, with the source on it', text: 'Filter by text, workplace, job type and location. Every result names the board it came from and links back to it.' },
{ title: 'Read it on the board', text: 'The listing page shows the excerpt and sends you to the original listing to read it in full and apply. Nothing is republished.' },
-- { title: 'Keep a search', text: 'Sign in to save a search; /saved shows each one with the count of listings that appeared since you last looked.' },
++ { title: 'Keep a search, get alerts', text: 'Sign in to save a search and see what is new since you last looked, or follow any search through OpenVibe.Watch.' },
{ title: 'Nothing behind a paywall', text: 'Reading and searching is free and needs no account. There is no premium tier on the listings, and listings expire when a board stops showing them.' },
{ title: 'Track your applications', text: 'Sign in and press Track this job on a listing: keep your status, notes and next steps in one place, and ask your OpenVibe.Actor agent to help prepare each application.' },
],
}))}
++${FAQ.length ? html`<section class="sc-sec" aria-labelledby="h-faq"><h2 id="h-faq">Questions people ask</h2>
++<div class="home-faq">${FAQ.map((f) => html`<details><summary>${f.question}</summary><p>${f.answer}</p></details>`)}</div></section>` : ''}
${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sources and their terms, and your saved searches over HTTP. Reading is public.', actions: [{ label: 'The API', href: '/docs' }, { label: 'The sources', href: '/sources' }] }))}`,
});
});
ddiff --git a/server/jobs/store.js b/server/jobs/store.js
iindex 9e062fb..2f3d55d 100644
---- a/server/jobs/store.js
++++ b/server/jobs/store.js
@@@ -156,6 +156,56 @@ const listRecent = (s, limit = 5, { remote = false } = {}) => s.db.many(
`SELECT * FROM work_listings WHERE expired = false${remote ? ' AND remote = true' : ''} ORDER BY sort_at DESC, id DESC LIMIT $1`,
[Math.min(50, Math.max(1, limit))]);
++/**
++ * What the home page shows about the whole board, from the live listings only: how many, how many are remote, how
++ * many companies, how many arrived in the last day and week, the tags most listings carry, the places most listings
++ * name and the companies with the most listings. Held for ten minutes per store (the ingest runs every few hours), so
++ * a busy home page costs no aggregate query. A place's count is the same LIKE the location filter applies, so the
++ * number on its link is the number the search finds.
++ */
++const SUMMARY_TTL_MS = 10 * 60 * 1000;
++const summaries = new WeakMap();
++const NOT_A_PLACE = /^(remote|remote job|anywhere|worldwide|hybrid|global|europe|emea|usa|united states|united kingdom|germany|france|uk)$/i;
++async function homeSummary(s, { now = Date.now(), tags = 12, places = 12, companies = 12 } = {}) {
++ const hit = summaries.get(s);
++ if (hit && now - hit.at < SUMMARY_TTL_MS) return hit.value;
++ const iso = (ms) => new Date(ms).toISOString();
++ const totals = await s.db.maybe(
++ `SELECT count(*) AS total, count(*) FILTER (WHERE remote) AS remote, count(DISTINCT lower(company)) AS companies,
++ count(*) FILTER (WHERE first_seen_at > $1) AS day, count(*) FILTER (WHERE first_seen_at > $2) AS week,
++ count(*) FILTER (WHERE coalesce(salary, '') <> '') AS salaried
++ FROM work_listings WHERE expired = false`, [iso(now - 86400e3), iso(now - 7 * 86400e3)]);
++ const tagRows = await s.db.many(
++ `SELECT t, count(*) AS n FROM (SELECT lower(trim(unnest(string_to_array(tags, ',')))) AS t FROM work_listings WHERE expired = false) x
++ WHERE length(t) >= 3 AND t <> 'remote' GROUP BY t ORDER BY n DESC, t ASC LIMIT $1`, [tags]);
++ const placeNames = (await s.db.many(
++ `SELECT trim(split_part(location, ',', 1)) AS p, count(*) AS n FROM work_listings
++ WHERE expired = false AND coalesce(location, '') <> '' GROUP BY 1 ORDER BY n DESC, p ASC LIMIT $1`, [places * 2]))
++ .map((r) => r.p).filter((p) => p && p.length >= 3 && p.length <= 40 && !NOT_A_PLACE.test(p) && !/remote/i.test(p)).slice(0, places);
++ const placeRows = [];
++ for (const p of placeNames) {
++ placeRows.push({ place: p, count: num(await s.db.value(`SELECT count(*) AS n FROM work_listings WHERE expired = false AND ${PARAM.location(1)}`, [like(lower(p))])) });
++ }
++ placeRows.sort((a, b) => b.count - a.count || a.place.localeCompare(b.place));
++ const companyRows = await s.db.many(
++ `SELECT mode() WITHIN GROUP (ORDER BY company) AS company, count(*) AS n FROM work_listings
++ WHERE expired = false GROUP BY lower(company) ORDER BY n DESC, 1 ASC LIMIT $1`, [companies]);
++ const value = {
++ total: num(totals && totals.total), remote: num(totals && totals.remote), companies: num(totals && totals.companies),
++ day: num(totals && totals.day), week: num(totals && totals.week), salaried: num(totals && totals.salaried),
++ tags: tagRows.map((r) => ({ tag: r.t, count: num(r.n) })),
++ places: placeRows,
++ hiring: companyRows.map((r) => ({ company: r.company, count: num(r.n) })),
++ };
++ summaries.set(s, { at: now, value });
++ return value;
++}
++
++/** The newest live listings that state a salary (the board's own words). */
++const listSalaried = (s, limit = 4) => s.db.many(
++ `SELECT * FROM work_listings WHERE expired = false AND coalesce(salary, '') <> '' ORDER BY sort_at DESC, id DESC LIMIT $1`,
++ [Math.min(20, Math.max(1, limit))]);
++
/** The ids of the newest live listings, for the sitemap: its own cap, so a big board cannot bloat the file. */
const recentIds = (s, limit = 1000) => s.db.many(
`SELECT id FROM work_listings WHERE expired = false ORDER BY sort_at DESC, id DESC LIMIT $1`, [Math.min(1000, Math.max(1, limit))]);
@@@ -285,7 +335,7 @@ function decodeCursor(v) {
}
module.exports = {
-- search, facets, getListing, countsBySource, listRecent, recentIds, upsertListings, beginFetch, finishFetch, sourceFetches,
++ search, facets, getListing, countsBySource, listRecent, listSalaried, homeSummary, recentIds, upsertListings, 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/test/jobs.test.js b/test/jobs.test.js
iindex 0d1594f..647b2ad 100644
---- a/test/jobs.test.js
++++ b/test/jobs.test.js
@@@ -228,6 +228,9 @@ const first = async (t, path) => (await t.get(path)).json();
assert.ok(r.text.includes(`href="${url}"`), `the source ${name} is not linked on the home page`);
}
assert.match(r.text, /no paywall/i, '"how it works" does not say what is behind a paywall (nothing)');
++ for (const h of ['Browse by field', 'Jobs by city', 'Companies with the most openings', 'Questions people ask']) assert.ok(r.text.includes(h), `the home has "${h}"`);
++ assert.match(r.text, /<li><a href="\/jobs\?q=[^"]+"><b>[^<]+<\/b><span>\d+ open listings?<\/span>/, 'a company card opens a search for it, with its count');
++ assert.match(r.text, /"@type":"ItemList"/, 'the latest remote jobs are an ItemList');
});
await check('search is limited per caller, and past it the API says so with Retry-After', async () => {
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex f1326b6..577f2df 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -38,11 +38,12 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200);
const head = r.text.slice(0, r.text.indexOf('</head>'));
assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
-- assert.ok(head.includes('<title>OpenVibe.Work: Find work on open job boards.</title>'), 'home title');
++ assert.ok(head.includes('<title>Jobs from open job boards, with sources · OpenVibe.Work</title>'), 'home title');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.work/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
++ assert.deepStrictEqual(ld.slice(0, 3), ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
++ assert.strictEqual(ld[ld.length - 1], 'FAQPage', 'the questions shown on the home are its FAQPage');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="work"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
}
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 8c46186..97167be 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -22,9 +22,9 @@ const BUDGETS = {
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
-- cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 20.5, // 15.0 before the listings; 18.2 after (the search form, the listing list, the source blocks)
-- cssBrotliKB: 4.8, // 3.7 before; 4.3 after
++ 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: 25.5, // 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: 6, // 5.5 with css/home.css; 3.7 before; 4.3 after
externalFiles: 0, // 0
};