openvibers/openvibe.work Commits
The Type facet and filter speak one language: nine canonical job types (#160)
Changes
@@@ -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';};nnew file mode 100644@@@ -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 };@@@ -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, typasync 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_listingsWHERE ${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"). */@@@ -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 };@@@ -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),nnew file mode 100644@@@ -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); });@@@ -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');
@@ -23,6 +23,7 @@ const { foldBlock, pickBlock, pickRules } = require('../render/short');
@@ -23,6 +23,7 @@ const { foldBlock, pickBlock, pickRules } = require('../render/short');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const listing = require('../render/listing');
const listing = require('../render/listing');
const store = require('../jobs/store');
const store = require('../jobs/store');
+const jobTypes = require('../jobs/job-types');
const companies = require('../jobs/companies');
const companies = require('../jobs/companies');
const sources = require('../jobs/sources');
const sources = require('../jobs/sources');
const applications = require('../applications');
const applications = require('../applications');
@@ -84,7 +85,7 @@ ${link({ remote: 'true' }, 'Remote', filters.remote === 'true', facets.remote.tr
@@ -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)}
${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 }, 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>` : ''}
</ul>` : ''}
</div>`;
</div>`;
};
};
@@ -231,7 +232,9 @@ ${raw(foldBlock({
@@ -231,7 +232,9 @@ ${raw(foldBlock({
// ── Alerts: a search as an Atom feed, followed by OpenVibe.Watch ──
// ── Alerts: a search as an Atom feed, followed by OpenVibe.Watch ──
const WATCH_NEW = 'https://openvibe.watch/watches/new';
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');
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. */
/** Watch's new-watch form, filled in to follow one search's feed; the person reviews and creates it on Watch. */
function alertHref(filters) {
function alertHref(filters) {
@@ -309,6 +312,7 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
@@ -309,6 +312,7 @@ ${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 jobTypeText = listing.jobTypeLabel(l.job_type);
const fetchedOn = row.fetched_at ? listing.dateWords(row.fetched_at) : 'a date we did not record';
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
// 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.
// 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</
@@ -335,7 +339,7 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
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">${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 })}
${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>` : ''}
${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>
@@ -384,7 +388,7 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
@@ -384,7 +388,7 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
}
}
const describeSaved = (row) => {
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';
return bits.length ? bits.join(' ') : 'everything';
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -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 };
@@ -10,10 +10,10 @@
@@ -10,10 +10,10 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const places = require('./places');
const places = require('./places');
+const jobTypes = require('./job-types');
const PAGE_SIZE = 30;
const PAGE_SIZE = 30;
const MAX_PAGE = 100;
const MAX_PAGE = 100;
-const TYPE_FACETS = 12;
// ── ids ─────────────────────────────────────────────────────
// ── ids ─────────────────────────────────────────────────────
// A listing's id is derived from (source, source_id), so the same listing keeps the same URL on this site however
// 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 } = {}) {
@@ -71,6 +71,10 @@ function toWire(row, { source } = {}) {
remote: !!row.remote,
remote: !!row.remote,
tags: parseTags(row.tags),
tags: parseTags(row.tags),
job_type: row.job_type || null,
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,
salary: row.salary || null,
excerpt: row.excerpt,
excerpt: row.excerpt,
posted_at: row.posted_at || null,
posted_at: row.posted_at || null,
@@ -86,10 +90,35 @@ function toWire(row, { source } = {}) {
@@ -86,10 +90,35 @@ function toWire(row, { source } = {}) {
const PARAM = {
const PARAM = {
q: (n) => `($${n} = '' OR search_text LIKE $${n} ESCAPE '\\')`,
q: (n) => `($${n} = '' OR search_text LIKE $${n} ESCAPE '\\')`,
remote: (n) => `($${n} = '' OR remote = ($${n} = 'true'))`,
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[]))`,
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
* 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
@@ -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 } = {}) {
async function search(s, filters, { limit = PAGE_SIZE, cursor = null } = {}) {
const f = filtersOf(filters);
const f = filtersOf(filters);
const n = Math.min(MAX_PAGE, Math.max(1, Number(limit) || PAGE_SIZE));
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 ');
const scope = where.join(' AND ');
// How many listings the reader's filters actually match, counted exactly rather than inferred from the page.
// 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));
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) {
@@ -132,14 +157,24 @@ async function facets(s, f) {
const args = [valueOf.q(f), valueOf.location(f)];
const args = [valueOf.q(f), valueOf.location(f)];
const scope = `expired = false AND ${PARAM.q(1)} AND ${PARAM.location(2)}`;
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);
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(
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, '') <> ''
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 };
const remote = { true: 0, false: 0 };
for (const r of remoteRows) remote[r.remote ? 'true' : 'false'] = num(r.n);
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.
// `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(
@@ -232,12 +267,7 @@ const listSalaried = (s, limit = 4) => s.db.many(
const recentIds = (s, limit = 1000) => 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))]);
`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 ───────────────────────────────────────────
// ── ingest writes ───────────────────────────────────────────
/**
/**
@@ -344,7 +374,10 @@ const deleteSaved = (s, subject, id) => (SAVED_ID.test(String(id)) ? s.db.exec('
@@ -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. */
/** The count of listings that first appeared after the saved search was last looked at. */
async function newCount(s, row) {
async function newCount(s, row) {
const f = filtersOf(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"). */
/** Mark a saved search as looked at (the moment its count stops being "new"). */
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
*/
*/
const { html, badge } = require('./html');
const { html, badge } = require('./html');
const { stripHtml } = require('../jobs/text');
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 MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@ -52,25 +53,13 @@ function salaryLine(row, { note = false } = {}) {
@@ -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) {
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. */
/** The source's name, linked to the original listing: the attribution its terms require, in one element. */
@@ -99,12 +88,13 @@ function initialTile(company) {
@@ -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. */
/** 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 }) {
function listingItem(row, { source }) {
const l = meta(row);
const l = meta(row);
+ const typeText = jobTypeLabel(l.job_type);
return html`<li class="job-item" id="${l.id}">
return html`<li class="job-item" id="${l.id}">
${initialTile(l.company)}
${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">${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)}
${salaryLine(row)}
<p class="job-excerpt">${l.excerpt}</p>
<p class="job-excerpt">${l.excerpt}</p>
${provenance(row, source)}
${provenance(row, source)}
@@ -136,7 +126,7 @@ function jobPosting(row, { siteUrl }) {
@@ -136,7 +126,7 @@ function jobPosting(row, { siteUrl }) {
publisher: { '@type': 'Organization', name: 'OpenVibe.Work', url: siteUrl },
publisher: { '@type': 'Organization', name: 'OpenVibe.Work', url: siteUrl },
};
};
if (l.posted_at) node.datePosted = l.posted_at;
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.remote) node.jobLocationType = 'TELECOMMUTE';
if (l.location) node.jobLocation = { '@type': 'Place', address: { '@type': 'PostalAddress', addressLocality: l.location } };
if (l.location) node.jobLocation = { '@type': 'Place', address: { '@type': 'PostalAddress', addressLocality: l.location } };
if (l.salary) node.baseSalary = { '@type': 'MonetaryAmount', description: l.salary };
if (l.salary) node.baseSalary = { '@type': 'MonetaryAmount', description: l.salary };
@@ -27,14 +27,14 @@ const INTERVAL_MS = 3_600_000;
@@ -27,14 +27,14 @@ const INTERVAL_MS = 3_600_000;
function describe(row) {
function describe(row) {
const l = listing.meta(row);
const l = listing.meta(row);
const src = sources.byId(row.source);
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);
const excerpt = listing.plainExcerpt(row);
return {
return {
listed: !row.expired,
listed: !row.expired,
title: `${l.title} at ${l.company}`,
title: `${l.title} at ${l.company}`,
summary: [where, l.salary].filter(Boolean).join(' · ') || excerpt.slice(0, 200) || null,
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'),
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' },
authorship: { mode: 'imported' },
provenance: [{
provenance: [{
service: 'work', type: 'board_listing', id: `${row.source}:${row.source_id}`.slice(0, 128),
service: 'work', type: 'board_listing', id: `${row.source}:${row.source_id}`.slice(0, 128),
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -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); });
@@ -119,7 +119,7 @@ const cardOf = (html, id) => {
@@ -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');
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 jobs = (await t.get('/api/v1/jobs?limit=100')).json().jobs;
const one = jobs.find((j) => j.job_type === 'full_time');
const one = jobs.find((j) => j.job_type === 'full_time');
const two = jobs.find((j) => j.job_type === 'permanent, full_time');
const two = jobs.find((j) => j.job_type === 'permanent, full_time');
@@ -129,7 +129,8 @@ const cardOf = (html, id) => {
@@ -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(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.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.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.
// 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;
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="job-type">Full time</span>'), 'the page does not say the job type in words');