SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
6.5 KB · 112 lines typescript
Raw Blame History
1/**2 * =============================================================================3 * Job·Ka — Groupe KA4 * Auteur  : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * Fichier : frontend/src/fiche/synthese.ts7 * Rôle    : Synthèse DÉTERMINISTE de la fiche d'offre (aucun LLM, aucune donnée8 *           inventée) : position du salaire face au marché de sa catégorie9 *           (salary_context calculé côté API), constats « En bref », résumé.10 * Créé    : 2026-09-0711 * =============================================================================12 */13import {14  Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, compactMoney, daysSince, formatDate, formatSalary,15} from "../api";16import { Tone, NBSP } from "./ui";1718export interface ComparaisonSalaire {19  tone: "good" | "ok" | "high";   // good = au-dessus du marché (avantage candidat)20  pct: number;                    // écart signé en % vs médiane de la catégorie21  label: string;22  court: string;23  ref: number;                    // médiane ($/an)24  n: number;25  categorie: string;26}2728export function comparaisonSalaire(job: Job): ComparaisonSalaire | null {29  const ctx = job.salary_context;30  if (!ctx || ctx.job_mid == null || ctx.delta_pct == null || ctx.n < 20) return null;31  const pct = Math.round(ctx.delta_pct);32  const tone = pct >= 5 ? "good" : pct >= -5 ? "ok" : "high";33  return {34    tone, pct, ref: ctx.median, n: ctx.n, categorie: ctx.category || "toutes catégories",35    label: tone === "good" ? "Au-dessus du marché" : tone === "ok" ? "Dans le marché" : "Sous le marché",36    court: `${pct > 0 ? "↑" : pct < 0 ? "↓" : "≈"} ${Math.abs(pct)}${NBSP}% vs médiane`,37  };38}3940export interface Constat { tone: Tone; titre: string; detail: string; cle: string; }4142export function enBref(job: Job): Constat[] {43  const out: Constat[] = [];44  const salaire = formatSalary(job);45  const cmp = comparaisonSalaire(job);4647  // 1. salaire48  if (salaire && cmp) {49    out.push({ cle: "salaire", tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral",50               titre: cmp.tone === "good" ? `Salaire ${Math.abs(cmp.pct)}${NBSP}% au-dessus du marché` : cmp.tone === "high" ? `Salaire ${Math.abs(cmp.pct)}${NBSP}% sous le marché` : "Salaire dans le marché",51               detail: `${salaire} · médiane ${compactMoney(cmp.ref)}/an sur ${cmp.n.toLocaleString("fr-CA")} offres (${cmp.categorie})` });52  } else if (salaire) {53    out.push({ cle: "salaire", tone: "good", titre: "Salaire affiché", detail: `${salaire} — publié par l'employeur, jamais estimé par Job·Ka` });54  } else {55    out.push({ cle: "salaire", tone: "neutral", titre: "Salaire non affiché", detail: "L'employeur n'a pas publié de rémunération" });56  }5758  // 2. offre directe / source59  if (job.is_direct !== false)60    out.push({ cle: "direct", tone: "good", titre: "Offre publiée à la source", detail: `Page carrière de ${job.employer}${job.ats ? ` (${job.ats})` : ""} — pas un portail intermédiaire` });61  else62    out.push({ cle: "direct", tone: "neutral", titre: "Offre relayée par un portail", detail: `${job.ats || job.source} — la candidature se fait chez l'employeur` });6364  // 3. exigences65  const r = job.requirements ?? {};66  const ex: string[] = [];67  if (typeof r.experience_years === "number") ex.push(`${r.experience_years} an${r.experience_years > 1 ? "s" : ""} d'expérience`);68  if (typeof r.education === "string" && r.education && r.education.toLowerCase() !== "aucun") ex.push(r.education);69  if (Array.isArray(r.languages) && r.languages.length) ex.push(r.languages.length === 2 ? "bilingue" : r.languages.join(", "));70  if (ex.length) out.push({ cle: "exig", tone: "neutral", titre: "Exigences explicites", detail: ex.join(" · ") });71  else if (typeof r.education === "string" && r.education.toLowerCase() === "aucun") out.push({ cle: "exig", tone: "good", titre: "Aucun diplôme exigé", detail: "Extrait du texte de l'offre" });7273  // 4. mode et type74  const mode: string[] = [];75  if (job.work_mode) mode.push(MODE_FR[job.work_mode] ?? job.work_mode);76  if (job.employment_type) mode.push(TYPE_FR[job.employment_type] ?? job.employment_type);77  if (job.seniority) mode.push(SENIORITY_FR[job.seniority] ?? job.seniority);78  if (mode.length) out.push({ cle: "mode", tone: job.work_mode === "teletravail" ? "good" : "info", titre: mode.join(" · "), detail: job.language ? `Langue de travail : ${LANG_FR[job.language] ?? job.language}` : "Conditions publiées par l'employeur" });7980  // 5. fraîcheur et date limite81  const days = daysSince(job.date_posted);82  const deadlineIn = job.date_deadline ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000) : null;83  if (deadlineIn != null && deadlineIn >= 0 && deadlineIn <= 7)84    out.push({ cle: "limite", tone: "warn", titre: deadlineIn === 0 ? "Dernier jour pour postuler" : `Plus que ${deadlineIn} jour${deadlineIn > 1 ? "s" : ""} pour postuler`, detail: `Date limite : ${formatDate(job.date_deadline)}` });85  else if (deadlineIn != null && deadlineIn < 0)86    out.push({ cle: "limite", tone: "bad", titre: "Date limite dépassée", detail: `Limite publiée : ${formatDate(job.date_deadline)} — l'offre peut rester affichée par l'employeur` });87  else if (days != null && days <= 3)88    out.push({ cle: "frais", tone: "good", titre: days === 0 ? "Publiée aujourd'hui" : `Publiée il y a ${days} jour${days > 1 ? "s" : ""}`, detail: "Offre récente — candidatures probablement encore ouvertes" });89  else if (days != null && days >= 45)90    out.push({ cle: "frais", tone: "neutral", titre: `En ligne depuis ${days} jours`, detail: "Offre ancienne — vérifiez qu'elle est toujours ouverte" });9192  // 6. employeur93  if (typeof job.employer_jobs === "number" && job.employer_jobs > 1)94    out.push({ cle: "emp", tone: "info", titre: `${job.employer} recrute pour ${job.employer_jobs} postes`, detail: "Offres actives suivies par Job·Ka chez cet employeur" });9596  // 7. avantages97  if (job.benefits && job.benefits.length >= 3)98    out.push({ cle: "benef", tone: "good", titre: `${job.benefits.length} avantages annoncés`, detail: job.benefits.slice(0, 3).join(" · ") });99100  return out.slice(0, 6);101}102103/** Ligne résumé : « Présentiel · Temps plein · Senior · Montréal » */104export function ligneResume(job: Job): string[] {105  const p: string[] = [];106  if (job.work_mode) p.push(MODE_FR[job.work_mode] ?? job.work_mode);107  if (job.employment_type) p.push(TYPE_FR[job.employment_type] ?? job.employment_type);108  if (job.seniority) p.push(SENIORITY_FR[job.seniority] ?? job.seniority);109  if (job.city) p.push(job.city);110  return p;111}112