HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
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