openvibers/openvibe.work Commits

The Type facet and filter speak one language: nine canonical job types (#160)

OP OpenVibers committed 12 days ago · e911970ac8 · 7 files, +382 −46

* The Type facet and filter speak one language: nine canonical job types

The boards write the job type in their own words and their own language
("unbefristeter arbeitsvertrag", "Cdi", "Experienced, Permanent, Full
time"), so the facet showed raw strings and the filter was a plain text
search over them.

server/jobs/job-types.js holds the nine types (Full time, Part time,
Permanent, Temporary, Contract, Freelance, Internship, Apprenticeship,
Volunteer) and the words each goes by in the languages the boards write.
The mapper drops what is not a type — a seniority, a title, a company's
name — so nothing unknown is ever shown raw.

The facet counts the canonical types (a listing naming two counts under
both), at most nine chips, most first. ?type=<key> filters with the very
patterns the facet counts with, so a chip and its link cannot disagree; an
older ?type= with a board's own words still works as the plain search it
always was. Cards and listing pages show the canonical labels.

* The canonical types keep the board's order, and the facet links the canonical key

The types a listing names are read in the order the board wrote them, so a
card says "Permanent, Full time" as the board meant it. The type field stays
a plain input: the chips are the canonical filter, and the home page stays
inside its size budget.

The facet now links the canonical key (?type=full-time), which is what the
filter reads; test/outcomes.test.js says so.

Changes

ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 0a827e0..bf8e021 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -23,6 +23,7 @@ const { foldBlock, pickBlock, pickRules } = require('../render/short');
const { send } = require('../render/layout');
const listing = require('../render/listing');
const store = require('../jobs/store');
++const jobTypes = require('../jobs/job-types');
const companies = require('../jobs/companies');
const sources = require('../jobs/sources');
const applications = require('../applications');
@@@ -84,7 +85,7 @@ ${link({ remote: 'true' }, 'Remote', filters.remote === 'true', facets.remote.tr
${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 }, listing.jobTypeLabel(t.type), filters.type === t.type, t.count))}
++${facets.type.map((t) => link({ type: t.type }, t.label, filters.type === t.type, t.count))}
</ul>` : ''}
</div>`;
};
@@@ -231,7 +232,9 @@ ${raw(foldBlock({
// ── Alerts: a search as an Atom feed, followed by OpenVibe.Watch ──
const WATCH_NEW = 'https://openvibe.watch/watches/new';
-- const describe = (filters) => [filters.q ? `“${filters.q}”` : '', filters.remote === 'true' ? 'remote' : filters.remote === 'false' ? 'on-site' : '', filters.type ? filters.type : '', filters.location ? `in ${filters.location}` : ''].filter(Boolean).join(' ');
++ // A search is described in the same language as the facet: a canonical type is named by its label.
++ const typeText = (value) => (value ? (jobTypes.label(jobTypes.canonicalKey(value)) || value) : '');
++ const describe = (filters) => [filters.q ? `“${filters.q}”` : '', filters.remote === 'true' ? 'remote' : filters.remote === 'false' ? 'on-site' : '', typeText(filters.type), filters.location ? `in ${filters.location}` : ''].filter(Boolean).join(' ');
const feedPath = (filters) => withFilters(filters, { cursor: null }, '/jobs.atom');
/** Watch's new-watch form, filled in to follow one search's feed; the person reviews and creates it on Watch. */
function alertHref(filters) {
@@@ -309,6 +312,7 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
}
const src = sourceOf(row);
const l = listing.meta(row);
++ const jobTypeText = listing.jobTypeLabel(l.job_type);
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.
@@@ -335,7 +339,7 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
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">${listing.jobTypeLabel(l.job_type)}</span>` : ''}</p>
++<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' : ''}${jobTypeText ? html` · <span class="job-type">${jobTypeText}</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>
@@@ -384,7 +388,7 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
}
const describeSaved = (row) => {
-- const bits = [row.query ? `“${row.query}”` : '', row.remote === 'true' ? 'remote' : row.remote === 'false' ? 'on-site' : '', row.type ? row.type : '', row.location ? `in ${row.location}` : ''].filter(Boolean);
++ const bits = [row.query ? `“${row.query}”` : '', row.remote === 'true' ? 'remote' : row.remote === 'false' ? 'on-site' : '', typeText(row.type), row.location ? `in ${row.location}` : ''].filter(Boolean);
return bits.length ? bits.join(' ') : 'everything';
};
ddiff --git a/server/jobs/job-types.js b/server/jobs/job-types.js
nnew file mode 100644
iindex 0000000..ae1d736
---- /dev/null
++++ b/server/jobs/job-types.js
@@@ -0,0 +1,157 @@
++'use strict';
++
++/**
++ * The job types this service speaks, in one place (plan 2026-11-01).
++ *
++ * The boards write the type in their own words and in their own language: Arbeitnow says "unbefristeter
++ * arbeitsvertrag", RemoteOK says "Experienced, Permanent, Full time", a French board says "CDI - contrat à durée
++ * indéterminée", a Dutch board says "vast dienstverband". A reader should not have to know that: the facet shows one
++ * set of nine types, in English, and the filter takes the same nine.
++ *
++ * So a raw job_type is mapped to the canonical keys it names. The synonyms below are the words the boards really
++ * use (read from the live rows, counts only), and a word that is not in the table is dropped: a seniority
++ * ("Berufserfahren", "Experienced", "Entry", "Senior", "Mid"), a manager's title, a workplace or a company's name is
++ * not a job type and is never shown.
++ *
++ * One table, two readers: the mapper here (JavaScript) decides what a card, a detail page and the facet say, and the
++ * very same patterns are handed to PostgreSQL, so a facet count and the filter it links to can never disagree.
++ */
++
++// The nine types, in the order the facet shows them.
++const CANONICAL = [
++ { key: 'full-time', label: 'Full time' },
++ { key: 'part-time', label: 'Part time' },
++ { key: 'permanent', label: 'Permanent' },
++ { key: 'temporary', label: 'Temporary' },
++ { key: 'contract', label: 'Contract' },
++ { key: 'freelance', label: 'Freelance' },
++ { key: 'internship', label: 'Internship' },
++ { key: 'apprenticeship', label: 'Apprenticeship' },
++ { key: 'volunteer', label: 'Volunteer' },
++];
++
++const KEYS = CANONICAL.map((t) => t.key);
++const LABELS = Object.fromEntries(CANONICAL.map((t) => [t.key, t.label]));
++
++/**
++ * The words each type goes by, in the languages the boards write: German, French, Dutch, Spanish and English, in
++ * the spelling each board uses (an umlaut, an accent, an underscore, a hyphen). A board that drops the accent
++ * ("duree" for "durée") is covered too, because the board's spelling is the one we have to match.
++ *
++ * Only a word that names a type is here. Seniority ("Berufserfahren", "Berufseinstieg", "Experienced", "Entry",
++ * "Senior", "Mid"), a title ("Teamleitung", "Manager", "Executive"), a school ("Student college") and a company's
++ * name are deliberately absent: they are dropped, never shown.
++ */
++const SYNONYMS = {
++ 'full-time': [
++ 'full time', 'fulltime', 'vollzeit', 'vollzeitstelle', 'temps plein', 'plein temps', 'voltijd', 'voltijds',
++ 'tiempo completo', 'jornada completa', 'tiempo integral',
++ ],
++ 'part-time': [
++ 'part time', 'parttime', 'teilzeit', 'teilzeitstelle', 'teilzeitbeschäftigung', 'minijob', 'nebentätigkeit',
++ 'nebenbeschäftigung', 'hilfstätigkeit', 'werkstudent', 'working student', 'side', 'side job', 'deeltijd',
++ 'deeltijdcontract', 'temps partiel', 'medio tiempo', 'tiempo parcial', 'media jornada',
++ ],
++ permanent: [
++ 'permanent', 'permanent employment', 'permanent contract', 'permanent position', 'festanstellung',
++ 'unbefristet', 'unbefristeter arbeitsvertrag', 'unbefristetes arbeitsverhältnis', 'cdi',
++ 'contrat à durée indéterminée', 'contrat a duree indeterminee', 'indefinite contract', 'indefinite employment',
++ 'open-ended contract', 'open ended contract', 'onbepaalde tijd', 'onbepaalde', 'vast dienstverband',
++ 'vast contract', 'contrato indefinido', 'contrato fijo',
++ ],
++ temporary: [
++ 'temporary', 'temporary employment', 'temporary work', 'temporary contract', 'fixed term', 'fixed-term contract',
++ 'fixed-term', 'befristet', 'befristeter arbeitsvertrag', 'befristetes arbeitsverhältnis', 'cdd',
++ 'contrat à durée déterminée', 'contrat a duree determinee', 'interim', 'ctt', 'tijdelijk contract',
++ 'tijdelijk', 'tijdelijke', 'bepaalde tijd', 'contrato temporal', 'temporal',
++ ],
++ contract: [
++ 'contract', 'contract 6-12 months', 'contrat', 'vertrag',
++ ],
++ freelance: [
++ 'freelance', 'freelancer', 'freiberuflich', 'freiberufler', 'freiberufliche', 'selbstständiger',
++ 'selbständiger', 'self-employed', 'self employed', 'auto-entrepreneur', 'travailleur indépendant',
++ 'travailleur independant', 'indépendant', 'indepependant', 'independent', 'contractor',
++ 'independent contractor', 'zzp', 'service provider',
++ ],
++ internship: [
++ 'internship', 'intern', 'praktikum', 'praktikumsvertrag', 'praktikant', 'stage', 'stage de fin d\'études',
++ 'stage de fin d etudes', 'convention de stage', 'trainee', 'traineeship', 'prácticas', 'practicas',
++ ],
++ apprenticeship: [
++ 'apprenticeship', 'apprentice', 'ausbildung', 'ausbildungsberuf', 'lehrvertrag', 'lehrling', 'alternance',
++ 'dual studies', 'duales studium', 'apprentissage', 'aprendizaje',
++ ],
++ volunteer: [
++ 'volunteer', 'volunteering', 'bénévolat', 'benevolat', 'ehrenamt', 'volontär', 'volontaire',
++ ],
++};
++
++// A word may be written with a space, a hyphen or an underscore between its words, and the boards differ on the
++// accents ("durée", "duree"), so a pattern accepts all of them.
++const SEPARATOR = '[- _]+';
++const WORD_BEFORE = '(^|[^a-z0-9])';
++const WORD_AFTER = '([^a-z0-9]|$)';
++
++const escapeRe = (ch) => (/[.*+?^${}()|[\]\\]/.test(ch) ? '\\' + ch : ch);
++
++/**
++ * One synonym → one pattern: the words, separated by a space, a hyphen or an underscore, bounded by a non-letter on
++ * either side, so "contract" never matches "contractor" and "befristet" never matches "unbefristet".
++ */
++function patternOf(synonym) {
++ const words = String(synonym).toLowerCase().split(' ').map((word) => [...word].map(escapeRe).join(''));
++ return WORD_BEFORE + words.join(SEPARATOR) + WORD_AFTER;
++}
++
++/** The patterns of every type, and the same patterns as one JavaScript regular expression each. */
++const PATTERNS = {};
++const COMPILED = {};
++for (const key of KEYS) {
++ PATTERNS[key] = SYNONYMS[key].map(patternOf);
++ COMPILED[key] = PATTERNS[key].map((p) => new RegExp(p));
++}
++
++/**
++ * A raw job_type → the canonical keys it names. The raw value is a board's list of words, so it is read part by part
++ * (on commas and slashes) and every part is matched against the synonyms. A listing that names two types ("Permanent,
++ * Full time") gets both, so it counts under each and either filter finds it; the keys come in the order the board
++ * wrote them, so a card reads the way the board meant it. A raw value that names no type (a seniority, a title, a
++ * company's name, nothing at all) gets none: it is dropped, never shown raw.
++ */
++function mapTypes(raw) {
++ const text = String(raw == null ? '' : raw).toLowerCase().trim();
++ if (!text) return [];
++ const parts = text.split(/[,;/]/).map((p) => p.trim()).filter(Boolean);
++ const named = [];
++ for (const part of parts) {
++ for (const key of KEYS) {
++ if (!named.includes(key) && COMPILED[key].some((re) => re.test(part))) named.push(key);
++ }
++ }
++ return named;
++}
++
++/** The canonical labels a raw job_type names, in the order the facet shows them. */
++const labelsOf = (raw) => mapTypes(raw).map((key) => LABELS[key]);
++
++/** The canonical labels of a raw job_type as one line: "Permanent, Full time". */
++const labelText = (raw) => labelsOf(raw).join(', ');
++
++/** The label of one canonical key. */
++const label = (key) => LABELS[key] || '';
++
++/**
++ * The canonical key a reader's ?type= names, or null when the value is not one of the nine. "Full time", "full_time"
++ * and "FULL-TIME" all mean the full-time type; anything else ("unbefristeter arbeitsvertrag") is not a canonical type
++ * and keeps the old behaviour of a plain text search over the boards' own words.
++ */
++function canonicalKey(value) {
++ const norm = String(value == null ? '' : value).trim().toLowerCase().replace(/[\s_]+/g, '-');
++ return KEYS.includes(norm) ? norm : null;
++}
++
++/** The patterns PostgreSQL needs to find the listings of one canonical type. */
++const patternsOf = (key) => (PATTERNS[key] || []).slice();
++
++module.exports = { CANONICAL, KEYS, LABELS, SYNONYMS, mapTypes, labelsOf, labelText, label, canonicalKey, patternsOf, patternOf };
ddiff --git a/server/jobs/store.js b/server/jobs/store.js
iindex a4ce3b7..52f8c1b 100644
---- a/server/jobs/store.js
++++ b/server/jobs/store.js
@@@ -10,10 +10,10 @@
*/
const crypto = require('crypto');
const places = require('./places');
++const jobTypes = require('./job-types');
const PAGE_SIZE = 30;
const MAX_PAGE = 100;
--const TYPE_FACETS = 12;
// ── ids ─────────────────────────────────────────────────────
// A listing's id is derived from (source, source_id), so the same listing keeps the same URL on this site however
@@@ -71,6 +71,10 @@ function toWire(row, { source } = {}) {
remote: !!row.remote,
tags: parseTags(row.tags),
job_type: row.job_type || null,
++ // The board's own words, and the nine canonical types they name: both, so the API speaks the same language
++ // as the facet without losing what the board actually said.
++ job_types: jobTypes.mapTypes(row.job_type),
++ job_type_label: jobTypes.labelText(row.job_type) || null,
salary: row.salary || null,
excerpt: row.excerpt,
posted_at: row.posted_at || null,
@@@ -86,10 +90,35 @@ function toWire(row, { source } = {}) {
const PARAM = {
q: (n) => `($${n} = '' OR search_text LIKE $${n} ESCAPE '\\')`,
remote: (n) => `($${n} = '' OR remote = ($${n} = 'true'))`,
-- type: (n) => `($${n} = '' OR lower(coalesce(job_type, '')) LIKE $${n} ESCAPE '\\')`,
location: (n) => `(cardinality($${n}::text[]) = 0 OR lower(coalesce(location, '')) LIKE ANY ($${n}::text[]))`,
};
--const valueOf = { q: (f) => (f.q ? like(f.q) : ''), remote: (f) => f.remote, type: (f) => (f.type ? like(f.type) : ''), location: (f) => places.patternsOf(f.location) };
++const valueOf = { q: (f) => (f.q ? like(f.q) : ''), remote: (f) => f.remote, location: (f) => places.patternsOf(f.location) };
++
++/**
++ * The filters as SQL and bound parameters. The type is the awkward one, and it is the reason this table exists: when
++ * the reader picked one of the nine canonical types, the listings are the ones whose own words name that type —
++ * the very patterns the facet counts with (server/jobs/job-types.js), so a chip and the link it carries can never
++ * disagree — and when they typed a board's own words, it is the plain text search of before. Nothing is built from
++ * what a caller typed: the patterns are ours and the words are bound parameters.
++ */
++function filterSql(f, start = 1) {
++ const args = [];
++ const push = (v) => { args.push(v); return start + args.length - 1; };
++ const qn = push(valueOf.q(f));
++ const rn = push(valueOf.remote(f));
++ const key = jobTypes.canonicalKey(f.type);
++ const kn = push(key || '');
++ const pn = push(key ? jobTypes.patternsOf(key) : []);
++ const sn = push(!key && f.type ? like(f.type) : '');
++ const ln = push(valueOf.location(f));
++ const sql = [
++ PARAM.q(qn),
++ PARAM.remote(rn),
++ `(($${kn} = '' AND $${sn} = '') OR ($${kn} <> '' AND lower(coalesce(job_type, '')) ~ ANY ($${pn}::text[])) OR ($${sn} <> '' AND lower(coalesce(job_type, '')) LIKE $${sn} ESCAPE '\\'))`,
++ PARAM.location(ln),
++ ].join(' AND ');
++ return { sql, args };
++}
/**
* One page of listings, newest first, plus the facets for the current scope. The cursor is the position of the last
@@@ -98,12 +127,8 @@ const valueOf = { q: (f) => (f.q ? like(f.q) : ''), remote: (f) => f.remote, typ
async function search(s, filters, { limit = PAGE_SIZE, cursor = null } = {}) {
const f = filtersOf(filters);
const n = Math.min(MAX_PAGE, Math.max(1, Number(limit) || PAGE_SIZE));
-- const args = [];
-- const where = ['expired = false'];
-- for (const name of ['q', 'remote', 'type', 'location']) {
-- args.push(valueOf[name](f));
-- where.push(PARAM[name](args.length));
-- }
++ const { sql, args } = filterSql(f);
++ const where = ['expired = false', sql];
const scope = where.join(' AND ');
// How many listings the reader's filters actually match, counted exactly rather than inferred from the page.
const matches = Number(await s.db.value(`SELECT count(*) AS n FROM work_listings WHERE ${scope}`, args));
@@@ -132,14 +157,24 @@ async function facets(s, f) {
const args = [valueOf.q(f), valueOf.location(f)];
const scope = `expired = false AND ${PARAM.q(1)} AND ${PARAM.location(2)}`;
const remoteRows = await s.db.many(`SELECT remote, count(*) AS n FROM work_listings WHERE ${scope} GROUP BY remote`, args);
++ // The type facet counts the canonical types, not the boards' own strings: a listing that says "Permanent, Full
++ // time" counts under both, and a chip never shows a board's wording (server/jobs/job-types.js). The rows are read
++ // as the distinct wordings they are — a few hundred at most — and each is mapped, so the count is the count.
const typeRows = await s.db.many(
-- `SELECT lower(job_type) AS t, count(*) AS n FROM work_listings
++ `SELECT lower(coalesce(job_type, '')) AS t, count(*) AS n FROM work_listings
WHERE ${scope} AND coalesce(job_type, '') <> ''
-- GROUP BY lower(job_type) ORDER BY n DESC, t ASC LIMIT ${TYPE_FACETS}`, args);
++ GROUP BY 1 ORDER BY n DESC, t ASC`, args);
++ const counts = new Map();
++ for (const row of typeRows) {
++ for (const key of jobTypes.mapTypes(row.t)) counts.set(key, (counts.get(key) || 0) + num(row.n));
++ }
++ const type = jobTypes.KEYS.filter((key) => counts.has(key))
++ .map((key) => ({ type: key, label: jobTypes.label(key), count: counts.get(key) }))
++ .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type));
const remote = { true: 0, false: 0 };
for (const r of remoteRows) remote[r.remote ? 'true' : 'false'] = num(r.n);
// `scope` counts the words and the location only — the population each facet below divides up.
-- return { remote, type: typeRows.map((r) => ({ type: r.t, count: num(r.n) })), scope: remote.true + remote.false };
++ return { remote, type, scope: remote.true + remote.false };
}
/**
@@@ -232,12 +267,7 @@ const listSalaried = (s, limit = 4) => s.db.many(
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))]);
--/** The listings first seen after a saved search's last look — what "new since you last looked" counts. */
--function newCountSql() {
-- return `SELECT count(*) AS n FROM work_listings
-- WHERE expired = false AND first_seen_at > $1
-- AND ${PARAM.q(2)} AND ${PARAM.remote(3)} AND ${PARAM.type(4)} AND ${PARAM.location(5)}`;
--}
++
// ── ingest writes ───────────────────────────────────────────
/**
@@@ -344,7 +374,10 @@ const deleteSaved = (s, subject, id) => (SAVED_ID.test(String(id)) ? s.db.exec('
/** The count of listings that first appeared after the saved search was last looked at. */
async function newCount(s, row) {
const f = filtersOf(row);
-- return Number(await s.db.value(newCountSql(), [row.last_seen_at, valueOf.q(f), valueOf.remote(f), valueOf.type(f), valueOf.location(f)]));
++ const { sql, args } = filterSql(f, 2);
++ return Number(await s.db.value(
++ `SELECT count(*) AS n FROM work_listings WHERE expired = false AND first_seen_at > $1 AND ${sql}`,
++ [row.last_seen_at, ...args]));
}
/** Mark a saved search as looked at (the moment its count stops being "new"). */
ddiff --git a/server/render/listing.js b/server/render/listing.js
iindex 29e246a..dbf3e97 100644
---- a/server/render/listing.js
++++ b/server/render/listing.js
@@@ -10,6 +10,7 @@
*/
const { html, badge } = require('./html');
const { stripHtml } = require('../jobs/text');
++const jobTypes = require('../jobs/job-types');
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@@ -52,25 +53,13 @@ function salaryLine(row, { note = false } = {}) {
}
/**
-- * 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.
++ * A job type in our own words. The boards write "unbefristeter arbeitsvertrag", "Cdi" and "full_time", and a card
++ * does not show a board's wording: the nine canonical types (server/jobs/job-types.js) say what the listing is
++ * ("Full time, Permanent"). A value that names no type — a seniority, a title, a company's name — is not shown at all:
++ * it is dropped, never shown raw.
*/
--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(', ');
++ return jobTypes.labelText(value);
}
/** The source's name, linked to the original listing: the attribution its terms require, in one element. */
@@@ -99,12 +88,13 @@ function initialTile(company) {
/** One listing in a list, as a card: who, what and where at a glance, two lines of the excerpt, and the link back. */
function listingItem(row, { source }) {
const l = meta(row);
++ const typeText = jobTypeLabel(l.job_type);
return html`<li class="job-item" id="${l.id}">
${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">${jobTypeLabel(l.job_type)}</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>` : ''}${typeText ? html`<span class="chip">${typeText}</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)}
@@@ -136,7 +126,7 @@ function jobPosting(row, { siteUrl }) {
publisher: { '@type': 'Organization', name: 'OpenVibe.Work', url: siteUrl },
};
if (l.posted_at) node.datePosted = l.posted_at;
-- if (l.job_type) node.employmentType = l.job_type;
++ if (jobTypeLabel(l.job_type)) node.employmentType = jobTypeLabel(l.job_type);
if (l.remote) node.jobLocationType = 'TELECOMMUTE';
if (l.location) node.jobLocation = { '@type': 'Place', address: { '@type': 'PostalAddress', addressLocality: l.location } };
if (l.salary) node.baseSalary = { '@type': 'MonetaryAmount', description: l.salary };
ddiff --git a/server/search-index.js b/server/search-index.js
iindex 245b2a2..01845ca 100644
---- a/server/search-index.js
++++ b/server/search-index.js
@@@ -27,14 +27,14 @@ const INTERVAL_MS = 3_600_000;
function describe(row) {
const l = listing.meta(row);
const src = sources.byId(row.source);
-- 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(' · ');
const excerpt = listing.plainExcerpt(row);
return {
listed: !row.expired,
title: `${l.title} at ${l.company}`,
summary: [where, l.salary].filter(Boolean).join(' · ') || excerpt.slice(0, 200) || null,
body: [l.title, l.company, where, l.tags.join(', '), l.salary, excerpt].filter(Boolean).join('\n'),
-- facets: { source: row.source, remote: l.remote, type: l.job_type || null, location: l.location || null },
++ facets: { source: row.source, remote: l.remote, type: listing.jobTypeLabel(l.job_type) || null, location: l.location || null },
authorship: { mode: 'imported' },
provenance: [{
service: 'work', type: 'board_listing', id: `${row.source}:${row.source_id}`.slice(0, 128),
ddiff --git a/test/job-types.test.js b/test/job-types.test.js
nnew file mode 100644
iindex 0000000..b1209bf
---- /dev/null
++++ b/test/job-types.test.js
@@@ -0,0 +1,151 @@
++'use strict';
++/**
++ * The canonical job types: the mapper that reads a board's own words, the facet that counts the nine types, and the
++ * filter that finds them. The listings come from the stand-in boards through the real ingest, so what is counted is
++ * what is stored and what is searched.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const jobTypes = require('../server/jobs/job-types');
++
++(async () => {
++ await check('the mapper reads the words the boards really use, in every language', () => {
++ assert.deepStrictEqual(jobTypes.mapTypes('Full time'), ['full-time']);
++ assert.deepStrictEqual(jobTypes.mapTypes('full_time'), ['full-time']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Fulltime'), ['full-time']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Vollzeit'), ['full-time']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Temps plein'), ['full-time']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Teilzeit'), ['part-time']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Minijob (geringfügige beschäftigung)'), ['part-time']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Werkstudent, working student, hilfstätigkeit / student'), ['part-time']);
++ assert.deepStrictEqual(jobTypes.mapTypes('unbefristeter arbeitsvertrag'), ['permanent']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Festanstellung'), ['permanent']);
++ assert.deepStrictEqual(jobTypes.mapTypes('CDI - contrat à durée indéterminée'), ['permanent', 'contract']);
++ assert.deepStrictEqual(jobTypes.mapTypes('vaste dienstverband (onbepaalde tijd)'), ['permanent']);
++ assert.deepStrictEqual(jobTypes.mapTypes('contrato indefinido'), ['permanent']);
++ assert.deepStrictEqual(jobTypes.mapTypes('befristeter arbeitsvertrag'), ['temporary']);
++ // A board that writes "contrat" says "contract", so it is both: temporary work, and a contract.
++ assert.deepStrictEqual(jobTypes.mapTypes('CDD - contrat à durée déterminée'), ['temporary', 'contract']);
++ assert.deepStrictEqual(jobTypes.mapTypes('tijdelijk contract'), ['temporary', 'contract']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Contract'), ['contract']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Freelancer / selbstständiger'), ['freelance']);
++ assert.deepStrictEqual(jobTypes.mapTypes('auto-entrepreneur'), ['freelance']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Praktikum'), ['internship']);
++ assert.deepStrictEqual(jobTypes.mapTypes('stage de fin d\'études'), ['internship']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Traineeship'), ['internship']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Ausbildung'), ['apprenticeship']);
++ assert.deepStrictEqual(jobTypes.mapTypes('duales studium'), ['apprenticeship']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Alternance'), ['apprenticeship']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Volunteer'), ['volunteer']);
++ assert.deepStrictEqual(jobTypes.mapTypes('bénévolat'), ['volunteer']);
++ });
++
++ await check('a listing that names two types is both, and the words that are not types are dropped', () => {
++ assert.deepStrictEqual(jobTypes.mapTypes('Experienced, Permanent, Full time'), ['permanent', 'full-time']);
++ assert.deepStrictEqual(jobTypes.mapTypes('permanent, full_time'), ['permanent', 'full-time']);
++ // A seniority is not a type: it is dropped, and the type beside it is kept.
++ assert.deepStrictEqual(jobTypes.mapTypes('Experienced, Permanent, Full time'), ['permanent', 'full-time']);
++ assert.deepStrictEqual(jobTypes.mapTypes('Berufserfahren'), []);
++ assert.deepStrictEqual(jobTypes.mapTypes('Berufseinsteiger'), []);
++ assert.deepStrictEqual(jobTypes.mapTypes('Mid-Senior'), []);
++ assert.deepStrictEqual(jobTypes.mapTypes('Teamleitung'), []);
++ assert.deepStrictEqual(jobTypes.mapTypes('professional / experienced'), []);
++ // A company's name is not a type either, and is never shown raw.
++ assert.deepStrictEqual(jobTypes.mapTypes('chocolate factory, keynsham [9025]'), []);
++ assert.deepStrictEqual(jobTypes.mapTypes(''), []);
++ assert.deepStrictEqual(jobTypes.mapTypes(null), []);
++ // A longer word is not a type: "contractor" is not a "contract".
++ assert.deepStrictEqual(jobTypes.mapTypes('independent contractor'), ['freelance']);
++ assert.deepStrictEqual(jobTypes.mapTypes('unbefristet'), ['permanent']);
++ assert.deepStrictEqual(jobTypes.mapTypes('befristet'), ['temporary']);
++ });
++
++ await check('the labels are ours, and a reader\'s ?type= is read as a type or as plain words', () => {
++ assert.strictEqual(jobTypes.labelText('permanent, full_time'), 'Permanent, Full time');
++ assert.strictEqual(jobTypes.labelText('Berufserfahren'), '', 'nothing is shown when no type is named');
++ assert.strictEqual(jobTypes.label('full-time'), 'Full time');
++ assert.strictEqual(jobTypes.canonicalKey('Full time'), 'full-time');
++ assert.strictEqual(jobTypes.canonicalKey('full_time'), 'full-time');
++ assert.strictEqual(jobTypes.canonicalKey('FULL-TIME'), 'full-time');
++ assert.strictEqual(jobTypes.canonicalKey('unbefristeter arbeitsvertrag'), null, 'not one of the nine');
++ assert.strictEqual(jobTypes.canonicalKey(''), null);
++ });
++
++ const t = await boot({ env: { WORK_INGEST_PAGES: '2' } });
++ try {
++ await t.ctx.ingest.runAll();
++ // Two listings in the boards' own words: a German one and a French one.
++ t.boards.add('arbeitnow', {
++ slug: 'support-werkstudent-berlin',
++ company_name: 'Nordlicht Systems',
++ title: 'Support Werkstudent',
++ description: '<p>Support in Berlin.</p>',
++ remote: false,
++ url: 'https://www.arbeitnow.com/view/support-werkstudent-berlin-45678',
++ tags: ['support'],
++ job_types: ['Werkstudent', 'hilfstätigkeit'],
++ location: 'Berlin, Germany',
++ created_at: 1791800000,
++ });
++ t.boards.add('remotive', {
++ id: 1900900, url: 'https://remotive.com/remote-jobs/other/consultant-cdi-1900900',
++ title: 'Consultant CDI', company_name: 'Longform Ltd', category: 'Other', tags: ['consulting'],
++ job_type: 'CDI - contrat à durée indéterminée', publication_date: '2026-10-02T10:00:00',
++ candidate_required_location: 'Worldwide', salary: '', description: '<p>A permanent contract.</p>',
++ });
++ await t.ctx.ingest.runAll();
++
++ await check('the facet counts the canonical types, most first, and says nothing a board said', async () => {
++ const all = (await t.get('/api/v1/jobs')).json();
++ assert.ok(all.count > 0, 'a search without a type filter finds nothing');
++ const facets = all.facets;
++ assert.ok(facets.type.length > 0, 'the facet has types');
++ for (const f of facets.type) {
++ assert.ok(jobTypes.KEYS.includes(f.type), `not a canonical type: ${f.type}`);
++ assert.ok(f.count > 0, `${f.type} has no listings`);
++ }
++ assert.ok(facets.type.length <= 9, 'at most nine chips');
++ const counts = facets.type.map((f) => f.count);
++ assert.deepStrictEqual(counts, [...counts].sort((a, b) => b - a), 'most first');
++ // The raw strings are gone: no chip carries a board's own wording.
++ for (const f of facets.type) assert.ok(!/unbefristet|cdi|berufserfahren|erfahren/i.test(f.type));
++ });
++
++ await check('every chip is a filter that finds exactly what the chip counted', async () => {
++ const facets = (await t.get('/api/v1/jobs')).json().facets;
++ for (const f of facets.type) {
++ const page = (await t.get(`/api/v1/jobs?type=${f.type}`)).json();
++ assert.strictEqual(page.facets.matches, f.count, `the chip for ${f.type} does not match its count`);
++ }
++ // A listing that names two types is found by either.
++ const permanent = (await t.get('/api/v1/jobs?type=permanent')).json();
++ assert.ok(permanent.jobs.some((j) => /Consultant CDI/.test(j.title)), 'the French listing is not permanent');
++ const partTime = (await t.get('/api/v1/jobs?type=part-time')).json();
++ assert.ok(partTime.jobs.some((j) => /Support Werkstudent/.test(j.title)), 'the German listing is not part time');
++ // And it is counted under both.
++ const fullTime = (await t.get('/api/v1/jobs?type=full-time')).json();
++ assert.ok(fullTime.jobs.some((j) => /Frontend Engineer/.test(j.title)));
++ });
++
++ await check('a type in a board\'s own words still works, as the plain search it always was', async () => {
++ const raw = (await t.get('/api/v1/jobs?type=Werkstudent')).json();
++ assert.ok(raw.jobs.some((j) => /Support Werkstudent/.test(j.title)), 'the raw value finds nothing');
++ const unknown = (await t.get('/api/v1/jobs?type=nothinglikethis')).json();
++ assert.strictEqual(unknown.count, 0);
++ assert.strictEqual(unknown.facets.matches, 0);
++ });
++
++ await check('the list page shows the canonical chips, and the cards the canonical labels', async () => {
++ const html = (await t.get('/jobs')).text;
++ assert.match(html, /aria-label="Job type"/);
++ for (const f of (await t.get('/api/v1/jobs')).json().facets.type) {
++ assert.ok(html.includes(`type=${f.type}`), `no chip for ${f.type}`);
++ }
++ assert.ok(!html.includes('unbefristeter arbeitsvertrag'), 'a board\'s own wording is on the page');
++ assert.ok(!html.includes('>Berufserfahren'), 'a seniority is on the page');
++ // The cards carry the label, not the raw value.
++ assert.ok(html.includes('Full time') || html.includes('Permanent'), 'the cards carry no canonical label');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/outcomes.test.js b/test/outcomes.test.js
iindex 54ddcfa..8dd8331 100644
---- a/test/outcomes.test.js
++++ b/test/outcomes.test.js
@@@ -119,7 +119,7 @@ const cardOf = (html, id) => {
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 () => {
++ await check('the job types are in words on the cards, in the facet and on the page, and the filter takes the canonical key', 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');
@@@ -129,7 +129,8 @@ const cardOf = (html, id) => {
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 facet links the canonical type, so the chip and the filter speak the same language.
++ assert.ok(page.includes('href="/jobs?type=full-time"'), 'the facet does not link the canonical type');
// 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');