/** * ============================================================================= * Job·Ka — Groupe KA * Auteur : Simon-Pierre Boucher * Contact : contact@spboucher.ai * Fichier : frontend/src/fiche/synthese.ts * Rôle : Synthèse DÉTERMINISTE de la fiche d'offre (aucun LLM, aucune donnée * inventée) : position du salaire face au marché de sa catégorie * (salary_context calculé côté API), constats « En bref », résumé. * Créé : 2026-09-07 * ============================================================================= */ import { Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, compactMoney, daysSince, formatDate, formatSalary, } from "../api"; import { Tone, NBSP } from "./ui"; export interface ComparaisonSalaire { tone: "good" | "ok" | "high"; // good = au-dessus du marché (avantage candidat) pct: number; // écart signé en % vs médiane de la catégorie label: string; court: string; ref: number; // médiane ($/an) n: number; categorie: string; } export function comparaisonSalaire(job: Job): ComparaisonSalaire | null { const ctx = job.salary_context; if (!ctx || ctx.job_mid == null || ctx.delta_pct == null || ctx.n < 20) return null; const pct = Math.round(ctx.delta_pct); const tone = pct >= 5 ? "good" : pct >= -5 ? "ok" : "high"; return { tone, pct, ref: ctx.median, n: ctx.n, categorie: ctx.category || "toutes catégories", label: tone === "good" ? "Au-dessus du marché" : tone === "ok" ? "Dans le marché" : "Sous le marché", court: `${pct > 0 ? "↑" : pct < 0 ? "↓" : "≈"} ${Math.abs(pct)}${NBSP}% vs médiane`, }; } export interface Constat { tone: Tone; titre: string; detail: string; cle: string; } export function enBref(job: Job): Constat[] { const out: Constat[] = []; const salaire = formatSalary(job); const cmp = comparaisonSalaire(job); // 1. salaire if (salaire && cmp) { out.push({ cle: "salaire", tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral", 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é", detail: `${salaire} · médiane ${compactMoney(cmp.ref)}/an sur ${cmp.n.toLocaleString("fr-CA")} offres (${cmp.categorie})` }); } else if (salaire) { out.push({ cle: "salaire", tone: "good", titre: "Salaire affiché", detail: `${salaire} — publié par l'employeur, jamais estimé par Job·Ka` }); } else { out.push({ cle: "salaire", tone: "neutral", titre: "Salaire non affiché", detail: "L'employeur n'a pas publié de rémunération" }); } // 2. offre directe / source if (job.is_direct !== false) 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` }); else 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` }); // 3. exigences const r = job.requirements ?? {}; const ex: string[] = []; if (typeof r.experience_years === "number") ex.push(`${r.experience_years} an${r.experience_years > 1 ? "s" : ""} d'expérience`); if (typeof r.education === "string" && r.education && r.education.toLowerCase() !== "aucun") ex.push(r.education); if (Array.isArray(r.languages) && r.languages.length) ex.push(r.languages.length === 2 ? "bilingue" : r.languages.join(", ")); if (ex.length) out.push({ cle: "exig", tone: "neutral", titre: "Exigences explicites", detail: ex.join(" · ") }); 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" }); // 4. mode et type const mode: string[] = []; if (job.work_mode) mode.push(MODE_FR[job.work_mode] ?? job.work_mode); if (job.employment_type) mode.push(TYPE_FR[job.employment_type] ?? job.employment_type); if (job.seniority) mode.push(SENIORITY_FR[job.seniority] ?? job.seniority); 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" }); // 5. fraîcheur et date limite const days = daysSince(job.date_posted); const deadlineIn = job.date_deadline ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000) : null; if (deadlineIn != null && deadlineIn >= 0 && deadlineIn <= 7) 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)}` }); else if (deadlineIn != null && deadlineIn < 0) 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` }); else if (days != null && days <= 3) 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" }); else if (days != null && days >= 45) out.push({ cle: "frais", tone: "neutral", titre: `En ligne depuis ${days} jours`, detail: "Offre ancienne — vérifiez qu'elle est toujours ouverte" }); // 6. employeur if (typeof job.employer_jobs === "number" && job.employer_jobs > 1) 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" }); // 7. avantages if (job.benefits && job.benefits.length >= 3) out.push({ cle: "benef", tone: "good", titre: `${job.benefits.length} avantages annoncés`, detail: job.benefits.slice(0, 3).join(" · ") }); return out.slice(0, 6); } /** Ligne résumé : « Présentiel · Temps plein · Senior · Montréal » */ export function ligneResume(job: Job): string[] { const p: string[] = []; if (job.work_mode) p.push(MODE_FR[job.work_mode] ?? job.work_mode); if (job.employment_type) p.push(TYPE_FR[job.employment_type] ?? job.employment_type); if (job.seniority) p.push(SENIORITY_FR[job.seniority] ?? job.seniority); if (job.city) p.push(job.city); return p; }